diff --git a/src/sentry/workflow_engine/endpoints/validators/base/action.py b/src/sentry/workflow_engine/endpoints/validators/base/action.py index bcd4e4b716dbfa..82136cff61c325 100644 --- a/src/sentry/workflow_engine/endpoints/validators/base/action.py +++ b/src/sentry/workflow_engine/endpoints/validators/base/action.py @@ -1,27 +1,22 @@ -from typing import Any, Generic, TypeVar +from typing import Any from rest_framework import serializers -from sentry.api.serializers.rest_framework import CamelSnakeModelSerializer -from sentry.db.models import Model +from sentry.api.serializers.rest_framework import CamelSnakeSerializer from sentry.workflow_engine.endpoints.validators.utils import validate_json_schema from sentry.workflow_engine.models import Action from sentry.workflow_engine.registry import action_handler_registry from sentry.workflow_engine.types import ActionHandler -T = TypeVar("T", bound=Model) ActionData = dict[str, Any] ActionConfig = dict[str, Any] -class BaseActionValidator(CamelSnakeModelSerializer[T], Generic[T]): +class BaseActionValidator(CamelSnakeSerializer): data: Any = serializers.JSONField() config: Any = serializers.JSONField() type = serializers.ChoiceField(choices=[(t.value, t.name) for t in Action.Type]) - integration_id = serializers.IntegerField() - - class Meta: - model = T + integration_id = serializers.IntegerField(required=False) def _get_action_handler(self) -> ActionHandler: action_type = self.initial_data.get("type") @@ -34,3 +29,9 @@ def validate_data(self, value) -> ActionData: def validate_config(self, value) -> ActionConfig: config_schema = self._get_action_handler().config_schema return validate_json_schema(value, config_schema) + + def create(self, validated_value: dict[str, Any]) -> Action: + """ + TODO @saponifi3d -- add any org checks for creating actions here + """ + return Action.objects.create(**validated_value) diff --git a/src/sentry/workflow_engine/endpoints/validators/base/data_condition.py b/src/sentry/workflow_engine/endpoints/validators/base/data_condition.py index 81162f10069f42..c31a13bffdfedc 100644 --- a/src/sentry/workflow_engine/endpoints/validators/base/data_condition.py +++ b/src/sentry/workflow_engine/endpoints/validators/base/data_condition.py @@ -10,7 +10,7 @@ validate_json_primitive, validate_json_schema, ) -from sentry.workflow_engine.models.data_condition import CONDITION_OPS, Condition +from sentry.workflow_engine.models.data_condition import CONDITION_OPS, Condition, DataCondition from sentry.workflow_engine.registry import condition_handler_registry from sentry.workflow_engine.types import DataConditionHandler @@ -26,7 +26,7 @@ class AbstractDataConditionValidator( type = serializers.ChoiceField(choices=[(t.value, t.value) for t in Condition]) comparison = serializers.JSONField(required=True) condition_result = serializers.JSONField(required=True) - condition_group_id = serializers.IntegerField(required=True) + condition_group_id = serializers.IntegerField(required=False) @abstractmethod def validate_comparison(self, value: Any) -> ComparisonType: @@ -99,3 +99,9 @@ def validate_condition_result(self, value: Any) -> Any: raise serializers.ValidationError( f"Value, {value}, does not match JSON Schema for condition result" ) + + def create(self, validated_data: dict[str, Any]) -> Any: + """ + Create a DataCondition object from the validated data. + """ + return DataCondition.objects.create(**validated_data) diff --git a/src/sentry/workflow_engine/endpoints/validators/base/data_condition_group.py b/src/sentry/workflow_engine/endpoints/validators/base/data_condition_group.py index 0a0a9c939e656f..d9bf4d153bfb4b 100644 --- a/src/sentry/workflow_engine/endpoints/validators/base/data_condition_group.py +++ b/src/sentry/workflow_engine/endpoints/validators/base/data_condition_group.py @@ -1,8 +1,11 @@ +from typing import Any + +from django.db import router, transaction from rest_framework import serializers from sentry.api.serializers.rest_framework import CamelSnakeSerializer from sentry.workflow_engine.endpoints.validators.base import BaseDataConditionValidator -from sentry.workflow_engine.models import DataCondition, DataConditionGroup +from sentry.workflow_engine.models import DataConditionGroup class BaseDataConditionGroupValidator(CamelSnakeSerializer): @@ -11,15 +14,27 @@ class BaseDataConditionGroupValidator(CamelSnakeSerializer): organization_id = serializers.IntegerField(required=True) conditions = serializers.ListField(required=False) - def validate_conditions(self, value) -> list[DataCondition]: - conditions: list[DataCondition] = [] - + def validate_conditions(self, value: list[dict[str, Any]]) -> list[dict[str, Any]]: + conditions = [] for condition in value: condition_validator = BaseDataConditionValidator(data=condition) condition_validator.is_valid(raise_exception=True) - - # TODO Use the validator.create() method when it exists - condition = DataCondition(condition_validator.validated_data) - conditions.append(condition) + conditions.append(condition_validator.validated_data) return conditions + + def create(self, validated_data: dict[str, Any]) -> DataConditionGroup: + with transaction.atomic(router.db_for_write(DataConditionGroup)): + condition_group = DataConditionGroup.objects.create( + logic_type=validated_data["logic_type"], + organization_id=validated_data["organization_id"], + ) + + for condition in validated_data["conditions"]: + if not condition.get("condition_group_id"): + condition["condition_group_id"] = condition_group.id + + condition_validator = BaseDataConditionValidator() + condition_validator.create(condition) + + return condition_group diff --git a/src/sentry/workflow_engine/endpoints/validators/base/workflow.py b/src/sentry/workflow_engine/endpoints/validators/base/workflow.py index b18b4314602d7b..75c01f9cdc1b0f 100644 --- a/src/sentry/workflow_engine/endpoints/validators/base/workflow.py +++ b/src/sentry/workflow_engine/endpoints/validators/base/workflow.py @@ -1,9 +1,25 @@ +from typing import Any + +from django.db import router, transaction from rest_framework import serializers +# from sentry import audit_log from sentry.api.serializers.rest_framework import CamelSnakeSerializer -from sentry.workflow_engine.endpoints.validators.base import BaseDataConditionGroupValidator + +# from sentry.utils.audit import create_audit_entry +from sentry.workflow_engine.endpoints.validators.base import ( + BaseActionValidator, + BaseDataConditionGroupValidator, +) from sentry.workflow_engine.endpoints.validators.utils import validate_json_schema -from sentry.workflow_engine.models import Workflow +from sentry.workflow_engine.models import ( + DataConditionGroupAction, + Workflow, + WorkflowDataConditionGroup, +) + +DataConditionGroupData = dict[str, Any] +ActionData = list[dict[str, Any]] class WorkflowValidator(CamelSnakeSerializer): @@ -11,12 +27,78 @@ class WorkflowValidator(CamelSnakeSerializer): enabled = serializers.BooleanField(required=False, default=True) config = serializers.JSONField(required=False) triggers = BaseDataConditionGroupValidator(required=False) - action_filters = serializers.ListField(child=BaseDataConditionGroupValidator(), required=False) + action_filters = serializers.ListField(required=False) # TODO - Need to improve the following fields (validate them in db) organization_id = serializers.IntegerField(required=True) environment_id = serializers.IntegerField(required=False) + def _split_action_and_condition_group( + self, action_filter: dict[str, Any] + ) -> tuple[ActionData, DataConditionGroupData]: + try: + actions = action_filter["actions"] + except KeyError: + raise serializers.ValidationError("Missing actions key in action filter") + + return actions, action_filter + def validate_config(self, value): schema = Workflow.config_schema return validate_json_schema(value, schema) + + def validate_action_filters(self, value): + for action_filter in value: + actions, condition_group = self._split_action_and_condition_group(action_filter) + BaseDataConditionGroupValidator(data=condition_group).is_valid(raise_exception=True) + + for action in actions: + BaseActionValidator(data=action).is_valid(raise_exception=True) + + return value + + def create(self, validated_value: dict[str, Any]) -> Workflow: + condition_group_validator = BaseDataConditionGroupValidator() + action_validator = BaseActionValidator() + + with transaction.atomic(router.db_for_write(Workflow)): + when_condition_group = condition_group_validator.create(validated_value["triggers"]) + + workflow = Workflow.objects.create( + name=validated_value["name"], + enabled=validated_value["enabled"], + config=validated_value["config"], + organization_id=validated_value["organization_id"], + environment_id=validated_value.get("environment_id"), + when_condition_group=when_condition_group, + ) + + # TODO -- can we bulk create: actions, dcga's and the workflow dcg? + # Create actions and action filters, then associate them to the workflow + for action_filter in validated_value["action_filters"]: + actions, condition_group = self._split_action_and_condition_group(action_filter) + new_condition_group = condition_group_validator.create(condition_group) + + # Connect the condition group to the workflow + WorkflowDataConditionGroup.objects.create( + condition_group=new_condition_group, + workflow=workflow, + ) + + for action in actions: + new_action = action_validator.create(action) + DataConditionGroupAction.objects.create( + action=new_action, + condition_group=new_condition_group, + ) + + # TODO - Fix after adding contexts + # create_audit_entry( + # request=self.context["request"], + # organization=self.context["organization_id"], + # target_object=workflow.id, + # event=audit_log.get_event_id("WORKFLOW_ADD"), + # data=workflow.get_audit_log_data(), + # ) + + return workflow diff --git a/tests/sentry/workflow_engine/endpoints/validators/test_base_action.py b/tests/sentry/workflow_engine/endpoints/validators/test_base_action.py index c15e342b56605b..7b0468b9530124 100644 --- a/tests/sentry/workflow_engine/endpoints/validators/test_base_action.py +++ b/tests/sentry/workflow_engine/endpoints/validators/test_base_action.py @@ -1,47 +1,8 @@ from unittest import TestCase, mock -from rest_framework import serializers - from sentry.workflow_engine.endpoints.validators.base import BaseActionValidator from sentry.workflow_engine.models import Action -from sentry.workflow_engine.types import ActionHandler -from tests.sentry.workflow_engine.test_base import MockModel - - -class MockActionHandler(ActionHandler): - config_schema = { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "The configuration schema for a Action Configuration", - "type": "object", - "properties": { - "foo": { - "type": ["string"], - }, - }, - "required": ["foo"], - "additionalProperties": False, - } - - data_schema = { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "The configuration schema for a Action Data", - "type": "object", - "properties": { - "baz": { - "type": ["string"], - }, - }, - "required": ["baz"], - "additionalProperties": False, - } - - -class MockActionValidator(BaseActionValidator[MockModel]): - field1 = serializers.CharField() - - class Meta: - model = MockModel - fields = "__all__" +from tests.sentry.workflow_engine.test_base import MockActionHandler @mock.patch( @@ -52,7 +13,6 @@ class TestBaseActionValidator(TestCase): def setUp(self): super().setUp() self.valid_data = { - "field1": "test", "type": Action.Type.SLACK, "config": {"foo": "bar"}, "data": {"baz": "bar"}, @@ -60,7 +20,7 @@ def setUp(self): } def test_validate_type(self, mock_action_handler_get): - validator = MockActionValidator( + validator = BaseActionValidator( data={ **self.valid_data, "type": Action.Type.SLACK, @@ -71,7 +31,7 @@ def test_validate_type(self, mock_action_handler_get): assert result is True def test_validate_type__invalid(self, mock_action_handler_get): - validator = MockActionValidator( + validator = BaseActionValidator( data={ **self.valid_data, "type": "invalid_test", @@ -82,7 +42,7 @@ def test_validate_type__invalid(self, mock_action_handler_get): assert result is False def test_validate_config(self, mock_action_handler_get): - validator = MockActionValidator( + validator = BaseActionValidator( data={ **self.valid_data, "config": {"foo": "bar"}, @@ -93,7 +53,7 @@ def test_validate_config(self, mock_action_handler_get): assert result is True def test_validate_config__invalid(self, mock_action_handler_get): - validator = MockActionValidator( + validator = BaseActionValidator( data={ **self.valid_data, "config": {"invalid": 1}, @@ -104,7 +64,7 @@ def test_validate_config__invalid(self, mock_action_handler_get): assert result is False def test_validate_data(self, mock_action_handler_get): - validator = MockActionValidator( + validator = BaseActionValidator( data={ **self.valid_data, "data": {"baz": "foo"}, @@ -115,7 +75,7 @@ def test_validate_data(self, mock_action_handler_get): assert result is True def test_validate_data__invalid(self, mock_action_handler_get): - validator = MockActionValidator( + validator = BaseActionValidator( data={ **self.valid_data, "data": {"invalid": 1}, diff --git a/tests/sentry/workflow_engine/endpoints/validators/test_base_data_condition_group.py b/tests/sentry/workflow_engine/endpoints/validators/test_base_data_condition_group.py index 7cbfd6ffec39f8..abac60423d0bde 100644 --- a/tests/sentry/workflow_engine/endpoints/validators/test_base_data_condition_group.py +++ b/tests/sentry/workflow_engine/endpoints/validators/test_base_data_condition_group.py @@ -57,7 +57,7 @@ def test_conditions__custom_handler__invalid_to_schema(self): validator = BaseDataConditionGroupValidator(data=self.valid_data) assert validator.is_valid() is False - def test_conditions__custom_handler__invalid__missing_group_id(self): + def test_conditions__custom_handler__valid__missing_group_id(self): self.valid_data["conditions"] = [ { "type": Condition.AGE_COMPARISON, @@ -72,7 +72,7 @@ def test_conditions__custom_handler__invalid__missing_group_id(self): ] validator = BaseDataConditionGroupValidator(data=self.valid_data) - assert validator.is_valid() is False + assert validator.is_valid() is True def test_conditions__custom_handler(self): self.valid_data["conditions"] = [ @@ -90,3 +90,46 @@ def test_conditions__custom_handler(self): validator = BaseDataConditionGroupValidator(data=self.valid_data) assert validator.is_valid() is True + + +class TestBaseDataConditionGroupValidatorCreate(TestCase): + def setUp(self): + self.valid_data = { + "logicType": DataConditionGroup.Type.ANY, + "organizationId": self.organization.id, + "conditions": [], + } + + def test_create(self): + validator = BaseDataConditionGroupValidator(data=self.valid_data) + + # Validate the data and raise any exceptions if invalid to halt test + validator.is_valid(raise_exception=True) + result = validator.create(validator.validated_data) + + # Validate the condition group is created correctly + assert result.logic_type == DataConditionGroup.Type.ANY + assert result.organization_id == self.organization.id + assert result.conditions.count() == 0 + + def test_create__with_conditions(self): + self.valid_data["conditions"] = [ + { + "type": Condition.EQUAL, + "comparison": 1, + "conditionResult": True, + } + ] + + validator = BaseDataConditionGroupValidator(data=self.valid_data) + validator.is_valid(raise_exception=True) + result = validator.create(validator.validated_data) + + assert result.conditions.count() == 1 + + condition = result.conditions.first() + assert condition is not None + + assert condition.type == Condition.EQUAL + assert condition.comparison == 1 + assert condition.condition_group == result diff --git a/tests/sentry/workflow_engine/endpoints/validators/test_base_workflow.py b/tests/sentry/workflow_engine/endpoints/validators/test_base_workflow.py index 7cfe66a0f7b328..1d914c4afaff2a 100644 --- a/tests/sentry/workflow_engine/endpoints/validators/test_base_workflow.py +++ b/tests/sentry/workflow_engine/endpoints/validators/test_base_workflow.py @@ -1,5 +1,9 @@ +from unittest import mock + from sentry.testutils.cases import TestCase from sentry.workflow_engine.endpoints.validators.base.workflow import WorkflowValidator +from sentry.workflow_engine.models import Action, Condition, DataConditionGroupAction +from tests.sentry.workflow_engine.test_base import MockActionHandler class TestWorkflowValidator(TestCase): @@ -23,11 +27,49 @@ def test_valid_data(self): validator = WorkflowValidator(data=self.valid_data) assert validator.is_valid() is True - def test_valid_data__with_action_filters(self): - self.valid_data["actionFilters"] = [self.valid_data["triggers"]] + @mock.patch( + "sentry.workflow_engine.registry.action_handler_registry.get", + return_value=MockActionHandler, + ) + def test_valid_data__with_action_filters(self, mock_action_handler): + self.valid_data["actionFilters"] = [ + { + **self.valid_data["triggers"], + "actions": [ + { + "type": Action.Type.SLACK, + "config": {"foo": "bar"}, + "data": {"baz": "bar"}, + "integrationId": 1, + } + ], + } + ] + validator = WorkflowValidator(data=self.valid_data) assert validator.is_valid() is True + @mock.patch( + "sentry.workflow_engine.registry.action_handler_registry.get", + return_value=MockActionHandler, + ) + def test_valid_data__with_invalid_action_filters(self, mock_action_handler): + self.valid_data["actionFilters"] = [ + { + **self.valid_data["triggers"], + "actions": [ + { + "type": Action.Type.SLACK, + "config": {}, + "integrationId": 1, + } + ], + } + ] + + validator = WorkflowValidator(data=self.valid_data) + assert validator.is_valid() is False + def test_invalid_data__no_name(self): self.valid_data["name"] = "" validator = WorkflowValidator(data=self.valid_data) @@ -42,3 +84,133 @@ def test_invalid_data__invalid_trigger(self): self.valid_data["triggers"] = {"foo": "bar"} validator = WorkflowValidator(data=self.valid_data) assert validator.is_valid() is False + + +class TestWorkflowValidatorCreate(TestCase): + def setUp(self): + self.valid_data = { + "name": "test", + "enabled": True, + "actionFilters": [], + "config": { + "frequency": 30, + }, + "organizationId": self.organization.id, + "triggers": { + "logicType": "any", + "conditions": [], + "organizationId": self.organization.id, + }, + } + + def test_create__simple(self): + validator = WorkflowValidator(data=self.valid_data) + assert validator.is_valid() is True + workflow = validator.create(validator.validated_data) + + # workflow is created + assert workflow.id is not None + assert workflow.name == self.valid_data["name"] + assert workflow.enabled == self.valid_data["enabled"] + assert workflow.config == self.valid_data["config"] + assert workflow.organization_id == self.organization.id + + def test_create__validate_triggers_empty(self): + validator = WorkflowValidator(data=self.valid_data) + assert validator.is_valid() is True + + workflow = validator.create(validator.validated_data) + + assert workflow.when_condition_group is not None + assert workflow.when_condition_group.conditions.count() == 0 + + def test_create__validate_triggers_with_conditions(self): + self.valid_data["triggers"]["conditions"] = [ + { + "type": Condition.EQUAL, + "comparison": 1, + "conditionResult": True, + } + ] + + validator = WorkflowValidator(data=self.valid_data) + assert validator.is_valid() is True + workflow = validator.create(validator.validated_data) + + trigger = workflow.when_condition_group + assert trigger is not None + assert trigger.conditions.count() == 1 + + trigger_condition = trigger.conditions.first() + assert trigger_condition is not None + assert trigger_condition.type == Condition.EQUAL + + @mock.patch( + "sentry.workflow_engine.registry.action_handler_registry.get", + return_value=MockActionHandler, + ) + def test_create__with_actions__creates_workflow_group(self, mock_action_handler): + self.valid_data["actionFilters"] = [ + { + "actions": [ + { + "type": Action.Type.SLACK, + "config": {"foo": "bar"}, + "data": {"baz": "bar"}, + "integrationId": 1, + } + ], + "logicType": "any", + "conditions": [], + "organizationId": self.organization.id, + } + ] + + validator = WorkflowValidator(data=self.valid_data) + assert validator.is_valid() is True + workflow = validator.create(validator.validated_data) + + workflow_condition_group = workflow.workflowdataconditiongroup_set.first() + assert workflow_condition_group is not None + + assert workflow_condition_group.condition_group.logic_type == "any" + + @mock.patch( + "sentry.workflow_engine.registry.action_handler_registry.get", + return_value=MockActionHandler, + ) + def test_create__with_actions__creates_action_group(self, mock_action_handler): + self.valid_data["actionFilters"] = [ + { + "actions": [ + { + "type": Action.Type.SLACK, + "config": {"foo": "bar"}, + "data": {"baz": "bar"}, + "integrationId": 1, + } + ], + "logicType": "any", + "conditions": [], + "organizationId": self.organization.id, + } + ] + + validator = WorkflowValidator(data=self.valid_data) + assert validator.is_valid() is True + workflow = validator.create(validator.validated_data) + + workflow_condition_group = workflow.workflowdataconditiongroup_set.first() + assert workflow_condition_group is not None + + action_group_query = DataConditionGroupAction.objects.filter( + condition_group=workflow_condition_group.condition_group + ) + + assert action_group_query.count() == 1 + action_group = action_group_query.first() + assert action_group is not None + + # check the action / condition group + assert action_group.action.type == Action.Type.SLACK + assert action_group.condition_group.logic_type == "any" diff --git a/tests/sentry/workflow_engine/test_base.py b/tests/sentry/workflow_engine/test_base.py index 529c3690c76159..fe2a369d61d384 100644 --- a/tests/sentry/workflow_engine/test_base.py +++ b/tests/sentry/workflow_engine/test_base.py @@ -25,7 +25,7 @@ ) from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.registry import data_source_type_registry -from sentry.workflow_engine.types import DetectorPriorityLevel +from sentry.workflow_engine.types import ActionHandler, DetectorPriorityLevel from tests.sentry.issues.test_utils import OccurrenceTestMixin try: @@ -43,6 +43,34 @@ class Meta: app_label = "fixtures" +class MockActionHandler(ActionHandler): + config_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "The configuration schema for a Action Configuration", + "type": "object", + "properties": { + "foo": { + "type": ["string"], + }, + }, + "required": ["foo"], + "additionalProperties": False, + } + + data_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "The configuration schema for a Action Data", + "type": "object", + "properties": { + "baz": { + "type": ["string"], + }, + }, + "required": ["baz"], + "additionalProperties": False, + } + + class BaseWorkflowTest(TestCase, OccurrenceTestMixin): def create_snuba_query(self, **kwargs): return SnubaQuery.objects.create(