test_forms.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732
  1. import tempfile
  2. from pathlib import Path
  3. from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
  4. from django.core.files.uploadedfile import SimpleUploadedFile
  5. from django.test import TestCase
  6. from core.choices import ManagedFileRootPathChoices
  7. from core.events import OBJECT_CREATED
  8. from core.models import DataSource, ObjectType
  9. from dcim.forms import SiteForm
  10. from dcim.models import Site
  11. from extras.choices import CustomFieldTypeChoices, EventRuleActionChoices
  12. from extras.forms import SavedFilterForm, TableConfigBulkEditForm, TableConfigForm
  13. from extras.forms.bulk_edit import EventRuleBulkEditForm
  14. from extras.forms.bulk_import import EventRuleImportForm
  15. from extras.forms.filtersets import EventRuleFilterForm
  16. from extras.forms.model_forms import CustomFieldChoiceSetForm, EventRuleForm
  17. from extras.forms.scripts import ScriptFileForm
  18. from extras.models import CustomField, CustomFieldChoiceSet, EventRule, NotificationGroup, Script, ScriptModule, Webhook
  19. from netbox.event_rules import EventRuleAction, register_event_rule_action
  20. from netbox.events import EventType
  21. from netbox.registry import registry
  22. from utilities.forms.widgets import HTMXSelect
  23. class CustomFieldModelFormTestCase(TestCase):
  24. @classmethod
  25. def setUpTestData(cls):
  26. object_type = ObjectType.objects.get_for_model(Site)
  27. choice_set = CustomFieldChoiceSet.objects.create(
  28. name='Choice Set 1',
  29. extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
  30. )
  31. cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
  32. cf_text.object_types.set([object_type])
  33. cf_longtext = CustomField.objects.create(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT)
  34. cf_longtext.object_types.set([object_type])
  35. cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER)
  36. cf_integer.object_types.set([object_type])
  37. cf_integer = CustomField.objects.create(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL)
  38. cf_integer.object_types.set([object_type])
  39. cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
  40. cf_boolean.object_types.set([object_type])
  41. cf_date = CustomField.objects.create(name='date', type=CustomFieldTypeChoices.TYPE_DATE)
  42. cf_date.object_types.set([object_type])
  43. cf_datetime = CustomField.objects.create(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME)
  44. cf_datetime.object_types.set([object_type])
  45. cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL)
  46. cf_url.object_types.set([object_type])
  47. cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON)
  48. cf_json.object_types.set([object_type])
  49. cf_select = CustomField.objects.create(
  50. name='select',
  51. type=CustomFieldTypeChoices.TYPE_SELECT,
  52. choice_set=choice_set
  53. )
  54. cf_select.object_types.set([object_type])
  55. cf_multiselect = CustomField.objects.create(
  56. name='multiselect',
  57. type=CustomFieldTypeChoices.TYPE_MULTISELECT,
  58. choice_set=choice_set
  59. )
  60. cf_multiselect.object_types.set([object_type])
  61. cf_object = CustomField.objects.create(
  62. name='object',
  63. type=CustomFieldTypeChoices.TYPE_OBJECT,
  64. related_object_type=ObjectType.objects.get_for_model(Site)
  65. )
  66. cf_object.object_types.set([object_type])
  67. cf_multiobject = CustomField.objects.create(
  68. name='multiobject',
  69. type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
  70. related_object_type=ObjectType.objects.get_for_model(Site)
  71. )
  72. cf_multiobject.object_types.set([object_type])
  73. def test_empty_values(self):
  74. """
  75. Test that empty custom field values are stored as null
  76. """
  77. form = SiteForm({
  78. 'name': 'Site 1',
  79. 'slug': 'site-1',
  80. 'status': 'active',
  81. })
  82. self.assertTrue(form.is_valid())
  83. instance = form.save()
  84. for field_type, _ in CustomFieldTypeChoices.CHOICES:
  85. self.assertIn(field_type, instance.custom_field_data)
  86. self.assertIsNone(instance.custom_field_data[field_type])
  87. class CustomFieldChoiceSetFormTestCase(TestCase):
  88. def test_escaped_colons_preserved_on_edit(self):
  89. choice_set = CustomFieldChoiceSet.objects.create(
  90. name='Test Choice Set',
  91. extra_choices=[['foo:bar', 'label'], ['value', 'label:with:colons']]
  92. )
  93. form = CustomFieldChoiceSetForm(instance=choice_set)
  94. initial_choices = form.initial['extra_choices']
  95. # colons are re-escaped
  96. self.assertEqual(initial_choices, 'foo\\:bar:label\nvalue:label\\:with\\:colons')
  97. form = CustomFieldChoiceSetForm(
  98. {'name': choice_set.name, 'extra_choices': initial_choices},
  99. instance=choice_set
  100. )
  101. self.assertTrue(form.is_valid())
  102. updated = form.save()
  103. # cleaned extra choices are correct, which does actually mean a list of tuples
  104. self.assertEqual(updated.extra_choices, [('foo:bar', 'label'), ('value', 'label:with:colons')])
  105. def test_choice_colors_round_trip_on_edit(self):
  106. choice_set = CustomFieldChoiceSet.objects.create(
  107. name='Test Choice Set',
  108. extra_choices=[['foo:bar', 'label'], ['choice2', 'Choice 2']],
  109. choice_colors={'foo:bar': 'red', 'choice2': 'green'},
  110. )
  111. form = CustomFieldChoiceSetForm(instance=choice_set)
  112. initial_choices = form.initial['extra_choices']
  113. initial_choice_colors = form.initial['choice_colors']
  114. self.assertEqual(initial_choice_colors, 'choice2:green\nfoo\\:bar:red')
  115. form = CustomFieldChoiceSetForm(
  116. {
  117. 'name': choice_set.name,
  118. 'extra_choices': initial_choices,
  119. 'choice_colors': initial_choice_colors,
  120. },
  121. instance=choice_set,
  122. )
  123. self.assertTrue(form.is_valid())
  124. updated = form.save()
  125. self.assertEqual(updated.choice_colors, {'choice2': 'green', 'foo:bar': 'red'})
  126. class SavedFilterFormTestCase(TestCase):
  127. def test_basic_submit(self):
  128. """
  129. Test form submission and validation
  130. """
  131. form = SavedFilterForm({
  132. 'name': 'test-sf',
  133. 'slug': 'test-sf',
  134. 'object_types': [ObjectType.objects.get_for_model(Site).pk],
  135. 'weight': 100,
  136. 'parameters': {
  137. "status": [
  138. "active"
  139. ]
  140. }
  141. })
  142. self.assertTrue(form.is_valid())
  143. form.save()
  144. class ScriptFileFormTestCase(TestCase):
  145. """
  146. Scripts added via a Data Source must be validated the same way uploaded scripts are (see #22180).
  147. """
  148. BROKEN_SCRIPT = (
  149. "from extras.scripts import Script\n"
  150. "import imnotarealmoduleicreateerrors\n\n\n"
  151. "class BrokenScript(Script):\n"
  152. " def run(self, data, commit):\n"
  153. " pass\n"
  154. )
  155. VALID_SCRIPT = (
  156. "from extras.scripts import Script\n\n\n"
  157. "class FirstScript(Script):\n"
  158. " def run(self, data, commit):\n"
  159. " pass\n\n\n"
  160. "class SecondScript(Script):\n"
  161. " def run(self, data, commit):\n"
  162. " pass\n"
  163. )
  164. @staticmethod
  165. def _write(scripts_dir, filename, content):
  166. with open(scripts_dir / filename, 'w') as f:
  167. f.write(content)
  168. @staticmethod
  169. def _new_module():
  170. # Mirror ScriptModuleCreateView.alter_object(), which sets file_root before validation.
  171. return ScriptModule(file_root=ManagedFileRootPathChoices.SCRIPTS)
  172. def _sync_source(self, name, **files):
  173. """
  174. Create a local DataSource over a temp dir populated with the given {filename: content} files,
  175. sync it, and return the DataSource.
  176. """
  177. temp_dir = tempfile.TemporaryDirectory()
  178. self.addCleanup(temp_dir.cleanup)
  179. scripts_dir = Path(temp_dir.name) / "scripts"
  180. scripts_dir.mkdir(parents=True, exist_ok=True)
  181. for filename, content in files.items():
  182. self._write(scripts_dir, filename, content)
  183. data_source = DataSource(name=name, type="local", source_url=str(scripts_dir))
  184. data_source.save()
  185. data_source.sync()
  186. return data_source
  187. def test_broken_script_via_data_file_is_rejected(self):
  188. """A script that fails to import via a data_file must be rejected, and no ScriptModule created."""
  189. data_source = self._sync_source("Broken", **{'broken.py': self.BROKEN_SCRIPT})
  190. data_file = data_source.datafiles.get(path__endswith='broken.py')
  191. form = ScriptFileForm(data={'data_file': data_file.pk}, instance=self._new_module())
  192. self.assertFalse(form.is_valid())
  193. self.assertIn(NON_FIELD_ERRORS, form.errors)
  194. self.assertEqual(ScriptModule.objects.count(), 0)
  195. def test_valid_script_via_data_file_is_accepted(self):
  196. """A valid script via a data_file passes validation and its Script classes are discovered on save."""
  197. data_source = self._sync_source("Valid", **{'valid.py': self.VALID_SCRIPT})
  198. data_file = data_source.datafiles.get(path__endswith='valid.py')
  199. form = ScriptFileForm(data={'data_file': data_file.pk}, instance=self._new_module())
  200. self.assertTrue(form.is_valid())
  201. module = form.save()
  202. self.assertEqual(ScriptModule.objects.count(), 1)
  203. self.assertEqual(
  204. {script.name for script in module.scripts.all()},
  205. {'FirstScript', 'SecondScript'},
  206. )
  207. def test_corrected_script_recovers(self):
  208. """After a broken script is rejected, syncing a corrected version succeeds without a uniqueness deadlock."""
  209. temp_dir = tempfile.TemporaryDirectory()
  210. self.addCleanup(temp_dir.cleanup)
  211. scripts_dir = Path(temp_dir.name) / "scripts"
  212. scripts_dir.mkdir(parents=True, exist_ok=True)
  213. data_source = DataSource(name="Recovery", type="local", source_url=str(scripts_dir))
  214. data_source.save()
  215. # First sync: broken script is rejected, nothing created
  216. self._write(scripts_dir, 'myscript.py', self.BROKEN_SCRIPT)
  217. data_source.sync()
  218. data_file = data_source.datafiles.get(path__endswith='myscript.py')
  219. form = ScriptFileForm(data={'data_file': data_file.pk}, instance=self._new_module())
  220. self.assertFalse(form.is_valid())
  221. self.assertEqual(ScriptModule.objects.count(), 0)
  222. # Correct the script and re-sync: now it should be accepted
  223. self._write(scripts_dir, 'myscript.py', self.VALID_SCRIPT)
  224. data_source.sync()
  225. data_file = data_source.datafiles.get(path__endswith='myscript.py')
  226. form = ScriptFileForm(data={'data_file': data_file.pk}, instance=self._new_module())
  227. self.assertTrue(form.is_valid())
  228. module = form.save()
  229. self.assertEqual(
  230. {script.name for script in module.scripts.all()},
  231. {'FirstScript', 'SecondScript'},
  232. )
  233. def test_broken_script_via_upload_is_rejected(self):
  234. """Regression guard: the upload_file path still validates content."""
  235. upload_file = SimpleUploadedFile(name='broken.py', content=self.BROKEN_SCRIPT.encode())
  236. form = ScriptFileForm(files={'upload_file': upload_file}, instance=self._new_module())
  237. self.assertFalse(form.is_valid())
  238. self.assertIn(NON_FIELD_ERRORS, form.errors)
  239. def test_valid_script_via_upload_is_accepted(self):
  240. """Regression guard: a valid uploaded script still validates."""
  241. upload_file = SimpleUploadedFile(name='valid.py', content=self.VALID_SCRIPT.encode())
  242. form = ScriptFileForm(files={'upload_file': upload_file}, instance=self._new_module())
  243. self.assertTrue(form.is_valid())
  244. class TableConfigFormTestCase(TestCase):
  245. def test_form_without_table_context(self):
  246. """The form must be constructible without an object type."""
  247. form = TableConfigForm()
  248. self.assertEqual(list(form.fields['available_columns'].widget.choices), [])
  249. self.assertEqual(list(form.fields['columns'].widget.choices), [])
  250. def test_form_with_invalid_object_type(self):
  251. """An unknown object type must yield empty column choices."""
  252. last_pk = ObjectType.objects.order_by('pk').last().pk
  253. form = TableConfigForm(initial={'object_type': last_pk + 1})
  254. self.assertEqual(list(form.fields['available_columns'].widget.choices), [])
  255. self.assertEqual(list(form.fields['columns'].widget.choices), [])
  256. def test_form_with_unknown_table(self):
  257. """An unresolvable table name must yield empty column choices."""
  258. object_type = ObjectType.objects.get_for_model(Site)
  259. form = TableConfigForm(initial={'object_type': object_type.pk, 'table': 'NoSuchTable'})
  260. self.assertEqual(list(form.fields['columns'].widget.choices), [])
  261. def test_form_with_table_context(self):
  262. """Column choices must be populated from the resolved table."""
  263. object_type = ObjectType.objects.get_for_model(Site)
  264. form = TableConfigForm(initial={
  265. 'object_type': object_type.pk,
  266. 'table': 'SiteTable',
  267. 'columns': ['name', 'status'],
  268. })
  269. self.assertEqual(
  270. [name for name, _ in form.fields['columns'].widget.choices],
  271. ['name', 'status']
  272. )
  273. self.assertIn('region', dict(form.fields['available_columns'].widget.choices))
  274. def test_form_includes_changelog_message(self):
  275. """The model form must expose the changelog_message meta field."""
  276. object_type = ObjectType.objects.get_for_model(Site)
  277. form = TableConfigForm(initial={'object_type': object_type.pk, 'table': 'SiteTable'})
  278. self.assertIn('changelog_message', form.fields)
  279. self.assertIn('changelog_message', form.meta_fields)
  280. def test_bulk_edit_form_includes_changelog_message(self):
  281. """The bulk edit form must expose the changelog_message meta field."""
  282. form = TableConfigBulkEditForm()
  283. self.assertIn('changelog_message', form.fields)
  284. self.assertIn('changelog_message', form.meta_fields)
  285. class EventRuleFormTestCase(TestCase):
  286. """
  287. EventRuleForm's action_choice field is built dynamically from the EventRuleAction registry,
  288. for both core actions and those registered by a plugin.
  289. """
  290. def tearDown(self):
  291. super().tearDown()
  292. registry['event_rule_actions'].pop('test.form_no_object_action', None)
  293. def test_action_type_widget_is_htmx_select(self):
  294. """
  295. action_choice refreshes via HTMX when action_type changes. The widget must be set on the
  296. field itself: Meta.widgets applies only to fields the ModelForm generates from the model,
  297. and action_type is declared explicitly.
  298. """
  299. form = EventRuleForm()
  300. widget = form.fields['action_type'].widget
  301. self.assertIsInstance(widget, HTMXSelect)
  302. self.assertEqual(widget.attrs.get('hx-target'), '#event-rule-action')
  303. def test_action_choice_field_for_webhook(self):
  304. webhook = Webhook.objects.create(name='Form Test Webhook', payload_url='http://localhost:9000/')
  305. form = EventRuleForm(data={'action_type': EventRuleActionChoices.WEBHOOK})
  306. self.assertIn('action_choice', form.fields)
  307. self.assertIn(webhook, form.fields['action_choice'].queryset)
  308. def test_action_choice_field_for_script(self):
  309. form = EventRuleForm(data={'action_type': EventRuleActionChoices.SCRIPT})
  310. self.assertIn('action_choice', form.fields)
  311. self.assertEqual(form.fields['action_choice'].queryset.model, Script)
  312. def test_action_choice_field_for_notification(self):
  313. form = EventRuleForm(data={'action_type': EventRuleActionChoices.NOTIFICATION})
  314. self.assertIn('action_choice', form.fields)
  315. self.assertEqual(form.fields['action_choice'].queryset.model, NotificationGroup)
  316. def test_action_choice_field_labels(self):
  317. """The object picker is labeled for the object being selected, not for the action itself."""
  318. for action_type, label in (
  319. (EventRuleActionChoices.WEBHOOK, 'Webhook'),
  320. (EventRuleActionChoices.SCRIPT, 'Script'),
  321. (EventRuleActionChoices.NOTIFICATION, 'Notification group'),
  322. ):
  323. form = EventRuleForm(data={'action_type': action_type})
  324. self.assertEqual(form.fields['action_choice'].label, label)
  325. def test_action_choice_field_honors_object_label(self):
  326. class LabeledObjectAction(EventRuleAction):
  327. slug = 'test.form_labeled_object_action'
  328. label = 'Form Labeled Object Action'
  329. object_model = Webhook
  330. object_label = 'Destination'
  331. register_event_rule_action(LabeledObjectAction)
  332. self.addCleanup(registry['event_rule_actions'].pop, LabeledObjectAction.slug, None)
  333. form = EventRuleForm(data={'action_type': LabeledObjectAction.slug})
  334. self.assertEqual(form.fields['action_choice'].label, 'Destination')
  335. def test_action_choice_field_omitted_for_registered_no_object_action(self):
  336. class NoObjectAction(EventRuleAction):
  337. slug = 'test.form_no_object_action'
  338. label = 'Form No-Object Action'
  339. object_required = False
  340. register_event_rule_action(NoObjectAction)
  341. form = EventRuleForm(data={'action_type': 'test.form_no_object_action'})
  342. self.assertNotIn('action_choice', form.fields)
  343. def test_action_choice_field_falls_back_to_initial_for_unregistered_action(self):
  344. """
  345. get_field_value() falls back to the field's own initial (webhook) for an unregistered
  346. action_type, so init_action_choice() still builds a usable picker.
  347. """
  348. form = EventRuleForm(data={'action_type': 'not.a.registered.action'})
  349. self.assertIn('action_choice', form.fields)
  350. self.assertEqual(form.fields['action_choice'].queryset.model, Webhook)
  351. def test_submit_and_save_with_registered_no_object_action(self):
  352. """A runtime-registered action can be submitted and saved end-to-end through the form."""
  353. class NoObjectAction(EventRuleAction):
  354. slug = 'test.form_no_object_action'
  355. label = 'Form No-Object Action'
  356. object_required = False
  357. def enqueue(self, **kwargs):
  358. pass
  359. register_event_rule_action(NoObjectAction)
  360. object_type = ObjectType.objects.get_for_model(Site)
  361. form = EventRuleForm(data={
  362. 'name': 'Form No-Object Rule',
  363. 'object_types': [object_type.pk],
  364. 'event_types': [OBJECT_CREATED],
  365. 'action_type': 'test.form_no_object_action',
  366. })
  367. self.assertTrue(form.is_valid(), form.errors)
  368. rule = form.save()
  369. self.assertIsNone(rule.action_object_type)
  370. self.assertIsNone(rule.action_object_id)
  371. def test_submit_and_save_webhook_action(self):
  372. """The generalized form still saves a core Webhook action correctly."""
  373. webhook = Webhook.objects.create(name='Form Submit Webhook', payload_url='http://localhost:9000/')
  374. object_type = ObjectType.objects.get_for_model(Site)
  375. form = EventRuleForm(data={
  376. 'name': 'Form Webhook Rule',
  377. 'object_types': [object_type.pk],
  378. 'event_types': [OBJECT_CREATED],
  379. 'action_type': EventRuleActionChoices.WEBHOOK,
  380. 'action_choice': webhook.pk,
  381. })
  382. self.assertTrue(form.is_valid(), form.errors)
  383. rule = form.save()
  384. self.assertEqual(rule.action_object, webhook)
  385. def test_switching_to_optional_object_action_clears_stale_action_object(self):
  386. """
  387. Switching an existing rule to an action which declares object_model but not
  388. object_required, leaving the picker blank, must clear the old action_object.
  389. """
  390. class OptionalObjectAction(EventRuleAction):
  391. slug = 'test.optional_object_action'
  392. label = 'Optional Object Action'
  393. object_model = Webhook
  394. object_required = False
  395. register_event_rule_action(OptionalObjectAction)
  396. self.addCleanup(registry['event_rule_actions'].pop, 'test.optional_object_action', None)
  397. webhook = Webhook.objects.create(name='Stale Webhook', payload_url='http://localhost:9000/')
  398. webhook_type = ObjectType.objects.get_for_model(Webhook)
  399. object_type = ObjectType.objects.get_for_model(Site)
  400. rule = EventRule.objects.create(
  401. name='Stale Object Rule',
  402. event_types=[OBJECT_CREATED],
  403. action_type=EventRuleActionChoices.WEBHOOK,
  404. action_object_type=webhook_type,
  405. action_object_id=webhook.pk,
  406. )
  407. rule.object_types.set([object_type])
  408. form = EventRuleForm(data={
  409. 'name': rule.name,
  410. 'object_types': [object_type.pk],
  411. 'event_types': [OBJECT_CREATED],
  412. 'action_type': 'test.optional_object_action',
  413. # action_choice omitted: the user left the picker blank
  414. }, instance=rule)
  415. self.assertTrue(form.is_valid(), form.errors)
  416. saved = form.save()
  417. self.assertIsNone(saved.action_object_type)
  418. self.assertIsNone(saved.action_object_id)
  419. class EventRuleFilterFormTestCase(TestCase):
  420. def test_action_type_choices_reflect_the_live_registry(self):
  421. """
  422. The filter form's action_type choices must be read from the registry on access, not frozen
  423. when this module was first imported.
  424. """
  425. class FilterFormAction(EventRuleAction):
  426. slug = 'test.filter_form_action'
  427. label = 'Filter Form Action'
  428. register_event_rule_action(FilterFormAction)
  429. self.addCleanup(registry['event_rule_actions'].pop, FilterFormAction.slug, None)
  430. choices = dict(EventRuleFilterForm().fields['action_type'].choices)
  431. self.assertEqual(choices.get(FilterFormAction.slug), 'Filter Form Action')
  432. self.assertIn(None, choices) # The blank choice is retained
  433. class EventRuleEventTypeChoicesTestCase(TestCase):
  434. """
  435. Event types registered after the forms module is imported (e.g. in a plugin's ready()) must be
  436. offered by every event rule form.
  437. """
  438. def setUp(self):
  439. EventType('test_late_event', 'Late event').register()
  440. self.addCleanup(registry['event_types'].pop, 'test_late_event', None)
  441. def test_model_form_offers_late_event_type(self):
  442. choices = dict(EventRuleForm().fields['event_types'].choices)
  443. self.assertEqual(choices.get('test_late_event'), 'Late event')
  444. def test_bulk_edit_form_offers_late_event_type(self):
  445. choices = dict(EventRuleBulkEditForm().fields['event_types'].choices)
  446. self.assertEqual(choices.get('test_late_event'), 'Late event')
  447. def test_filter_form_offers_late_event_type(self):
  448. choices = dict(EventRuleFilterForm().fields['event_type'].choices)
  449. self.assertEqual(choices.get('test_late_event'), 'Late event')
  450. def test_import_form_accepts_late_event_type(self):
  451. webhook = Webhook.objects.create(name='Late Event Webhook', payload_url='http://localhost:9000/')
  452. form = EventRuleImportForm(data={
  453. 'name': 'Late Event Rule',
  454. 'object_types': 'dcim.site',
  455. 'event_types': 'test_late_event',
  456. 'action_type': EventRuleActionChoices.WEBHOOK,
  457. 'action_object': webhook.name,
  458. })
  459. self.assertTrue(form.is_valid(), form.errors)
  460. self.assertEqual(form.cleaned_data['event_types'], ['test_late_event'])
  461. class EventRuleImportFormTestCase(TestCase):
  462. """
  463. EventRuleImportForm resolves action_object via each registered action's resolve_import_object()
  464. hook, and treats it as optional (an action need not operate against a target object).
  465. """
  466. def tearDown(self):
  467. super().tearDown()
  468. registry['event_rule_actions'].pop('test.import_no_object_action', None)
  469. def test_resolves_webhook_by_name(self):
  470. webhook = Webhook.objects.create(name='Import Test Webhook', payload_url='http://localhost:9000/')
  471. form = EventRuleImportForm(data={
  472. 'name': 'Import Webhook Rule',
  473. 'object_types': 'dcim.site',
  474. 'event_types': 'object_created',
  475. 'action_type': EventRuleActionChoices.WEBHOOK,
  476. 'action_object': webhook.name,
  477. })
  478. self.assertTrue(form.is_valid(), form.errors)
  479. self.assertEqual(form.instance.action_object, webhook)
  480. def test_resolves_notification_group_by_name(self):
  481. """The import form resolves a notification group, not just webhooks and scripts."""
  482. group = NotificationGroup.objects.create(name='Import Test Group')
  483. form = EventRuleImportForm(data={
  484. 'name': 'Import Notification Rule',
  485. 'object_types': 'dcim.site',
  486. 'event_types': 'object_created',
  487. 'action_type': EventRuleActionChoices.NOTIFICATION,
  488. 'action_object': group.name,
  489. })
  490. self.assertTrue(form.is_valid(), form.errors)
  491. self.assertEqual(form.instance.action_object, group)
  492. def test_unresolvable_webhook_name_is_rejected(self):
  493. form = EventRuleImportForm(data={
  494. 'name': 'Import Bad Webhook Rule',
  495. 'object_types': 'dcim.site',
  496. 'event_types': 'object_created',
  497. 'action_type': EventRuleActionChoices.WEBHOOK,
  498. 'action_object': 'Does Not Exist',
  499. })
  500. self.assertFalse(form.is_valid())
  501. self.assertIn('action_object', form.errors)
  502. def test_unregistered_action_type_is_rejected(self):
  503. form = EventRuleImportForm(data={
  504. 'name': 'Import Bad Type Rule',
  505. 'object_types': 'dcim.site',
  506. 'event_types': 'object_created',
  507. 'action_type': 'not.a.registered.action',
  508. 'action_object': 'whatever',
  509. })
  510. self.assertFalse(form.is_valid())
  511. self.assertIn('action_type', form.errors)
  512. def test_submit_no_object_action_with_blank_action_object_succeeds(self):
  513. """A blank action_object must be accepted for bulk-importing a no-object action."""
  514. class NoObjectAction(EventRuleAction):
  515. slug = 'test.import_no_object_action'
  516. label = 'Import No-Object Action'
  517. object_required = False
  518. register_event_rule_action(NoObjectAction)
  519. form = EventRuleImportForm(data={
  520. 'name': 'Import No-Object Rule',
  521. 'object_types': 'dcim.site',
  522. 'event_types': 'object_created',
  523. 'action_type': 'test.import_no_object_action',
  524. 'action_object': '',
  525. })
  526. self.assertTrue(form.is_valid(), form.errors)
  527. rule = form.save()
  528. self.assertIsNone(rule.action_object_type)
  529. self.assertIsNone(rule.action_object_id)
  530. def test_blank_action_object_rejected_for_object_required_action(self):
  531. """A blank action_object must be rejected cleanly (not raise) for an action which requires one."""
  532. form = EventRuleImportForm(data={
  533. 'name': 'Import Webhook No Object',
  534. 'object_types': 'dcim.site',
  535. 'event_types': 'object_created',
  536. 'action_type': EventRuleActionChoices.WEBHOOK,
  537. 'action_object': '',
  538. })
  539. self.assertFalse(form.is_valid())
  540. self.assertIn('action_object', form.errors)
  541. def test_action_object_rejected_for_action_without_object_model(self):
  542. """
  543. An action declaring no object_model rejects a supplied action_object as inapplicable,
  544. rather than reporting it as an unsupported bulk import.
  545. """
  546. class NoObjectAction(EventRuleAction):
  547. slug = 'test.import_no_object_action'
  548. label = 'Import No-Object Action'
  549. object_required = False
  550. register_event_rule_action(NoObjectAction)
  551. form = EventRuleImportForm(data={
  552. 'name': 'Import No-Object Rule With Object',
  553. 'object_types': 'dcim.site',
  554. 'event_types': 'object_created',
  555. 'action_type': 'test.import_no_object_action',
  556. 'action_object': 'Some Object',
  557. })
  558. self.assertFalse(form.is_valid())
  559. self.assertIn('does not operate against a target object', str(form.errors['action_object']))
  560. def test_csv_update_to_optional_object_action_clears_stale_action_object(self):
  561. """
  562. A CSV row updating an existing rule to an action_type which declares object_model but not
  563. object_required, with action_object left blank, must clear the previous action_object.
  564. """
  565. class OptionalObjectAction(EventRuleAction):
  566. slug = 'test.import_optional_object_action'
  567. label = 'Import Optional Object Action'
  568. object_model = Webhook
  569. object_required = False
  570. register_event_rule_action(OptionalObjectAction)
  571. self.addCleanup(registry['event_rule_actions'].pop, 'test.import_optional_object_action', None)
  572. webhook = Webhook.objects.create(name='CSV Stale Webhook', payload_url='http://localhost:9000/')
  573. webhook_type = ObjectType.objects.get_for_model(Webhook)
  574. rule = EventRule.objects.create(
  575. name='CSV Stale Object Rule',
  576. event_types=[OBJECT_CREATED],
  577. action_type=EventRuleActionChoices.WEBHOOK,
  578. action_object_type=webhook_type,
  579. action_object_id=webhook.pk,
  580. )
  581. form = EventRuleImportForm(data={
  582. 'name': rule.name,
  583. 'object_types': 'dcim.site',
  584. 'event_types': 'object_created',
  585. 'action_type': 'test.import_optional_object_action',
  586. 'action_object': '',
  587. }, instance=rule)
  588. self.assertTrue(form.is_valid(), form.errors)
  589. saved = form.save()
  590. self.assertIsNone(saved.action_object_type)
  591. self.assertIsNone(saved.action_object_id)
  592. def test_action_validate_error_on_unexposed_field_becomes_non_field_error(self):
  593. """A validate() error keyed by a field this form doesn't expose (e.g. action_data) must not raise."""
  594. class ActionDataValidatingAction(EventRuleAction):
  595. slug = 'test.import_action_data_validating'
  596. label = 'Import Action Data Validating'
  597. object_required = False
  598. def validate(self, *, action_object, action_data):
  599. raise ValidationError({'action_data': 'Bad action_data for test'})
  600. register_event_rule_action(ActionDataValidatingAction)
  601. self.addCleanup(registry['event_rule_actions'].pop, 'test.import_action_data_validating', None)
  602. form = EventRuleImportForm(data={
  603. 'name': 'Import Bad Action Data Rule',
  604. 'object_types': 'dcim.site',
  605. 'event_types': 'object_created',
  606. 'action_type': 'test.import_action_data_validating',
  607. 'action_object': '',
  608. })
  609. self.assertFalse(form.is_valid())
  610. self.assertIn(NON_FIELD_ERRORS, form.errors)
  611. self.assertIn('Bad action_data for test', form.errors[NON_FIELD_ERRORS])