зеркало из https://github.com/mozilla/normandy.git
v2 Serializers
This commit is contained in:
Родитель
e2dceca3d2
Коммит
71792095e0
|
@ -0,0 +1,192 @@
|
|||
from pyjexl import JEXL
|
||||
from rest_framework import serializers
|
||||
|
||||
from normandy.base.api.serializers import UserSerializer
|
||||
from normandy.recipes.api.fields import ActionImplementationHyperlinkField
|
||||
from normandy.recipes.models import (
|
||||
Action,
|
||||
ApprovalRequest,
|
||||
Channel,
|
||||
Country,
|
||||
Locale,
|
||||
Recipe,
|
||||
RecipeRevision,
|
||||
)
|
||||
from normandy.recipes.validators import JSONSchemaValidator
|
||||
|
||||
|
||||
class ActionSerializer(serializers.ModelSerializer):
|
||||
arguments_schema = serializers.JSONField()
|
||||
implementation_url = ActionImplementationHyperlinkField()
|
||||
|
||||
class Meta:
|
||||
model = Action
|
||||
fields = [
|
||||
'arguments_schema',
|
||||
'name',
|
||||
'id',
|
||||
'implementation_url',
|
||||
]
|
||||
|
||||
|
||||
class ApprovalRequestSerializer(serializers.ModelSerializer):
|
||||
approver = UserSerializer()
|
||||
created = serializers.DateTimeField(read_only=True)
|
||||
creator = UserSerializer()
|
||||
|
||||
class Meta:
|
||||
model = ApprovalRequest
|
||||
fields = [
|
||||
'approved',
|
||||
'approver',
|
||||
'comment',
|
||||
'created',
|
||||
'creator',
|
||||
'id',
|
||||
]
|
||||
|
||||
|
||||
class RecipeRevisionSerializer(serializers.ModelSerializer):
|
||||
approval_request = ApprovalRequestSerializer(read_only=True)
|
||||
comment = serializers.CharField(read_only=True)
|
||||
date_created = serializers.DateTimeField(source='created', read_only=True)
|
||||
recipe = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RecipeRevision
|
||||
fields = [
|
||||
'approval_request',
|
||||
'comment',
|
||||
'date_created',
|
||||
'id',
|
||||
'recipe',
|
||||
]
|
||||
|
||||
def get_recipe(self, instance):
|
||||
serializer = RecipeSerializer(instance.serializable_recipe,
|
||||
exclude_fields=['latest_revision', 'approved_revision'])
|
||||
return serializer.data
|
||||
|
||||
|
||||
class RecipeSerializer(serializers.ModelSerializer):
|
||||
action = serializers.SerializerMethodField(read_only=True)
|
||||
action_id = serializers.PrimaryKeyRelatedField(
|
||||
source='action', queryset=Action.objects.all(), write_only=True)
|
||||
approval_request = ApprovalRequestSerializer(read_only=True)
|
||||
approved_revision = RecipeRevisionSerializer(read_only=True)
|
||||
arguments = serializers.JSONField()
|
||||
channels = serializers.SlugRelatedField(
|
||||
slug_field='slug', queryset=Channel.objects.all(), many=True, required=False)
|
||||
countries = serializers.SlugRelatedField(
|
||||
slug_field='code', queryset=Country.objects.all(), many=True, required=False)
|
||||
enabled = serializers.BooleanField(read_only=True)
|
||||
extra_filter_expression = serializers.CharField()
|
||||
filter_expression = serializers.CharField(read_only=True)
|
||||
last_updated = serializers.DateTimeField(read_only=True)
|
||||
locales = serializers.SlugRelatedField(
|
||||
slug_field='code', queryset=Locale.objects.all(), many=True, required=False)
|
||||
latest_revision = RecipeRevisionSerializer(read_only=True)
|
||||
name = serializers.CharField()
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = [
|
||||
'action',
|
||||
'action_id',
|
||||
'approval_request',
|
||||
'approved_revision',
|
||||
'arguments',
|
||||
'channels',
|
||||
'countries',
|
||||
'enabled',
|
||||
'extra_filter_expression',
|
||||
'filter_expression',
|
||||
'id',
|
||||
'is_approved',
|
||||
'locales',
|
||||
'last_updated',
|
||||
'latest_revision',
|
||||
'name',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
exclude_fields = kwargs.pop('exclude_fields', [])
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if exclude_fields:
|
||||
for field in exclude_fields:
|
||||
if field in self.fields:
|
||||
self.fields.pop(field)
|
||||
|
||||
def get_action(self, instance):
|
||||
serializer = ActionSerializer(
|
||||
instance.action, read_only=True, context={'request': self.context.get('request')})
|
||||
return serializer.data
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
instance.revise(**validated_data)
|
||||
return instance
|
||||
|
||||
def create(self, validated_data):
|
||||
recipe = Recipe.objects.create()
|
||||
return self.update(recipe, validated_data)
|
||||
|
||||
def validate_extra_filter_expression(self, value):
|
||||
jexl = JEXL()
|
||||
|
||||
# Add mock transforms for validation. See
|
||||
# http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#transforms
|
||||
# for a list of what transforms we expect to be available.
|
||||
jexl.add_transform('date', lambda x: x)
|
||||
jexl.add_transform('stableSample', lambda x: x)
|
||||
jexl.add_transform('bucketSample', lambda x: x)
|
||||
jexl.add_transform('preferenceValue', lambda x: x)
|
||||
jexl.add_transform('preferenceIsUserSet', lambda x: x)
|
||||
jexl.add_transform('preferenceExists', lambda x: x)
|
||||
|
||||
errors = list(jexl.validate(value))
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
|
||||
return value
|
||||
|
||||
def validate_arguments(self, value):
|
||||
# Get the schema associated with the selected action
|
||||
try:
|
||||
schema = Action.objects.get(pk=self.initial_data.get('action_id')).arguments_schema
|
||||
except:
|
||||
raise serializers.ValidationError('Could not find arguments schema.')
|
||||
|
||||
schemaValidator = JSONSchemaValidator(schema)
|
||||
errorResponse = {}
|
||||
errors = sorted(schemaValidator.iter_errors(value), key=lambda e: e.path)
|
||||
|
||||
# Loop through ValidationErrors returned by JSONSchema
|
||||
# Each error contains a message and a path attribute
|
||||
# message: string human-readable error explanation
|
||||
# path: list containing path to offending element
|
||||
for error in errors:
|
||||
currentLevel = errorResponse
|
||||
|
||||
# Loop through the path of the current error
|
||||
# e.g. ['surveys'][0]['weight']
|
||||
for index, path in enumerate(error.path):
|
||||
# If this key already exists in our error response, step into it
|
||||
if path in currentLevel:
|
||||
currentLevel = currentLevel[path]
|
||||
continue
|
||||
else:
|
||||
# If we haven't reached the end of the path, add this path
|
||||
# as a key in our error response object and step into it
|
||||
if index < len(error.path) - 1:
|
||||
currentLevel[path] = {}
|
||||
currentLevel = currentLevel[path]
|
||||
continue
|
||||
# If we've reached the final path, set the error message
|
||||
else:
|
||||
currentLevel[path] = error.message
|
||||
|
||||
if (errorResponse):
|
||||
raise serializers.ValidationError(errorResponse)
|
||||
|
||||
return value
|
|
@ -0,0 +1,185 @@
|
|||
import pytest
|
||||
from rest_framework import serializers
|
||||
|
||||
from normandy.base.tests import Whatever
|
||||
from normandy.recipes.tests import (
|
||||
ARGUMENTS_SCHEMA,
|
||||
ActionFactory,
|
||||
ApprovalRequestFactory,
|
||||
ChannelFactory,
|
||||
CountryFactory,
|
||||
LocaleFactory,
|
||||
RecipeFactory,
|
||||
)
|
||||
from normandy.recipes.api.v2.serializers import (
|
||||
ActionSerializer,
|
||||
RecipeRevisionSerializer,
|
||||
RecipeSerializer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
||||
class TestRecipeSerializer:
|
||||
def test_it_works(self, rf):
|
||||
channel = ChannelFactory()
|
||||
country = CountryFactory()
|
||||
locale = LocaleFactory()
|
||||
recipe = RecipeFactory(arguments={'foo': 'bar'}, channels=[channel], countries=[country],
|
||||
locales=[locale])
|
||||
approval = ApprovalRequestFactory(revision=recipe.latest_revision)
|
||||
action = recipe.action
|
||||
serializer = RecipeSerializer(recipe, context={'request': rf.get('/')})
|
||||
|
||||
assert serializer.data == {
|
||||
'name': recipe.name,
|
||||
'id': recipe.id,
|
||||
'last_updated': Whatever(),
|
||||
'enabled': recipe.enabled,
|
||||
'extra_filter_expression': recipe.extra_filter_expression,
|
||||
'filter_expression': recipe.filter_expression,
|
||||
'action': {
|
||||
'arguments_schema': {},
|
||||
'id': action.id,
|
||||
'implementation_url': Whatever(),
|
||||
'name': action.name,
|
||||
},
|
||||
'arguments': {
|
||||
'foo': 'bar',
|
||||
},
|
||||
'channels': [channel.slug],
|
||||
'countries': [country.code],
|
||||
'locales': [locale.code],
|
||||
'is_approved': False,
|
||||
'latest_revision': RecipeRevisionSerializer(recipe.latest_revision).data,
|
||||
'approved_revision': None,
|
||||
'approval_request': {
|
||||
'id': approval.id,
|
||||
'created': Whatever(),
|
||||
'creator': Whatever(),
|
||||
'approved': None,
|
||||
'approver': None,
|
||||
'comment': None,
|
||||
},
|
||||
}
|
||||
|
||||
# If the action specified cannot be found, raise validation
|
||||
# error indicating the arguments schema could not be loaded
|
||||
def test_validation_with_wrong_action(self):
|
||||
serializer = RecipeSerializer(data={
|
||||
'action': 'action-that-doesnt-exist', 'arguments': {}
|
||||
})
|
||||
|
||||
with pytest.raises(serializers.ValidationError):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
assert serializer.errors['arguments'] == ['Could not find arguments schema.']
|
||||
|
||||
# If the action can be found, raise validation error
|
||||
# with the arguments error formatted appropriately
|
||||
def test_validation_with_wrong_arguments(self):
|
||||
action = ActionFactory(
|
||||
name='show-heartbeat',
|
||||
arguments_schema=ARGUMENTS_SCHEMA
|
||||
)
|
||||
|
||||
serializer = RecipeSerializer(data={
|
||||
'action_id': action.id,
|
||||
'arguments': {
|
||||
'surveyId': '',
|
||||
'surveys': [
|
||||
{'title': '', 'weight': 1},
|
||||
{'title': 'bar', 'weight': 1},
|
||||
{'title': 'foo', 'weight': 0},
|
||||
{'title': 'baz', 'weight': 'lorem ipsum'}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
with pytest.raises(serializers.ValidationError):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
assert serializer.errors['arguments'] == {
|
||||
'surveyId': 'This field may not be blank.',
|
||||
'surveys': {
|
||||
0: {'title': 'This field may not be blank.'},
|
||||
2: {'weight': '0 is less than the minimum of 1'},
|
||||
3: {'weight': '\'lorem ipsum\' is not of type \'integer\''}
|
||||
}
|
||||
}
|
||||
|
||||
def test_validation_with_invalid_filter_expression(self):
|
||||
ActionFactory(
|
||||
name='show-heartbeat',
|
||||
arguments_schema=ARGUMENTS_SCHEMA
|
||||
)
|
||||
|
||||
serializer = RecipeSerializer(data={
|
||||
'name': 'bar',
|
||||
'enabled': True,
|
||||
'extra_filter_expression': 'inv(-alsid',
|
||||
'action': 'show-heartbeat',
|
||||
'arguments': {
|
||||
'surveyId': 'lorem-ipsum-dolor',
|
||||
'surveys': [
|
||||
{'title': 'adipscing', 'weight': 1},
|
||||
{'title': 'consequetar', 'weight': 1}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert serializer.errors['extra_filter_expression'] == [
|
||||
'Could not parse expression: inv(-alsid'
|
||||
]
|
||||
|
||||
def test_validation_with_valid_data(self):
|
||||
mockAction = ActionFactory(
|
||||
name='show-heartbeat',
|
||||
arguments_schema=ARGUMENTS_SCHEMA
|
||||
)
|
||||
|
||||
channel = ChannelFactory(slug='release')
|
||||
country = CountryFactory(code='CA')
|
||||
locale = LocaleFactory(code='en-US')
|
||||
|
||||
serializer = RecipeSerializer(data={
|
||||
'name': 'bar', 'enabled': True, 'extra_filter_expression': '[]',
|
||||
'action_id': mockAction.id,
|
||||
'channels': ['release'],
|
||||
'countries': ['CA'],
|
||||
'locales': ['en-US'],
|
||||
'arguments': {
|
||||
'surveyId': 'lorem-ipsum-dolor',
|
||||
'surveys': [
|
||||
{'title': 'adipscing', 'weight': 1},
|
||||
{'title': 'consequetar', 'weight': 1}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data == {
|
||||
'name': 'bar',
|
||||
'extra_filter_expression': '[]',
|
||||
'action': mockAction,
|
||||
'arguments': {
|
||||
'surveyId': 'lorem-ipsum-dolor',
|
||||
'surveys': [
|
||||
{'title': 'adipscing', 'weight': 1},
|
||||
{'title': 'consequetar', 'weight': 1}
|
||||
]
|
||||
},
|
||||
'channels': [channel],
|
||||
'countries': [country],
|
||||
'locales': [locale],
|
||||
}
|
||||
assert serializer.errors == {}
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
||||
class TestActionSerializer:
|
||||
def test_it_uses_cdn_url(self, rf, settings):
|
||||
settings.CDN_URL = 'https://example.com/cdn/'
|
||||
action = ActionFactory()
|
||||
serializer = ActionSerializer(action, context={'request': rf.get('/')})
|
||||
assert serializer.data['implementation_url'].startswith(settings.CDN_URL)
|
Загрузка…
Ссылка в новой задаче