test_forms.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import tempfile
  2. from pathlib import Path
  3. from django.core.exceptions import NON_FIELD_ERRORS
  4. from django.core.files.uploadedfile import SimpleUploadedFile
  5. from django.test import TestCase
  6. from core.choices import ManagedFileRootPathChoices
  7. from core.models import DataSource, ObjectType
  8. from dcim.forms import SiteForm
  9. from dcim.models import Site
  10. from extras.choices import CustomFieldTypeChoices
  11. from extras.forms import SavedFilterForm
  12. from extras.forms.model_forms import CustomFieldChoiceSetForm
  13. from extras.forms.scripts import ScriptFileForm
  14. from extras.models import CustomField, CustomFieldChoiceSet, ScriptModule
  15. class CustomFieldModelFormTestCase(TestCase):
  16. @classmethod
  17. def setUpTestData(cls):
  18. object_type = ObjectType.objects.get_for_model(Site)
  19. choice_set = CustomFieldChoiceSet.objects.create(
  20. name='Choice Set 1',
  21. extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
  22. )
  23. cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
  24. cf_text.object_types.set([object_type])
  25. cf_longtext = CustomField.objects.create(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT)
  26. cf_longtext.object_types.set([object_type])
  27. cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER)
  28. cf_integer.object_types.set([object_type])
  29. cf_integer = CustomField.objects.create(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL)
  30. cf_integer.object_types.set([object_type])
  31. cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
  32. cf_boolean.object_types.set([object_type])
  33. cf_date = CustomField.objects.create(name='date', type=CustomFieldTypeChoices.TYPE_DATE)
  34. cf_date.object_types.set([object_type])
  35. cf_datetime = CustomField.objects.create(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME)
  36. cf_datetime.object_types.set([object_type])
  37. cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL)
  38. cf_url.object_types.set([object_type])
  39. cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON)
  40. cf_json.object_types.set([object_type])
  41. cf_select = CustomField.objects.create(
  42. name='select',
  43. type=CustomFieldTypeChoices.TYPE_SELECT,
  44. choice_set=choice_set
  45. )
  46. cf_select.object_types.set([object_type])
  47. cf_multiselect = CustomField.objects.create(
  48. name='multiselect',
  49. type=CustomFieldTypeChoices.TYPE_MULTISELECT,
  50. choice_set=choice_set
  51. )
  52. cf_multiselect.object_types.set([object_type])
  53. cf_object = CustomField.objects.create(
  54. name='object',
  55. type=CustomFieldTypeChoices.TYPE_OBJECT,
  56. related_object_type=ObjectType.objects.get_for_model(Site)
  57. )
  58. cf_object.object_types.set([object_type])
  59. cf_multiobject = CustomField.objects.create(
  60. name='multiobject',
  61. type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
  62. related_object_type=ObjectType.objects.get_for_model(Site)
  63. )
  64. cf_multiobject.object_types.set([object_type])
  65. def test_empty_values(self):
  66. """
  67. Test that empty custom field values are stored as null
  68. """
  69. form = SiteForm({
  70. 'name': 'Site 1',
  71. 'slug': 'site-1',
  72. 'status': 'active',
  73. })
  74. self.assertTrue(form.is_valid())
  75. instance = form.save()
  76. for field_type, _ in CustomFieldTypeChoices.CHOICES:
  77. self.assertIn(field_type, instance.custom_field_data)
  78. self.assertIsNone(instance.custom_field_data[field_type])
  79. class CustomFieldChoiceSetFormTestCase(TestCase):
  80. def test_escaped_colons_preserved_on_edit(self):
  81. choice_set = CustomFieldChoiceSet.objects.create(
  82. name='Test Choice Set',
  83. extra_choices=[['foo:bar', 'label'], ['value', 'label:with:colons']]
  84. )
  85. form = CustomFieldChoiceSetForm(instance=choice_set)
  86. initial_choices = form.initial['extra_choices']
  87. # colons are re-escaped
  88. self.assertEqual(initial_choices, 'foo\\:bar:label\nvalue:label\\:with\\:colons')
  89. form = CustomFieldChoiceSetForm(
  90. {'name': choice_set.name, 'extra_choices': initial_choices},
  91. instance=choice_set
  92. )
  93. self.assertTrue(form.is_valid())
  94. updated = form.save()
  95. # cleaned extra choices are correct, which does actually mean a list of tuples
  96. self.assertEqual(updated.extra_choices, [('foo:bar', 'label'), ('value', 'label:with:colons')])
  97. def test_choice_colors_round_trip_on_edit(self):
  98. choice_set = CustomFieldChoiceSet.objects.create(
  99. name='Test Choice Set',
  100. extra_choices=[['foo:bar', 'label'], ['choice2', 'Choice 2']],
  101. choice_colors={'foo:bar': 'red', 'choice2': 'green'},
  102. )
  103. form = CustomFieldChoiceSetForm(instance=choice_set)
  104. initial_choices = form.initial['extra_choices']
  105. initial_choice_colors = form.initial['choice_colors']
  106. self.assertEqual(initial_choice_colors, 'choice2:green\nfoo\\:bar:red')
  107. form = CustomFieldChoiceSetForm(
  108. {
  109. 'name': choice_set.name,
  110. 'extra_choices': initial_choices,
  111. 'choice_colors': initial_choice_colors,
  112. },
  113. instance=choice_set,
  114. )
  115. self.assertTrue(form.is_valid())
  116. updated = form.save()
  117. self.assertEqual(updated.choice_colors, {'choice2': 'green', 'foo:bar': 'red'})
  118. class SavedFilterFormTestCase(TestCase):
  119. def test_basic_submit(self):
  120. """
  121. Test form submission and validation
  122. """
  123. form = SavedFilterForm({
  124. 'name': 'test-sf',
  125. 'slug': 'test-sf',
  126. 'object_types': [ObjectType.objects.get_for_model(Site).pk],
  127. 'weight': 100,
  128. 'parameters': {
  129. "status": [
  130. "active"
  131. ]
  132. }
  133. })
  134. self.assertTrue(form.is_valid())
  135. form.save()
  136. class ScriptFileFormTestCase(TestCase):
  137. """
  138. Scripts added via a Data Source must be validated the same way uploaded scripts are (see #22180).
  139. """
  140. BROKEN_SCRIPT = (
  141. "from extras.scripts import Script\n"
  142. "import imnotarealmoduleicreateerrors\n\n\n"
  143. "class BrokenScript(Script):\n"
  144. " def run(self, data, commit):\n"
  145. " pass\n"
  146. )
  147. VALID_SCRIPT = (
  148. "from extras.scripts import Script\n\n\n"
  149. "class FirstScript(Script):\n"
  150. " def run(self, data, commit):\n"
  151. " pass\n\n\n"
  152. "class SecondScript(Script):\n"
  153. " def run(self, data, commit):\n"
  154. " pass\n"
  155. )
  156. @staticmethod
  157. def _write(scripts_dir, filename, content):
  158. with open(scripts_dir / filename, 'w') as f:
  159. f.write(content)
  160. @staticmethod
  161. def _new_module():
  162. # Mirror ScriptModuleCreateView.alter_object(), which sets file_root before validation.
  163. return ScriptModule(file_root=ManagedFileRootPathChoices.SCRIPTS)
  164. def _sync_source(self, name, **files):
  165. """
  166. Create a local DataSource over a temp dir populated with the given {filename: content} files,
  167. sync it, and return the DataSource.
  168. """
  169. temp_dir = tempfile.TemporaryDirectory()
  170. self.addCleanup(temp_dir.cleanup)
  171. scripts_dir = Path(temp_dir.name) / "scripts"
  172. scripts_dir.mkdir(parents=True, exist_ok=True)
  173. for filename, content in files.items():
  174. self._write(scripts_dir, filename, content)
  175. data_source = DataSource(name=name, type="local", source_url=str(scripts_dir))
  176. data_source.save()
  177. data_source.sync()
  178. return data_source
  179. def test_broken_script_via_data_file_is_rejected(self):
  180. """A script that fails to import via a data_file must be rejected, and no ScriptModule created."""
  181. data_source = self._sync_source("Broken", **{'broken.py': self.BROKEN_SCRIPT})
  182. data_file = data_source.datafiles.get(path__endswith='broken.py')
  183. form = ScriptFileForm(data={'data_file': data_file.pk}, instance=self._new_module())
  184. self.assertFalse(form.is_valid())
  185. self.assertIn(NON_FIELD_ERRORS, form.errors)
  186. self.assertEqual(ScriptModule.objects.count(), 0)
  187. def test_valid_script_via_data_file_is_accepted(self):
  188. """A valid script via a data_file passes validation and its Script classes are discovered on save."""
  189. data_source = self._sync_source("Valid", **{'valid.py': self.VALID_SCRIPT})
  190. data_file = data_source.datafiles.get(path__endswith='valid.py')
  191. form = ScriptFileForm(data={'data_file': data_file.pk}, instance=self._new_module())
  192. self.assertTrue(form.is_valid())
  193. module = form.save()
  194. self.assertEqual(ScriptModule.objects.count(), 1)
  195. self.assertEqual(
  196. {script.name for script in module.scripts.all()},
  197. {'FirstScript', 'SecondScript'},
  198. )
  199. def test_corrected_script_recovers(self):
  200. """After a broken script is rejected, syncing a corrected version succeeds without a uniqueness deadlock."""
  201. temp_dir = tempfile.TemporaryDirectory()
  202. self.addCleanup(temp_dir.cleanup)
  203. scripts_dir = Path(temp_dir.name) / "scripts"
  204. scripts_dir.mkdir(parents=True, exist_ok=True)
  205. data_source = DataSource(name="Recovery", type="local", source_url=str(scripts_dir))
  206. data_source.save()
  207. # First sync: broken script is rejected, nothing created
  208. self._write(scripts_dir, 'myscript.py', self.BROKEN_SCRIPT)
  209. data_source.sync()
  210. data_file = data_source.datafiles.get(path__endswith='myscript.py')
  211. form = ScriptFileForm(data={'data_file': data_file.pk}, instance=self._new_module())
  212. self.assertFalse(form.is_valid())
  213. self.assertEqual(ScriptModule.objects.count(), 0)
  214. # Correct the script and re-sync: now it should be accepted
  215. self._write(scripts_dir, 'myscript.py', self.VALID_SCRIPT)
  216. data_source.sync()
  217. data_file = data_source.datafiles.get(path__endswith='myscript.py')
  218. form = ScriptFileForm(data={'data_file': data_file.pk}, instance=self._new_module())
  219. self.assertTrue(form.is_valid())
  220. module = form.save()
  221. self.assertEqual(
  222. {script.name for script in module.scripts.all()},
  223. {'FirstScript', 'SecondScript'},
  224. )
  225. def test_broken_script_via_upload_is_rejected(self):
  226. """Regression guard: the upload_file path still validates content."""
  227. upload_file = SimpleUploadedFile(name='broken.py', content=self.BROKEN_SCRIPT.encode())
  228. form = ScriptFileForm(files={'upload_file': upload_file}, instance=self._new_module())
  229. self.assertFalse(form.is_valid())
  230. self.assertIn(NON_FIELD_ERRORS, form.errors)
  231. def test_valid_script_via_upload_is_accepted(self):
  232. """Regression guard: a valid uploaded script still validates."""
  233. upload_file = SimpleUploadedFile(name='valid.py', content=self.VALID_SCRIPT.encode())
  234. form = ScriptFileForm(files={'upload_file': upload_file}, instance=self._new_module())
  235. self.assertTrue(form.is_valid())