Browse Source

feat(api): Implement api for creating alert rules (SEN-823)

This implements the api for creating alert rules. I'm attempting to use `ModelSerializers` here so
that we can have our validation logic consolidated a little better. Since these serializers
automatically determine field names based off of the model, I've also added
`CamelSnakeModelSerializer` that will convert incoming parameters to snake_case, and convert
outgoing error messages to `camelCase`.

I'm also including the endpoints for alert rules in the incidents project. Will likely move all
incident endpoints into this folder as well, once I'm sure it's working ok. I had some issues
including the non-drf serializers in this project due to imports, will tackle them separately.
Dan Fuller 5 years ago
parent
commit
3388ead134

+ 59 - 0
src/sentry/api/serializers/rest_framework/base.py

@@ -0,0 +1,59 @@
+from __future__ import absolute_import
+
+import six
+from django.utils.text import re_camel_case
+from rest_framework.fields import empty
+from rest_framework.serializers import ModelSerializer
+
+
+def camel_to_snake_case(value):
+    """
+    Splits CamelCase and converts to lower case with underscores.
+    """
+    return re_camel_case.sub(r'_\1', value).strip('_').lower()
+
+
+def snake_to_camel_case(value):
+    """
+    Converts a string from snake_case to camelCase
+    """
+    words = value.strip('_').split('_')
+    return words[0].lower() + ''.join([word.capitalize() for word in words[1:]])
+
+
+def convert_dict_key_case(obj, converter):
+    """
+    Recursively converts the keys of a dictionary using the provided converter
+    param.
+    """
+    if not isinstance(obj, dict):
+        return obj
+
+    obj = obj.copy()
+    for key in list(six.iterkeys(obj)):
+        converted_key = converter(key)
+        obj[converted_key] = convert_dict_key_case(obj.pop(key), converter)
+
+    return obj
+
+
+class CamelSnakeModelSerializer(ModelSerializer):
+    """
+    Allows parameters to be defined in snake case, but passed as camel case.
+
+    Errors are output in camel case.
+    """
+
+    def __init__(self, instance=None, data=empty, **kwargs):
+        if data is not empty:
+            data = convert_dict_key_case(data, camel_to_snake_case)
+        return super(CamelSnakeModelSerializer, self).__init__(
+            instance=instance, data=data, **kwargs
+        )
+
+    @property
+    def errors(self):
+        return convert_dict_key_case(
+            super(CamelSnakeModelSerializer, self).errors,
+            snake_to_camel_case,
+        )

+ 6 - 0
src/sentry/api/urls.py

@@ -271,6 +271,7 @@ from .endpoints.user_social_identities_index import UserSocialIdentitiesIndexEnd
 from .endpoints.user_social_identity_details import UserSocialIdentityDetailsEndpoint
 from .endpoints.user_subscriptions import UserSubscriptionsEndpoint
 from .endpoints.useravatar import UserAvatarEndpoint
