Skip to content

feat(workflow_engine): Add create methods to validators #89769

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 16, 2025
19 changes: 10 additions & 9 deletions src/sentry/workflow_engine/endpoints/validators/base/action.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i suppose the DataCondition will error if this isn't set and we try to create the object, since condition_group_id is required. do we make it optional because we only create these inside of DataConditionGroupValidator?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - that way the API doesn't need to set the condition_group_id on every request -- also it won't have the DCG id when we initially make a workflow - so if we re-use those validators it'll fail there as well.


@abstractmethod
def validate_comparison(self, value: Any) -> ComparisonType:
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -1,22 +1,104 @@
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):
name = serializers.CharField(required=True, max_length=256)
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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the Workflow validator automatically convert DataConditionGroup.logic_type of ANY into ANY_SHORT_CIRCUIT? there's some discussion here about how to handle it in the FE #89233 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't feel like it should auto-convert that. It probably could based on the type but it would add some artificial constraints that i'm not sure i agree with.

we should not convert any to any_short_circuit though as they are specifically used in different places for different reasons.

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,
)
Comment on lines +83 to +93
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: can maybe bulk create the WorkflowDataConditionGroups and DataConditionGroupActions but idk how much time that would actually save

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add a todo to come back and optimize this / check if there's a better way to bulk create.

My expectation would be that there's only one or two of these per workflow, so it's probably okay for a bit.


# 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
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -52,15 +13,14 @@ class TestBaseActionValidator(TestCase):
def setUp(self):
super().setUp()
self.valid_data = {
"field1": "test",
"type": Action.Type.SLACK,
"config": {"foo": "bar"},
"data": {"baz": "bar"},
"integrationId": 1,
}

def test_validate_type(self, mock_action_handler_get):
validator = MockActionValidator(
validator = BaseActionValidator(
data={
**self.valid_data,
"type": Action.Type.SLACK,
Expand All @@ -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",
Expand All @@ -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"},
Expand All @@ -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},
Expand All @@ -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"},
Expand All @@ -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},
Expand Down
Loading
Loading