|
|
@@ -7,6 +7,7 @@ from unittest.mock import Mock, patch
|
|
|
|
|
|
import django_rq
|
|
|
from django.conf import settings
|
|
|
+from django.core.exceptions import ImproperlyConfigured, ValidationError
|
|
|
from django.http import HttpResponse
|
|
|
from django.test import RequestFactory, TestCase, tag
|
|
|
from django.urls import reverse
|
|
|
@@ -20,12 +21,20 @@ from core.models import Job, ObjectType
|
|
|
from dcim.choices import SiteStatusChoices
|
|
|
from dcim.models import DeviceType, Interface, Manufacturer, Site
|
|
|
from extras.choices import EventRuleActionChoices
|
|
|
-from extras.events import enqueue_event, flush_events, serialize_for_event
|
|
|
+from extras.events import enqueue_event, flush_events, process_event_rules, serialize_for_event
|
|
|
from extras.models import EventRule, Script, ScriptModule, Tag, Webhook
|
|
|
from extras.scripts import Script as ScriptBase
|
|
|
from extras.signals import process_job_end_event_rules
|
|
|
from extras.webhooks import generate_signature, send_webhook
|
|
|
from netbox.context_managers import event_tracking
|
|
|
+from netbox.event_rules import (
|
|
|
+ EventRuleAction,
|
|
|
+ get_event_rule_action,
|
|
|
+ get_event_rule_action_choices,
|
|
|
+ register_event_rule_action,
|
|
|
+)
|
|
|
+from netbox.registry import registry
|
|
|
+from netbox.tests.dummy_plugin.event_rules import DummyRaisingAction
|
|
|
from utilities.testing import APITestCase, create_test_device
|
|
|
from utilities.testing.mixins import RQQueueTestMixin
|
|
|
|
|
|
@@ -882,6 +891,464 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
|
|
|
self.assertEqual(job.kwargs['event_rule'], event_rule)
|
|
|
self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
|
|
|
|
|
|
+ def test_unregistered_action_type_does_not_block_other_rules(self):
|
|
|
+ """
|
|
|
+ An unregistered action_type must not block other EventRules for the same event. Kept on
|
|
|
+ this class, not a separate RQQueueTestMixin one, since two such classes in different
|
|
|
+ `--parallel` subsuites cross-flush each other's Redis queue.
|
|
|
+ """
|
|
|
+ site_type = ObjectType.objects.get_for_model(Site)
|
|
|
+ webhook = Webhook.objects.create(name='Dispatch Test Webhook', payload_url='http://localhost:9000/')
|
|
|
+ webhook_type = ObjectType.objects.get_for_model(Webhook)
|
|
|
+
|
|
|
+ good_rule = EventRule.objects.create(
|
|
|
+ name='Good Rule',
|
|
|
+ event_types=[OBJECT_CREATED],
|
|
|
+ action_type=EventRuleActionChoices.WEBHOOK,
|
|
|
+ action_object_type=webhook_type,
|
|
|
+ action_object_id=webhook.pk,
|
|
|
+ )
|
|
|
+ good_rule.object_types.set([site_type])
|
|
|
+
|
|
|
+ bad_rule = EventRule.objects.create(
|
|
|
+ name='Bad Rule',
|
|
|
+ event_types=[OBJECT_CREATED],
|
|
|
+ action_type='someplugin.not_installed',
|
|
|
+ )
|
|
|
+ bad_rule.object_types.set([site_type])
|
|
|
+
|
|
|
+ url = reverse('dcim-api:site-list')
|
|
|
+ self.add_permissions('dcim.add_site')
|
|
|
+ with self.assertLogs('netbox.events_processor', level='WARNING') as cm:
|
|
|
+ response = self.client.post(
|
|
|
+ url, {'name': 'Dispatch Site', 'slug': 'dispatch-site'}, format='json', **self.header
|
|
|
+ )
|
|
|
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
|
|
+ self.assertTrue(any('someplugin.not_installed' in message for message in cm.output))
|
|
|
+
|
|
|
+ # The good rule's webhook must still have been enqueued despite the bad rule.
|
|
|
+ rule_jobs = [j for j in self.queue.jobs if j.kwargs['event_rule'] == good_rule]
|
|
|
+ self.assertEqual(len(rule_jobs), 1)
|
|
|
+
|
|
|
+ def test_raising_enqueue_does_not_block_other_rules(self):
|
|
|
+ """A raising action registered as plugin-provided (the default) must not block other rules."""
|
|
|
+ register_event_rule_action(DummyRaisingAction)
|
|
|
+ self.addCleanup(registry['event_rule_actions'].pop, DummyRaisingAction.slug, None)
|
|
|
+
|
|
|
+ site_type = ObjectType.objects.get_for_model(Site)
|
|
|
+ webhook = Webhook.objects.create(name='Dispatch Test Webhook 2', payload_url='http://localhost:9000/')
|
|
|
+ webhook_type = ObjectType.objects.get_for_model(Webhook)
|
|
|
+
|
|
|
+ good_rule = EventRule.objects.create(
|
|
|
+ name='Good Rule 2',
|
|
|
+ event_types=[OBJECT_CREATED],
|
|
|
+ action_type=EventRuleActionChoices.WEBHOOK,
|
|
|
+ action_object_type=webhook_type,
|
|
|
+ action_object_id=webhook.pk,
|
|
|
+ )
|
|
|
+ good_rule.object_types.set([site_type])
|
|
|
+
|
|
|
+ raising_rule = EventRule.objects.create(
|
|
|
+ name='Raising Rule',
|
|
|
+ event_types=[OBJECT_CREATED],
|
|
|
+ action_type=DummyRaisingAction.slug,
|
|
|
+ )
|
|
|
+ raising_rule.object_types.set([site_type])
|
|
|
+
|
|
|
+ url = reverse('dcim-api:site-list')
|
|
|
+ self.add_permissions('dcim.add_site')
|
|
|
+ with self.assertLogs('netbox.events_processor', level='ERROR') as cm:
|
|
|
+ response = self.client.post(
|
|
|
+ url, {'name': 'Dispatch Site 2', 'slug': 'dispatch-site-2'}, format='json', **self.header
|
|
|
+ )
|
|
|
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
|
|
+ self.assertTrue(any('Raising Rule' in message for message in cm.output))
|
|
|
+
|
|
|
+ # The good rule's webhook must still have been enqueued despite the raising rule.
|
|
|
+ rule_jobs = [j for j in self.queue.jobs if j.kwargs['event_rule'] == good_rule]
|
|
|
+ self.assertEqual(len(rule_jobs), 1)
|
|
|
+
|
|
|
+ def test_raising_action_registered_as_non_plugin_propagates(self):
|
|
|
+ """A raising action registered with is_plugin_provided=False (as core actions are) must propagate."""
|
|
|
+ class RaisingCoreLikeAction(EventRuleAction):
|
|
|
+ slug = 'test.raising_core_like_action'
|
|
|
+ label = 'Raising Core-Like Action'
|
|
|
+ object_required = False
|
|
|
+
|
|
|
+ def enqueue(self, **kwargs):
|
|
|
+ raise RuntimeError("intentional failure for test")
|
|
|
+
|
|
|
+ register_event_rule_action(RaisingCoreLikeAction, is_plugin_provided=False)
|
|
|
+ self.addCleanup(registry['event_rule_actions'].pop, 'test.raising_core_like_action', None)
|
|
|
+
|
|
|
+ site_type = ObjectType.objects.get_for_model(Site)
|
|
|
+ rule = EventRule.objects.create(
|
|
|
+ name='Raising Core-Like Rule',
|
|
|
+ event_types=[OBJECT_CREATED],
|
|
|
+ action_type='test.raising_core_like_action',
|
|
|
+ )
|
|
|
+ rule.object_types.set([site_type])
|
|
|
+
|
|
|
+ with self.assertRaises(RuntimeError):
|
|
|
+ process_event_rules([rule], object_type=site_type, event={'data': {}, 'event_type': OBJECT_CREATED})
|
|
|
+
|
|
|
+
|
|
|
+class EventRuleActionRegistrationTestCase(TestCase):
|
|
|
+ """
|
|
|
+ Unit tests for the EventRuleAction registry (netbox.event_rules).
|
|
|
+ """
|
|
|
+
|
|
|
+ def tearDown(self):
|
|
|
+ super().tearDown()
|
|
|
+ # The registry is a global dict; test-registered actions must not leak into other tests.
|
|
|
+ for slug in ('test.dummy_action', 'test.duplicate_action'):
|
|
|
+ registry['event_rule_actions'].pop(slug, None)
|
|
|
+
|
|
|
+ def test_register_event_rule_action(self):
|
|
|
+ class DummyAction(EventRuleAction):
|
|
|
+ slug = 'test.dummy_action'
|
|
|
+ label = 'Dummy Action'
|
|
|
+ description = 'A dummy action for testing'
|
|
|
+
|
|
|
+ register_event_rule_action(DummyAction)
|
|
|
+
|
|
|
+ action = get_event_rule_action('test.dummy_action')
|
|
|
+ self.assertIsInstance(action, DummyAction)
|
|
|
+
|
|
|
+ choices = {choice.value: choice.label for choice in get_event_rule_action_choices()}
|
|
|
+ self.assertEqual(choices.get('test.dummy_action'), 'Dummy Action')
|
|
|
+
|
|
|
+ def test_register_event_rule_action_as_decorator(self):
|
|
|
+ @register_event_rule_action
|
|
|
+ class DummyAction(EventRuleAction):
|
|
|
+ slug = 'test.dummy_action'
|
|
|
+ label = 'Dummy Action'
|
|
|
+
|
|
|
+ self.assertIsInstance(get_event_rule_action('test.dummy_action'), DummyAction)
|
|
|
+
|
|
|
+ def test_duplicate_slug_raises(self):
|
|
|
+ class FirstAction(EventRuleAction):
|
|
|
+ slug = 'test.duplicate_action'
|
|
|
+ label = 'First'
|
|
|
+
|
|
|
+ class SecondAction(EventRuleAction):
|
|
|
+ slug = 'test.duplicate_action'
|
|
|
+ label = 'Second'
|
|
|
+
|
|
|
+ register_event_rule_action(FirstAction)
|
|
|
+ with self.assertRaises(ImproperlyConfigured):
|
|
|
+ register_event_rule_action(SecondAction)
|
|
|
+
|
|
|
+ def test_slug_starting_with_digit_rejected(self):
|
|
|
+ """A slug starting with a digit would sanitize into a GraphQL-invalid enum member name."""
|
|
|
+ class DigitSlugAction(EventRuleAction):
|
|
|
+ slug = '2fa.notify'
|
|
|
+ label = 'Digit Slug Action'
|
|
|
+
|
|
|
+ with self.assertRaises(ImproperlyConfigured):
|
|
|
+ register_event_rule_action(DigitSlugAction)
|
|
|
+ self.assertIsNone(get_event_rule_action('2fa.notify'))
|
|
|
+
|
|
|
+ def test_slug_with_hyphen_rejected(self):
|
|
|
+ """Hyphens are not permitted, though plugin distribution names conventionally use them."""
|
|
|
+ class HyphenSlugAction(EventRuleAction):
|
|
|
+ slug = 'my-plugin.open_ticket'
|
|
|
+ label = 'Hyphen Slug Action'
|
|
|
+
|
|
|
+ with self.assertRaises(ImproperlyConfigured):
|
|
|
+ register_event_rule_action(HyphenSlugAction)
|
|
|
+ self.assertIsNone(get_event_rule_action('my-plugin.open_ticket'))
|
|
|
+
|
|
|
+ def test_slug_with_leading_underscore_rejected(self):
|
|
|
+ """A leading underscore sanitizes into a "__"-prefixed name, which GraphQL reserves for introspection."""
|
|
|
+ class LeadingUnderscoreAction(EventRuleAction):
|
|
|
+ slug = '_internal.foo'
|
|
|
+ label = 'Leading Underscore Action'
|
|
|
+
|
|
|
+ with self.assertRaises(ImproperlyConfigured):
|
|
|
+ register_event_rule_action(LeadingUnderscoreAction)
|
|
|
+ self.assertIsNone(get_event_rule_action('_internal.foo'))
|
|
|
+
|
|
|
+ def test_slug_with_uppercase_rejected(self):
|
|
|
+ """Slugs must be lowercase, though plugin/class names conventionally are not."""
|
|
|
+ class UppercaseSlugAction(EventRuleAction):
|
|
|
+ slug = 'MyPlugin.action'
|
|
|
+ label = 'Uppercase Slug Action'
|
|
|
+
|
|
|
+ with self.assertRaises(ImproperlyConfigured):
|
|
|
+ register_event_rule_action(UppercaseSlugAction)
|
|
|
+ self.assertIsNone(get_event_rule_action('MyPlugin.action'))
|
|
|
+
|
|
|
+ def test_slug_enum_key_collision_rejected(self):
|
|
|
+ """Two distinct slugs that sanitize to the same GraphQL enum member name must not both register."""
|
|
|
+ class DotAction(EventRuleAction):
|
|
|
+ slug = 'test.collision_action'
|
|
|
+ label = 'Dot Action'
|
|
|
+
|
|
|
+ class UnderscoreAction(EventRuleAction):
|
|
|
+ slug = 'test_collision_action'
|
|
|
+ label = 'Underscore Action'
|
|
|
+
|
|
|
+ register_event_rule_action(DotAction)
|
|
|
+ self.addCleanup(registry['event_rule_actions'].pop, 'test.collision_action', None)
|
|
|
+ with self.assertRaises(ImproperlyConfigured):
|
|
|
+ register_event_rule_action(UnderscoreAction)
|
|
|
+ self.assertIsNone(get_event_rule_action('test_collision_action'))
|
|
|
+
|
|
|
+ def test_missing_slug_raises_at_registration(self):
|
|
|
+ # Class definition itself must succeed; only registration checks slug/label.
|
|
|
+ class NoSlugAction(EventRuleAction):
|
|
|
+ label = 'No Slug'
|
|
|
+
|
|
|
+ with self.assertRaises(ImproperlyConfigured):
|
|
|
+ register_event_rule_action(NoSlugAction)
|
|
|
+
|
|
|
+ def test_missing_label_raises_at_registration(self):
|
|
|
+ class NoLabelAction(EventRuleAction):
|
|
|
+ slug = 'test.no_label'
|
|
|
+
|
|
|
+ with self.assertRaises(ImproperlyConfigured):
|
|
|
+ register_event_rule_action(NoLabelAction)
|
|
|
+
|
|
|
+ def test_intermediate_base_class_without_slug_or_label_is_definable(self):
|
|
|
+ """
|
|
|
+ slug/label are checked at registration, not class definition, so several concrete actions
|
|
|
+ can share an intermediate base class which sets neither.
|
|
|
+ """
|
|
|
+ class PluginActionBase(EventRuleAction):
|
|
|
+ object_required = False
|
|
|
+
|
|
|
+ def enqueue(self, **kwargs):
|
|
|
+ pass
|
|
|
+
|
|
|
+ class ConcreteAction(PluginActionBase):
|
|
|
+ slug = 'test.intermediate_base_concrete_action'
|
|
|
+ label = 'Concrete Action'
|
|
|
+
|
|
|
+ register_event_rule_action(ConcreteAction)
|
|
|
+ self.addCleanup(registry['event_rule_actions'].pop, 'test.intermediate_base_concrete_action', None)
|
|
|
+
|
|
|
+ self.assertIsInstance(get_event_rule_action('test.intermediate_base_concrete_action'), ConcreteAction)
|
|
|
+
|
|
|
+ def test_unregistered_slug_returns_none(self):
|
|
|
+ self.assertIsNone(get_event_rule_action('this.does.not.exist'))
|
|
|
+
|
|
|
+ def test_core_actions_are_registered(self):
|
|
|
+ """WebhookAction/ScriptAction/NotificationAction are registered at app startup."""
|
|
|
+ core_slugs = (
|
|
|
+ EventRuleActionChoices.WEBHOOK, EventRuleActionChoices.SCRIPT, EventRuleActionChoices.NOTIFICATION,
|
|
|
+ )
|
|
|
+ for slug in core_slugs:
|
|
|
+ self.assertIsNotNone(get_event_rule_action(slug))
|
|
|
+
|
|
|
+ def test_get_object_queryset_returns_none_without_object_model(self):
|
|
|
+ action = EventRuleAction()
|
|
|
+ self.assertIsNone(action.get_object_queryset())
|
|
|
+
|
|
|
+ def test_internal_validate_requires_object_when_object_required(self):
|
|
|
+ action = EventRuleAction()
|
|
|
+ action.object_required = True
|
|
|
+ with self.assertRaises(ValidationError):
|
|
|
+ action._validate(action_object=None, action_data={})
|
|
|
+
|
|
|
+ def test_internal_validate_passes_when_object_not_required(self):
|
|
|
+ action = EventRuleAction()
|
|
|
+ action.object_required = False
|
|
|
+ # Must not raise
|
|
|
+ action._validate(action_object=None, action_data={})
|
|
|
+
|
|
|
+ def test_internal_validate_rejects_wrong_object_type(self):
|
|
|
+ action = EventRuleAction()
|
|
|
+ action.object_model = Webhook
|
|
|
+ action.object_required = True
|
|
|
+ site = Site(name='Not A Webhook')
|
|
|
+ with self.assertRaises(ValidationError):
|
|
|
+ action._validate(action_object=site, action_data={})
|
|
|
+
|
|
|
+ def test_internal_validate_rejects_object_for_action_without_object_model(self):
|
|
|
+ """An action which declares no object_model must reject a target object outright."""
|
|
|
+ action = EventRuleAction()
|
|
|
+ with self.assertRaises(ValidationError):
|
|
|
+ action._validate(action_object=Webhook(), action_data={})
|
|
|
+
|
|
|
+ def test_object_required_without_object_model_rejected_at_registration(self):
|
|
|
+ """object_required with no object_model could never be satisfied, so it's caught early."""
|
|
|
+ class ImpossibleAction(EventRuleAction):
|
|
|
+ slug = 'test.impossible_action'
|
|
|
+ label = 'Impossible Action'
|
|
|
+ object_required = True
|
|
|
+
|
|
|
+ with self.assertRaises(ImproperlyConfigured):
|
|
|
+ register_event_rule_action(ImpossibleAction)
|
|
|
+ self.assertIsNone(get_event_rule_action('test.impossible_action'))
|
|
|
+
|
|
|
+ def test_get_object_label_defaults_to_object_model_verbose_name(self):
|
|
|
+ """The object picker's label defaults to the model's verbose name, capitalized."""
|
|
|
+ self.assertEqual(get_event_rule_action(EventRuleActionChoices.NOTIFICATION).get_object_label(),
|
|
|
+ 'Notification group')
|
|
|
+ self.assertEqual(get_event_rule_action(EventRuleActionChoices.WEBHOOK).get_object_label(), 'Webhook')
|
|
|
+
|
|
|
+ def test_get_object_label_honors_explicit_override(self):
|
|
|
+ class LabeledAction(EventRuleAction):
|
|
|
+ object_model = Webhook
|
|
|
+ object_label = 'Destination'
|
|
|
+
|
|
|
+ self.assertEqual(LabeledAction().get_object_label(), 'Destination')
|
|
|
+
|
|
|
+ def test_get_object_label_is_none_without_object_model(self):
|
|
|
+ self.assertIsNone(EventRuleAction().get_object_label())
|
|
|
+
|
|
|
+ def test_validate_is_noop_by_default(self):
|
|
|
+ action = EventRuleAction()
|
|
|
+ # Must not raise
|
|
|
+ action.validate(action_object=None, action_data={})
|
|
|
+
|
|
|
+ def test_validate_override_does_not_need_super(self):
|
|
|
+ """A subclass overriding validate() gets the base object_required check for free, no super() needed."""
|
|
|
+ class CustomValidatingAction(EventRuleAction):
|
|
|
+ slug = 'test.custom_validating_action'
|
|
|
+ label = 'Custom Validating Action'
|
|
|
+ object_model = Webhook
|
|
|
+ object_required = True
|
|
|
+
|
|
|
+ def validate(self, *, action_object, action_data):
|
|
|
+ if action_data.get('bad'):
|
|
|
+ raise ValidationError({'action_data': 'bad action_data for test'})
|
|
|
+
|
|
|
+ action = CustomValidatingAction()
|
|
|
+
|
|
|
+ # The subclass's own check fires
|
|
|
+ with self.assertRaises(ValidationError):
|
|
|
+ action._validate(action_object=Webhook(), action_data={'bad': True})
|
|
|
+
|
|
|
+ # ...as does the base object_required check
|
|
|
+ with self.assertRaises(ValidationError):
|
|
|
+ action._validate(action_object=None, action_data={})
|
|
|
+
|
|
|
+ action._validate(action_object=Webhook(), action_data={}) # must not raise
|
|
|
+
|
|
|
+ def test_enqueue_not_implemented_by_default(self):
|
|
|
+ action = EventRuleAction()
|
|
|
+ with self.assertRaises(NotImplementedError):
|
|
|
+ action.enqueue(event_rule=None, event_context={}, action_object=None, action_data={})
|
|
|
+
|
|
|
+ def test_is_plugin_provided_defaults_true_before_registration(self):
|
|
|
+ """is_plugin_provided is True on an instance that never goes through registration."""
|
|
|
+ self.assertTrue(EventRuleAction().is_plugin_provided)
|
|
|
+
|
|
|
+
|
|
|
+class EventRuleActionAvailabilityTestCase(TestCase):
|
|
|
+ """
|
|
|
+ An EventRule with an unregistered action_type must remain loadable, skip gracefully during
|
|
|
+ processing, and display as "unavailable" -- but reject full_clean() until action_type changes.
|
|
|
+ """
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def setUpTestData(cls):
|
|
|
+ site_type = ObjectType.objects.get_for_model(Site)
|
|
|
+ webhook = Webhook.objects.create(name='Availability Test Webhook', payload_url='http://localhost:9000/')
|
|
|
+ webhook_type = ObjectType.objects.get_for_model(Webhook)
|
|
|
+
|
|
|
+ cls.healthy_rule = EventRule.objects.create(
|
|
|
+ name='Healthy Rule',
|
|
|
+ event_types=[OBJECT_CREATED],
|
|
|
+ action_type=EventRuleActionChoices.WEBHOOK,
|
|
|
+ action_object_type=webhook_type,
|
|
|
+ action_object_id=webhook.pk,
|
|
|
+ )
|
|
|
+ cls.healthy_rule.object_types.set([site_type])
|
|
|
+
|
|
|
+ # .objects.create() calls save(), not full_clean(), so an unregistered action_type can be
|
|
|
+ # persisted directly, matching the state of a row whose providing plugin was uninstalled.
|
|
|
+ cls.unavailable_rule = EventRule.objects.create(
|
|
|
+ name='Unavailable Rule',
|
|
|
+ event_types=[OBJECT_CREATED],
|
|
|
+ action_type='someplugin.not_installed',
|
|
|
+ )
|
|
|
+ cls.unavailable_rule.object_types.set([site_type])
|
|
|
+
|
|
|
+ def test_action_is_available_true_for_registered_action(self):
|
|
|
+ self.assertTrue(self.healthy_rule.action_is_available)
|
|
|
+ self.assertIsNotNone(self.healthy_rule.action_provider)
|
|
|
+
|
|
|
+ def test_action_is_available_false_for_unregistered_action(self):
|
|
|
+ self.assertFalse(self.unavailable_rule.action_is_available)
|
|
|
+ self.assertIsNone(self.unavailable_rule.action_provider)
|
|
|
+
|
|
|
+ def test_get_action_type_display_for_registered_action(self):
|
|
|
+ self.assertEqual(self.healthy_rule.get_action_type_display(), 'Webhook')
|
|
|
+
|
|
|
+ def test_get_action_type_display_for_unregistered_action(self):
|
|
|
+ self.assertEqual(
|
|
|
+ self.unavailable_rule.get_action_type_display(),
|
|
|
+ 'someplugin.not_installed (unavailable)',
|
|
|
+ )
|
|
|
+
|
|
|
+ def test_get_action_type_color_for_registered_action(self):
|
|
|
+ self.assertIsNone(self.healthy_rule.get_action_type_color())
|
|
|
+
|
|
|
+ def test_get_action_type_color_for_unregistered_action(self):
|
|
|
+ self.assertEqual(self.unavailable_rule.get_action_type_color(), 'red')
|
|
|
+
|
|
|
+ def test_clean_rejects_unchanged_unavailable_action_type(self):
|
|
|
+ """A persisted-but-unavailable action_type is rejected by full_clean() even when left unchanged."""
|
|
|
+ rule = EventRule.objects.get(pk=self.unavailable_rule.pk)
|
|
|
+ rule.enabled = False
|
|
|
+ with self.assertRaises(ValidationError):
|
|
|
+ rule.full_clean()
|
|
|
+
|
|
|
+ def test_clean_rejects_new_row_with_unregistered_action_type(self):
|
|
|
+ rule = EventRule(
|
|
|
+ name='New Unregistered Rule',
|
|
|
+ event_types=[OBJECT_CREATED],
|
|
|
+ action_type='someplugin.also_not_installed',
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValidationError):
|
|
|
+ rule.full_clean()
|
|
|
+
|
|
|
+ def test_clean_rejects_changing_to_unregistered_action_type(self):
|
|
|
+ rule = EventRule.objects.get(pk=self.healthy_rule.pk)
|
|
|
+ rule.action_type = 'someplugin.newly_unregistered'
|
|
|
+ with self.assertRaises(ValidationError):
|
|
|
+ rule.full_clean()
|
|
|
+
|
|
|
+ def test_clean_accepts_registered_action_with_valid_object(self):
|
|
|
+ rule = EventRule.objects.get(pk=self.healthy_rule.pk)
|
|
|
+ rule.full_clean() # must not raise
|
|
|
+
|
|
|
+
|
|
|
+class EventRuleNoObjectActionTestCase(TestCase):
|
|
|
+ """
|
|
|
+ Model-layer tests for an EventRuleAction which declares object_model=None (no target object).
|
|
|
+ """
|
|
|
+
|
|
|
+ def tearDown(self):
|
|
|
+ super().tearDown()
|
|
|
+ registry['event_rule_actions'].pop('test.model_no_object_action', None)
|
|
|
+
|
|
|
+ def test_full_clean_and_save_with_no_object_action(self):
|
|
|
+ class NoObjectAction(EventRuleAction):
|
|
|
+ slug = 'test.model_no_object_action'
|
|
|
+ label = 'Model No-Object Action'
|
|
|
+ object_required = False
|
|
|
+
|
|
|
+ register_event_rule_action(NoObjectAction)
|
|
|
+
|
|
|
+ site_type = ObjectType.objects.get_for_model(Site)
|
|
|
+ rule = EventRule(
|
|
|
+ name='Model No-Object Rule',
|
|
|
+ event_types=[OBJECT_CREATED],
|
|
|
+ action_type='test.model_no_object_action',
|
|
|
+ )
|
|
|
+ rule.full_clean() # must not raise: no action_object required or supplied
|
|
|
+ rule.save()
|
|
|
+ rule.object_types.set([site_type])
|
|
|
+
|
|
|
+ rule.refresh_from_db()
|
|
|
+ self.assertIsNone(rule.action_object_type)
|
|
|
+ self.assertIsNone(rule.action_object_id)
|
|
|
+ self.assertIsNone(rule.action_object)
|
|
|
+
|
|
|
|
|
|
class WebhookRenderHeadersTest(TestCase):
|
|
|
|