+from sentry.incidents.endpoints.project_alert_rule_index import ProjectAlertRuleIndexEndpoint
 
 urlpatterns = patterns(
     '',
@@ -967,6 +968,11 @@ urlpatterns = patterns(
             ProjectDetailsEndpoint.as_view(),
             name='sentry-api-0-project-details'
         ),
+        url(
+            r'^(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/alert-rules/$',
+            ProjectAlertRuleIndexEndpoint.as_view(),
+            name='sentry-api-0-project-alert-rules'
+        ),
         url(
             r'^(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/avatar/$',
             ProjectAvatarEndpoint.as_view(),

+ 1 - 0
src/sentry/incidents/endpoints/__init__.py

@@ -0,0 +1 @@
+from __future__ import absolute_import

+ 30 - 0
src/sentry/incidents/endpoints/project_alert_rule_index.py

@@ -0,0 +1,30 @@
+from __future__ import absolute_import
+
+from rest_framework import status
+from rest_framework.response import Response
+
+from sentry import features
+from sentry.api.bases.project import ProjectEndpoint
+from sentry.api.exceptions import ResourceDoesNotExist
+from sentry.incidents.endpoints.serializers import AlertRuleSerializer
+
+
+class ProjectAlertRuleIndexEndpoint(ProjectEndpoint):
+    def post(self, request, project):
+        """
+        Create an alert rule
+        """
+        if not features.has('organizations:incidents', project.organization, actor=request.user):
+            raise ResourceDoesNotExist
+
+        serializer = AlertRuleSerializer(
+            context={'project': project},
+            data=request.data,
+        )
+
+        if serializer.is_valid():
+            alert_rule = serializer.save()
+            # TODO: Implement serializer
+            return Response({'id': alert_rule.id}, status=status.HTTP_201_CREATED)
+
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

+ 63 - 0
src/sentry/incidents/endpoints/serializers.py

@@ -0,0 +1,63 @@
+from __future__ import absolute_import
+
+from datetime import timedelta
+
+from rest_framework import serializers
+
+from sentry.api.serializers.rest_framework.base import CamelSnakeModelSerializer
+from sentry.incidents.models import (
+    AlertRule,
+    AlertRuleAggregations,
+    AlertRuleThresholdType,
+)
+from sentry.incidents.logic import (
+    AlertRuleNameAlreadyUsedError,
+    create_alert_rule,
+)
+
+
+class AlertRuleSerializer(CamelSnakeModelSerializer):
+    # XXX: ArrayFields aren't supported automatically until DRF 3.1
+    aggregations = serializers.ListField(child=serializers.IntegerField())
+
+    class Meta:
+        model = AlertRule
+        fields = [
+            'name', 'threshold_type', 'query', 'time_window', 'alert_threshold',
+            'resolve_threshold', 'threshold_period', 'aggregations',
+        ]
+        extra_kwargs = {
+            'threshold_period': {'default': 1, 'min_value': 1, 'max_value': 20},
+            'time_window': {
+                'min_value': 1,
+                'max_value': int(timedelta(days=1).total_seconds() / 60),
+            },
+            'aggregations': {'min_length': 1, 'max_length': 10},
+            'name': {'min_length': 1, 'max_length': 64},
+        }
+
+    def validate_threshold_type(self, threshold_type):
+        try:
+            return AlertRuleThresholdType(threshold_type)
+        except ValueError:
+            raise serializers.ValidationError(
+                'Invalid threshold type, valid values are %s' % [
+                    item.value for item in AlertRuleThresholdType
+                ],
+            )
+
+    def validate_aggregations(self, aggregations):
+        try:
+            return [AlertRuleAggregations(agg) for agg in aggregations]
+        except ValueError:
+            raise serializers.ValidationError(
+                'Invalid aggregation, valid values are %s' % [
+                    item.value for item in AlertRuleAggregations
+                ],
+            )
+
+    def create(self, validated_data):
+        try:
+            return create_alert_rule(project=self.context['project'], **validated_data)
+        except AlertRuleNameAlreadyUsedError:
+            raise serializers.ValidationError('This name is already in use for this project')

+ 31 - 0
tests/sentry/api/serializers/rest_framework/test_base.py

@@ -0,0 +1,31 @@
+from __future__ import absolute_import
+
+from django.test import TestCase
+from django.db import models
+
+from sentry.api.serializers.rest_framework.base import CamelSnakeModelSerializer
+
+
+class SampleModel(models.Model):
+    camel_case = models.IntegerField()
+
+    class Meta:
+        app_label = 'sentry'
+
+
+class SampleSerializer(CamelSnakeModelSerializer):
+    class Meta:
+        model = SampleModel
+        fields = ['camel_case']
+
+
+class CamelSnakeModelSerializerTest(TestCase):
+    def test_simple(self):
+        serializer = SampleSerializer(data={'camelCase': 1})
+        assert serializer.is_valid()
+        assert serializer.data == {'camel_case': 1}
+
+    def test_error(self):
+        serializer = SampleSerializer(data={'camelCase': 'hi'})
+        assert not serializer.is_valid()
+        assert serializer.errors == {'camelCase': ['A valid integer is required.']}

+ 1 - 0
tests/sentry/incidents/endpoints/__init__.py

@@ -0,0 +1 @@
+from __future__ import absolute_import

+ 85 - 0
tests/sentry/incidents/endpoints/test_project_alert_rule_index.py

@@ -0,0 +1,85 @@
+from __future__ import absolute_import
+
+from exam import fixture
+from freezegun import freeze_time
+
+from sentry.incidents.models import AlertRule
+from sentry.testutils import APITestCase
+
+
+@freeze_time()
+class IncidentCreateEndpointTest(APITestCase):
+    endpoint = 'sentry-api-0-project-alert-rules'
+    method = 'post'
+
+    @fixture
+    def organization(self):
+        return self.create_organization()
+
+    @fixture
+    def project(self):
+        return self.create_project(organization=self.organization)
+
+    @fixture
+    def user(self):
+        return self.create_user()
+
+    def test_simple(self):
+        self.create_member(
+            user=self.user,
+            organization=self.organization,
+            role='owner',
+            teams=[self.team],
+        )
+        self.login_as(self.user)
+        name = 'an alert'
+        threshold_type = 1
+        query = 'hi'
+        aggregations = [0]
+        time_window = 10
+        alert_threshold = 1000
+        resolve_threshold = 300
+        with self.feature('organizations:incidents'):
+            resp = self.get_valid_response(
+                self.organization.slug,
+                self.project.slug,
+                name=name,
+                thresholdType=threshold_type,
+                query=query,
+                aggregations=aggregations,
+                timeWindow=time_window,
+                alertThreshold=alert_threshold,
+                resolveThreshold=resolve_threshold,
+                status_code=201,
+            )
+        assert 'id' in resp.data
+        alert_rule = AlertRule.objects.get(id=resp.data['id'])
+        assert alert_rule.name == name
+        assert alert_rule.threshold_type == threshold_type
+        assert alert_rule.query == query
+        assert alert_rule.aggregations == aggregations
+        assert alert_rule.time_window == time_window
+        assert alert_rule.alert_threshold == alert_threshold
+        assert alert_rule.resolve_threshold == resolve_threshold
+
+    def test_no_feature(self):
+        self.create_member(
+            user=self.user,
+            organization=self.organization,
+            role='owner',
+            teams=[self.team],
+        )
+        self.login_as(self.user)
+        resp = self.get_response(self.organization.slug, self.project.slug)
+        assert resp.status_code == 404
+
+    def test_no_perms(self):
+        self.create_member(
+            user=self.user,
+            organization=self.organization,
+            role='member',
+            teams=[self.team],
+        )
+        self.login_as(self.user)
+        resp = self.get_response(self.organization.slug, self.project.slug)
+        assert resp.status_code == 403

+ 85 - 0
tests/sentry/incidents/endpoints/test_serializers.py

@@ -0,0 +1,85 @@
+from __future__ import absolute_import
+
+from exam import fixture
+
+from sentry.incidents.endpoints.serializers import AlertRuleSerializer
+from sentry.incidents.models import (
+    AlertRuleAggregations,
+    AlertRuleThresholdType,
+)
+from sentry.testutils import TestCase
+
+
+class TestAlertRuleSerializer(TestCase):
+    @fixture
+    def valid_params(self):
+        return {
+            'name': 'something',
+            'time_window': 10,
+            'query': 'hi',
+            'threshold_type': 0,
+            'resolve_threshold': 1,
+            'alert_threshold': 0,
+            'aggregations': [0],
+        }
+
+    def run_fail_validation_test(self, params, errors):
+        base_params = self.valid_params.copy()
+        base_params.update(params)
+        serializer = AlertRuleSerializer(context={'project': self.project}, data=base_params)
+        assert not serializer.is_valid()
+        assert serializer.errors == errors
+
+    def test_validation_no_params(self):
+        serializer = AlertRuleSerializer(context={'project': self.project}, data={})
+        assert not serializer.is_valid()
+        field_is_required = ['This field is required.']
+        assert serializer.errors == {
+            'name': field_is_required,
+            'timeWindow': field_is_required,
+            'query': field_is_required,
+            'thresholdType': field_is_required,
+            'resolveThreshold': field_is_required,
+            'alertThreshold': field_is_required,
+            'aggregations': field_is_required,
+        }
+
+    def test_time_window(self):
+        self.run_fail_validation_test(
+            {'timeWindow': 'a'},
+            {'timeWindow': ['A valid integer is required.']},
+        )
+        self.run_fail_validation_test(
+            {'timeWindow': 1441},
+            {'timeWindow': ['Ensure this value is less than or equal to 1440.']},
+        )
+        self.run_fail_validation_test(
+            {'timeWindow': 0},
+            {'timeWindow': ['Ensure this value is greater than or equal to 1.']},
+        )
+
+    def test_threshold_type(self):
+        invalid_values = ['Invalid threshold type, valid values are %s' % [
+            item.value for item in AlertRuleThresholdType
+        ]]
+        self.run_fail_validation_test(
+            {'thresholdType': 'a'},
+            {'thresholdType': ['A valid integer is required.']},
+        )
+        self.run_fail_validation_test(
+            {'thresholdType': 50},
+            {'thresholdType': invalid_values},
+        )
+
+    def test_aggregations(self):
+        invalid_values = ['Invalid aggregation, valid values are %s' % [
+            item.value for item in AlertRuleAggregations
+        ]]
+        self.run_fail_validation_test(
+            {'aggregations': ['a']},
+            {'aggregations': ['A valid integer is required.']},
+        )
+        self.run_fail_validation_test(
+            {'aggregations': [50]},
+            {'aggregations': invalid_values},
+        )