зеркало из https://github.com/mozilla/normandy.git
Коммит
095ddf753d
|
@ -5,7 +5,7 @@ from urllib.parse import urlparse, urlunparse
|
|||
from product_details import product_details
|
||||
|
||||
from normandy.base.utils import canonical_json_dumps
|
||||
from normandy.recipes.api.serializers import ClientSerializer
|
||||
from normandy.recipes.api.v1.serializers import ClientSerializer
|
||||
from normandy.recipes.models import Action, Recipe
|
||||
from normandy.recipes.tests import ClientFactory, RecipeFactory, SignatureFactory
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ export default class PreferenceExperimentAction extends Action {
|
|||
// Once we remove self-repair support, we should be able to use native
|
||||
// async/await anyway, which solves the issue.
|
||||
const {
|
||||
slug, preferenceName, preferenceBranchType, branches, preferenceType
|
||||
slug, preferenceName, preferenceBranchType, branches, preferenceType,
|
||||
} = this.recipe.arguments;
|
||||
const experiments = this.normandy.preferenceExperiments;
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
export function fetchAction(name) {
|
||||
return async dispatch => {
|
||||
const requestId = `fetch-action-${name}`;
|
||||
const action = await dispatch(makeApiRequest(requestId, `action/${name}/`));
|
||||
const action = await dispatch(makeApiRequest(requestId, `v2/action/${name}/`));
|
||||
|
||||
dispatch({
|
||||
type: ACTION_RECEIVE,
|
||||
|
@ -23,7 +23,7 @@ export function fetchAction(name) {
|
|||
export function fetchAllActions() {
|
||||
return async dispatch => {
|
||||
const requestId = 'fetch-all-actions';
|
||||
const actions = await dispatch(makeApiRequest(requestId, 'action/'));
|
||||
const actions = await dispatch(makeApiRequest(requestId, 'v2/action/'));
|
||||
|
||||
actions.forEach(action => {
|
||||
dispatch({
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
export function fetchApprovalRequest(pk) {
|
||||
return async dispatch => {
|
||||
const requestId = `fetch-approval-request-${pk}`;
|
||||
const response = dispatch(makeApiRequest(requestId, `approval_request/${pk}/`));
|
||||
const response = dispatch(makeApiRequest(requestId, `v2/approval_request/${pk}/`));
|
||||
const approvalRequest = await response;
|
||||
|
||||
dispatch({
|
||||
|
@ -24,7 +24,7 @@ export function fetchApprovalRequest(pk) {
|
|||
export function fetchAllApprovalRequests() {
|
||||
return async dispatch => {
|
||||
const requestId = 'fetch-all-approval-requests';
|
||||
const approvalRequests = await dispatch(makeApiRequest(requestId, 'approval_request/'));
|
||||
const approvalRequests = await dispatch(makeApiRequest(requestId, 'v2/approval_request/'));
|
||||
|
||||
approvalRequests.forEach(approvalRequest => {
|
||||
dispatch({
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
ACTION_RECEIVE,
|
||||
RECIPE_RECEIVE,
|
||||
RECIPE_FILTERS_RECEIVE,
|
||||
RECIPE_HISTORY_RECEIVE,
|
||||
|
@ -10,15 +11,38 @@ import {
|
|||
} from '../requests/actions';
|
||||
|
||||
|
||||
export function fetchRecipe(pk) {
|
||||
return async dispatch => {
|
||||
const requestId = `fetch-recipe-${pk}`;
|
||||
const recipe = await dispatch(makeApiRequest(requestId, `recipe/${pk}/`));
|
||||
|
||||
function recipeReceived(recipe) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: RECIPE_RECEIVE,
|
||||
recipe,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: ACTION_RECEIVE,
|
||||
action: recipe.action,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: REVISION_RECEIVE,
|
||||
revision: recipe.latest_revision,
|
||||
});
|
||||
|
||||
if (recipe.approved_revision) {
|
||||
dispatch({
|
||||
type: REVISION_RECEIVE,
|
||||
revision: recipe.approved_revision,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function fetchRecipe(pk) {
|
||||
return async dispatch => {
|
||||
const requestId = `fetch-recipe-${pk}`;
|
||||
const recipe = await dispatch(makeApiRequest(requestId, `v2/recipe/${pk}/`));
|
||||
dispatch(recipeReceived(recipe));
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -26,13 +50,10 @@ export function fetchRecipe(pk) {
|
|||
export function fetchAllRecipes() {
|
||||
return async dispatch => {
|
||||
const requestId = 'fetch-all-recipes';
|
||||
const recipes = await dispatch(makeApiRequest(requestId, 'recipe/'));
|
||||
const recipes = await dispatch(makeApiRequest(requestId, 'v2/recipe/'));
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
dispatch({
|
||||
type: RECIPE_RECEIVE,
|
||||
recipe,
|
||||
});
|
||||
dispatch(recipeReceived(recipe));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -41,7 +62,7 @@ export function fetchAllRecipes() {
|
|||
export function fetchRecipeHistory(pk) {
|
||||
return async dispatch => {
|
||||
const requestId = `fetch-recipe-history-${pk}`;
|
||||
const revisions = await dispatch(makeApiRequest(requestId, `recipe/${pk}/history/`));
|
||||
const revisions = await dispatch(makeApiRequest(requestId, `v2/recipe/${pk}/history/`));
|
||||
|
||||
dispatch({
|
||||
type: RECIPE_HISTORY_RECEIVE,
|
||||
|
@ -62,7 +83,7 @@ export function fetchRecipeHistory(pk) {
|
|||
export function fetchRecipeFilters() {
|
||||
return async dispatch => {
|
||||
const requestId = 'fetch-recipe-filters';
|
||||
const filters = await dispatch(makeApiRequest(requestId, 'filters/'));
|
||||
const filters = await dispatch(makeApiRequest(requestId, 'v2/filters/'));
|
||||
|
||||
dispatch({
|
||||
type: RECIPE_FILTERS_RECEIVE,
|
||||
|
|
|
@ -31,9 +31,28 @@ function history(state = new Map(), action) {
|
|||
|
||||
|
||||
function items(state = new Map(), action) {
|
||||
let recipe;
|
||||
|
||||
switch (action.type) {
|
||||
case RECIPE_RECEIVE:
|
||||
return state.set(action.recipe.id, fromJS(action.recipe));
|
||||
recipe = fromJS(action.recipe);
|
||||
|
||||
// Normalize action field
|
||||
recipe = recipe.set('action_id', action.recipe.action.id);
|
||||
recipe = recipe.remove('action');
|
||||
|
||||
// Normalize latest_revision field
|
||||
recipe = recipe.set('latest_revision_id', action.recipe.latest_revision.id);
|
||||
recipe = recipe.remove('latest_revision');
|
||||
|
||||
// Normalize approved_revision field
|
||||
recipe = recipe.set('approved_revision_id', null);
|
||||
if (action.recipe.approved_revision) {
|
||||
recipe = recipe.set('approved_revision_id', action.recipe.approved_revision.id);
|
||||
}
|
||||
recipe = recipe.remove('approved_revision');
|
||||
|
||||
return state.set(action.recipe.id, recipe);
|
||||
|
||||
default:
|
||||
return state;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
ACTION_RECEIVE,
|
||||
REVISION_RECEIVE,
|
||||
} from '../action-types';
|
||||
|
||||
|
@ -7,15 +8,26 @@ import {
|
|||
} from '../requests/actions';
|
||||
|
||||
|
||||
export function fetchRevision(pk) {
|
||||
return async dispatch => {
|
||||
const requestId = `fetch-revision-${pk}`;
|
||||
const revision = await dispatch(makeApiRequest(requestId, `recipe_revision/${pk}/`));
|
||||
|
||||
function revisionReceived(revision) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: REVISION_RECEIVE,
|
||||
revision,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: ACTION_RECEIVE,
|
||||
action: revision.recipe.action,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function fetchRevision(pk) {
|
||||
return async dispatch => {
|
||||
const requestId = `fetch-revision-${pk}`;
|
||||
const revision = await dispatch(makeApiRequest(requestId, `v2/recipe_revision/${pk}/`));
|
||||
dispatch(revisionReceived(revision));
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -23,13 +35,10 @@ export function fetchRevision(pk) {
|
|||
export function fetchAllRevisions() {
|
||||
return async dispatch => {
|
||||
const requestId = 'fetch-all-revisions';
|
||||
const revisions = await dispatch(makeApiRequest(requestId, 'recipe_revision/'));
|
||||
const revisions = await dispatch(makeApiRequest(requestId, 'v2/recipe_revision/'));
|
||||
|
||||
revisions.forEach(revision => {
|
||||
dispatch({
|
||||
type: REVISION_RECEIVE,
|
||||
revision,
|
||||
});
|
||||
dispatch(revisionReceived(revision));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -7,9 +7,15 @@ import {
|
|||
|
||||
|
||||
function items(state = new Map(), action) {
|
||||
let revision;
|
||||
|
||||
switch (action.type) {
|
||||
case REVISION_RECEIVE:
|
||||
return state.set(action.revision.id, fromJS(action.revision));
|
||||
revision = fromJS(action.revision);
|
||||
revision = revision.setIn(['recipe', 'action_id'], action.revision.recipe.action.id);
|
||||
revision = revision.removeIn(['recipe', 'action']);
|
||||
|
||||
return state.set(action.revision.id, revision);
|
||||
|
||||
default:
|
||||
return state;
|
||||
|
|
|
@ -10,6 +10,21 @@ export const INITIAL_STATE = {
|
|||
|
||||
export const RECIPE = {
|
||||
id: 1,
|
||||
action: {
|
||||
id: 1,
|
||||
name: 'test-action',
|
||||
},
|
||||
latest_revision: {
|
||||
id: '9f86d081',
|
||||
recipe: {
|
||||
id: 1,
|
||||
action: {
|
||||
id: 1,
|
||||
name: 'test-action',
|
||||
},
|
||||
},
|
||||
},
|
||||
approved_revision: null,
|
||||
};
|
||||
|
||||
export const FILTERS = {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { fromJS, List } from 'immutable';
|
||||
import * as matchers from 'jasmine-immutable-matchers';
|
||||
|
||||
import {
|
||||
RECIPE_RECEIVE,
|
||||
|
@ -19,18 +20,34 @@ import {
|
|||
|
||||
|
||||
describe('Recipes reducer', () => {
|
||||
beforeEach(() => {
|
||||
jasmine.addMatchers(matchers);
|
||||
});
|
||||
|
||||
it('should return initial state by default', () => {
|
||||
expect(recipesReducer(undefined, {})).toEqual(INITIAL_STATE);
|
||||
});
|
||||
|
||||
it('should handle RECIPE_RECEIVE', () => {
|
||||
expect(recipesReducer(undefined, {
|
||||
const reducedRecipe = {
|
||||
...RECIPE,
|
||||
action_id: RECIPE.action.id,
|
||||
latest_revision_id: RECIPE.latest_revision.id,
|
||||
approved_revision_id: null,
|
||||
};
|
||||
|
||||
delete reducedRecipe.action;
|
||||
delete reducedRecipe.latest_revision;
|
||||
delete reducedRecipe.approved_revision;
|
||||
|
||||
const updatedState = recipesReducer(undefined, {
|
||||
type: RECIPE_RECEIVE,
|
||||
recipe: RECIPE,
|
||||
})).toEqual({
|
||||
...INITIAL_STATE,
|
||||
items: INITIAL_STATE.items.set(RECIPE.id, fromJS(RECIPE)),
|
||||
});
|
||||
|
||||
expect(updatedState.items).toEqualImmutable(
|
||||
INITIAL_STATE.items.set(RECIPE.id, fromJS(reducedRecipe))
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle RECIPE_FILTERS_RECEIVE', () => {
|
||||
|
|
|
@ -10,5 +10,9 @@ export const REVISION = {
|
|||
id: '9f86d081',
|
||||
recipe: {
|
||||
id: 1,
|
||||
action: {
|
||||
id: 1,
|
||||
name: 'test-action',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { fromJS } from 'immutable';
|
||||
import * as matchers from 'jasmine-immutable-matchers';
|
||||
|
||||
import {
|
||||
REVISION_RECEIVE,
|
||||
|
@ -12,17 +13,32 @@ import {
|
|||
|
||||
|
||||
describe('Revisions reducer', () => {
|
||||
beforeEach(() => {
|
||||
jasmine.addMatchers(matchers);
|
||||
});
|
||||
|
||||
it('should return initial state by default', () => {
|
||||
expect(revisionsReducer(undefined, {})).toEqual(INITIAL_STATE);
|
||||
});
|
||||
|
||||
it('should handle REVISION_RECEIVE', () => {
|
||||
expect(revisionsReducer(undefined, {
|
||||
const reducedRevision = {
|
||||
...REVISION,
|
||||
recipe: {
|
||||
...REVISION.recipe,
|
||||
action_id: REVISION.recipe.action.id,
|
||||
},
|
||||
};
|
||||
|
||||
delete reducedRevision.recipe.action;
|
||||
|
||||
const updatedState = revisionsReducer(undefined, {
|
||||
type: REVISION_RECEIVE,
|
||||
revision: REVISION,
|
||||
})).toEqual({
|
||||
...INITIAL_STATE,
|
||||
items: INITIAL_STATE.items.set(REVISION.id, fromJS(REVISION)),
|
||||
});
|
||||
|
||||
expect(updatedState.items).toEqualImmutable(
|
||||
INITIAL_STATE.items.set(REVISION.id, fromJS(reducedRevision))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const API_ROOT = '/api/v1/';
|
||||
const API_ROOT = '/api/';
|
||||
|
||||
export default async function apiFetch(url, options = {}) {
|
||||
let queryString = '';
|
||||
|
|
|
@ -23,7 +23,7 @@ from normandy.recipes.models import (
|
|||
Recipe,
|
||||
RecipeRevision
|
||||
)
|
||||
from normandy.recipes.api.serializers import (
|
||||
from normandy.recipes.api.v1.serializers import (
|
||||
ActionSerializer,
|
||||
ApprovalRequestSerializer,
|
||||
ClientSerializer,
|
|
@ -0,0 +1,193 @@
|
|||
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',
|
||||
'approval_request'])
|
||||
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,184 @@
|
|||
from django.db.models import Q
|
||||
|
||||
import django_filters
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.response import Response
|
||||
|
||||
from normandy.base.api import UpdateOrCreateModelViewSet
|
||||
from normandy.base.api.filters import CaseInsensitiveBooleanFilter
|
||||
from normandy.base.api.mixins import CachingViewsetMixin
|
||||
from normandy.base.api.permissions import AdminEnabledOrReadOnly
|
||||
from normandy.base.decorators import api_cache_control, reversion_transaction
|
||||
from normandy.recipes.models import (
|
||||
Action,
|
||||
ApprovalRequest,
|
||||
Recipe,
|
||||
RecipeRevision
|
||||
)
|
||||
from normandy.recipes.api.v2.serializers import (
|
||||
ActionSerializer,
|
||||
ApprovalRequestSerializer,
|
||||
RecipeRevisionSerializer,
|
||||
RecipeSerializer,
|
||||
)
|
||||
|
||||
|
||||
class ActionViewSet(CachingViewsetMixin, viewsets.ReadOnlyModelViewSet):
|
||||
"""Viewset for viewing recipe actions."""
|
||||
queryset = Action.objects.all()
|
||||
serializer_class = ActionSerializer
|
||||
|
||||
|
||||
class RecipeFilters(django_filters.FilterSet):
|
||||
enabled = CaseInsensitiveBooleanFilter(name='enabled', lookup_expr='eq')
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['latest_revision__action', 'enabled']
|
||||
|
||||
|
||||
class RecipeViewSet(CachingViewsetMixin, UpdateOrCreateModelViewSet):
|
||||
"""Viewset for viewing and uploading recipes."""
|
||||
queryset = Recipe.objects.all()
|
||||
serializer_class = RecipeSerializer
|
||||
filter_class = RecipeFilters
|
||||
permission_classes = [
|
||||
permissions.DjangoModelPermissionsOrAnonReadOnly,
|
||||
AdminEnabledOrReadOnly,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset
|
||||
|
||||
if self.request.GET.get('status') == 'enabled':
|
||||
queryset = queryset.filter(enabled=True)
|
||||
elif self.request.GET.get('status') == 'disabled':
|
||||
queryset = queryset.filter(enabled=False)
|
||||
|
||||
if 'channels' in self.request.GET:
|
||||
channels = self.request.GET.get('channels').split(',')
|
||||
queryset = queryset.filter(latest_revision__channels__slug__in=channels)
|
||||
|
||||
if 'countries' in self.request.GET:
|
||||
countries = self.request.GET.get('countries').split(',')
|
||||
queryset = queryset.filter(latest_revision__countries__code__in=countries)
|
||||
|
||||
if 'locales' in self.request.GET:
|
||||
locales = self.request.GET.get('locales').split(',')
|
||||
queryset = queryset.filter(latest_revision__locales__code__in=locales)
|
||||
|
||||
if 'text' in self.request.GET:
|
||||
text = self.request.GET.get('text')
|
||||
queryset = queryset.filter(Q(latest_revision__name__contains=text) |
|
||||
Q(latest_revision__extra_filter_expression__contains=text))
|
||||
|
||||
return queryset
|
||||
|
||||
@detail_route(methods=['GET'])
|
||||
@api_cache_control()
|
||||
def history(self, request, pk=None):
|
||||
recipe = self.get_object()
|
||||
serializer = RecipeRevisionSerializer(recipe.revisions.all(), many=True,
|
||||
context={'request': request})
|
||||
return Response(serializer.data)
|
||||
|
||||
@reversion_transaction
|
||||
@detail_route(methods=['POST'])
|
||||
def enable(self, request, pk=None):
|
||||
recipe = self.get_object()
|
||||
recipe.enabled = True
|
||||
|
||||
try:
|
||||
recipe.save()
|
||||
except Recipe.NotApproved as e:
|
||||
return Response({'enabled': str(e)}, status=status.HTTP_409_CONFLICT)
|
||||
|
||||
return Response(RecipeSerializer(recipe).data)
|
||||
|
||||
@reversion_transaction
|
||||
@detail_route(methods=['POST'])
|
||||
def disable(self, request, pk=None):
|
||||
recipe = self.get_object()
|
||||
recipe.enabled = False
|
||||
recipe.save()
|
||||
return Response(RecipeSerializer(recipe).data)
|
||||
|
||||
|
||||
class RecipeRevisionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = RecipeRevision.objects.all()
|
||||
serializer_class = RecipeRevisionSerializer
|
||||
permission_classes = [
|
||||
AdminEnabledOrReadOnly,
|
||||
permissions.DjangoModelPermissionsOrAnonReadOnly,
|
||||
]
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def request_approval(self, request, pk=None):
|
||||
revision = self.get_object()
|
||||
|
||||
if revision.approval_status is not None:
|
||||
return Response({'error': 'This revision already has an approval request.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
approval_request = revision.request_approval(creator=request.user)
|
||||
|
||||
return Response(ApprovalRequestSerializer(approval_request).data,
|
||||
status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class ApprovalRequestViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = ApprovalRequest.objects.all()
|
||||
serializer_class = ApprovalRequestSerializer
|
||||
permission_classes = [
|
||||
AdminEnabledOrReadOnly,
|
||||
permissions.DjangoModelPermissionsOrAnonReadOnly,
|
||||
]
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def approve(self, request, pk=None):
|
||||
approval_request = self.get_object()
|
||||
|
||||
if 'comment' not in request.data:
|
||||
return Response({'comment': 'This field is required.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
approval_request.approve(approver=request.user, comment=request.data.get('comment'))
|
||||
except ApprovalRequest.NotActionable:
|
||||
return Response(
|
||||
{'error': 'This approval request has already been approved or rejected.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
except ApprovalRequest.CannotActOnOwnRequest:
|
||||
return Response(
|
||||
{'error': 'You cannot approve your own approval request.'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
return Response(ApprovalRequestSerializer(approval_request).data)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def reject(self, request, pk=None):
|
||||
approval_request = self.get_object()
|
||||
|
||||
if 'comment' not in request.data:
|
||||
return Response({'comment': 'This field is required.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
approval_request.reject(approver=request.user, comment=request.data.get('comment'))
|
||||
except ApprovalRequest.NotActionable:
|
||||
return Response(
|
||||
{'error': 'This approval request has already been approved or rejected.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
except ApprovalRequest.CannotActOnOwnRequest:
|
||||
return Response(
|
||||
{'error': 'You cannot reject your own approval request.'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
return Response(ApprovalRequestSerializer(approval_request).data)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def close(self, request, pk=None):
|
||||
approval_request = self.get_object()
|
||||
approval_request.close()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
@ -187,7 +187,7 @@ class Recipe(DirtyFieldsMixin, models.Model):
|
|||
|
||||
def canonical_json(self):
|
||||
# Avoid circular import
|
||||
from normandy.recipes.api.serializers import MinimalRecipeSerializer
|
||||
from normandy.recipes.api.v1.serializers import MinimalRecipeSerializer
|
||||
data = MinimalRecipeSerializer(self).data
|
||||
return CanonicalJSONRenderer().render(data)
|
||||
|
||||
|
|
|
@ -11,8 +11,12 @@ from normandy.recipes.tests import (
|
|||
LocaleFactory,
|
||||
RecipeFactory,
|
||||
)
|
||||
from normandy.recipes.api.serializers import (
|
||||
ActionSerializer, RecipeSerializer, MinimalRecipeSerializer, SignedRecipeSerializer)
|
||||
from normandy.recipes.api.v1.serializers import (
|
||||
ActionSerializer,
|
||||
MinimalRecipeSerializer,
|
||||
RecipeSerializer,
|
||||
SignedRecipeSerializer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
|
@ -0,0 +1,711 @@
|
|||
import pytest
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from normandy.base.api.permissions import AdminEnabledOrReadOnly
|
||||
from normandy.base.tests import UserFactory, Whatever
|
||||
from normandy.base.utils import canonical_json_dumps
|
||||
from normandy.recipes.models import ApprovalRequest, Recipe
|
||||
from normandy.recipes.tests import (
|
||||
ActionFactory,
|
||||
ApprovalRequestFactory,
|
||||
ChannelFactory,
|
||||
CountryFactory,
|
||||
LocaleFactory,
|
||||
RecipeFactory,
|
||||
fake_sign,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestActionAPI(object):
|
||||
def test_it_works(self, api_client):
|
||||
res = api_client.get('/api/v2/action/')
|
||||
assert res.status_code == 200
|
||||
assert res.data == []
|
||||
|
||||
def test_it_serves_actions(self, api_client):
|
||||
action = ActionFactory(
|
||||
name='foo',
|
||||
implementation='foobar',
|
||||
arguments_schema={'type': 'object'}
|
||||
)
|
||||
|
||||
res = api_client.get('/api/v2/action/')
|
||||
action_url = reverse('recipes:action-implementation', kwargs={
|
||||
'name': action.name,
|
||||
'impl_hash': action.implementation_hash,
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.data == [
|
||||
{
|
||||
'id': action.id,
|
||||
'name': 'foo',
|
||||
'implementation_url': Whatever.endswith(action_url),
|
||||
'arguments_schema': {'type': 'object'}
|
||||
}
|
||||
]
|
||||
|
||||
def test_list_view_includes_cache_headers(self, api_client):
|
||||
res = api_client.get('/api/v2/action/')
|
||||
assert res.status_code == 200
|
||||
# It isn't important to assert a particular value for max-age
|
||||
assert 'max-age=' in res['Cache-Control']
|
||||
assert 'public' in res['Cache-Control']
|
||||
|
||||
def test_detail_view_includes_cache_headers(self, api_client):
|
||||
action = ActionFactory()
|
||||
res = api_client.get('/api/v2/action/{id}/'.format(id=action.id))
|
||||
assert res.status_code == 200
|
||||
# It isn't important to assert a particular value for max-age
|
||||
assert 'max-age=' in res['Cache-Control']
|
||||
assert 'public' in res['Cache-Control']
|
||||
|
||||
def test_list_sets_no_cookies(self, api_client):
|
||||
res = api_client.get('/api/v2/action/')
|
||||
assert res.status_code == 200
|
||||
assert 'Cookies' not in res
|
||||
|
||||
def test_detail_sets_no_cookies(self, api_client):
|
||||
action = ActionFactory()
|
||||
res = api_client.get('/api/v2/action/{id}/'.format(id=action.id))
|
||||
assert res.status_code == 200
|
||||
assert res.client.cookies == {}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRecipeAPI(object):
|
||||
def test_it_works(self, api_client):
|
||||
res = api_client.get('/api/v2/recipe/')
|
||||
assert res.status_code == 200
|
||||
assert res.data == []
|
||||
|
||||
def test_it_serves_recipes(self, api_client):
|
||||
recipe = RecipeFactory()
|
||||
|
||||
res = api_client.get('/api/v2/recipe/')
|
||||
assert res.status_code == 200
|
||||
assert res.data[0]['name'] == recipe.name
|
||||
|
||||
def test_it_can_create_recipes(self, api_client):
|
||||
action = ActionFactory()
|
||||
|
||||
# Enabled recipe
|
||||
res = api_client.post('/api/v2/recipe/', {
|
||||
'name': 'Test Recipe',
|
||||
'action_id': action.id,
|
||||
'arguments': {},
|
||||
'extra_filter_expression': 'whatever',
|
||||
'enabled': True
|
||||
})
|
||||
assert res.status_code == 201
|
||||
|
||||
recipes = Recipe.objects.all()
|
||||
assert recipes.count() == 1
|
||||
|
||||
def test_it_can_create_disabled_recipes(self, api_client):
|
||||
action = ActionFactory()
|
||||
|
||||
# Disabled recipe
|
||||
res = api_client.post('/api/v2/recipe/', {
|
||||
'name': 'Test Recipe',
|
||||
'action_id': action.id,
|
||||
'arguments': {},
|
||||
'extra_filter_expression': 'whatever',
|
||||
'enabled': False
|
||||
})
|
||||
assert res.status_code == 201
|
||||
|
||||
recipes = Recipe.objects.all()
|
||||
assert recipes.count() == 1
|
||||
|
||||
def test_it_can_edit_recipes(self, api_client):
|
||||
recipe = RecipeFactory(name='unchanged', extra_filter_expression='true')
|
||||
old_revision_id = recipe.revision_id
|
||||
|
||||
res = api_client.patch('/api/v2/recipe/%s/' % recipe.id, {
|
||||
'name': 'changed',
|
||||
'extra_filter_expression': 'false',
|
||||
})
|
||||
assert res.status_code == 200
|
||||
|
||||
recipe = Recipe.objects.all()[0]
|
||||
assert recipe.name == 'changed'
|
||||
assert recipe.filter_expression == 'false'
|
||||
assert recipe.revision_id != old_revision_id
|
||||
|
||||
def test_creation_when_action_does_not_exist(self, api_client):
|
||||
res = api_client.post('/api/v2/recipe/', {'name': 'Test Recipe',
|
||||
'action_id': 1234,
|
||||
'arguments': '{}'})
|
||||
assert res.status_code == 400
|
||||
|
||||
recipes = Recipe.objects.all()
|
||||
assert recipes.count() == 0
|
||||
|
||||
def test_creation_when_arguments_are_invalid(self, api_client):
|
||||
action = ActionFactory(
|
||||
name='foobarbaz',
|
||||
arguments_schema={
|
||||
'type': 'object',
|
||||
'properties': {'message': {'type': 'string'}},
|
||||
'required': ['message']
|
||||
}
|
||||
)
|
||||
res = api_client.post('/api/v2/recipe/', {'name': 'Test Recipe',
|
||||
'enabled': True,
|
||||
'extra_filter_expression': 'true',
|
||||
'action_id': action.id,
|
||||
'arguments': {'message': ''}})
|
||||
assert res.status_code == 400
|
||||
|
||||
recipes = Recipe.objects.all()
|
||||
assert recipes.count() == 0
|
||||
|
||||
def test_it_can_change_action_for_recipes(self, api_client):
|
||||
recipe = RecipeFactory()
|
||||
action = ActionFactory()
|
||||
|
||||
res = api_client.patch('/api/v2/recipe/%s/' % recipe.id, {'action_id': action.id})
|
||||
assert res.status_code == 200
|
||||
|
||||
recipe = Recipe.objects.get(pk=recipe.id)
|
||||
assert recipe.action == action
|
||||
|
||||
def test_it_can_change_arguments_for_recipes(self, api_client):
|
||||
recipe = RecipeFactory(arguments_json='{}')
|
||||
action = ActionFactory(
|
||||
name='foobarbaz',
|
||||
arguments_schema={
|
||||
'type': 'object',
|
||||
'properties': {'message': {'type': 'string'}},
|
||||
'required': ['message']
|
||||
}
|
||||
)
|
||||
|
||||
arguments = {'message': 'test message'}
|
||||
|
||||
res = api_client.patch('/api/v2/recipe/%s/' % recipe.id, {
|
||||
'action_id': action.id, 'arguments': arguments})
|
||||
assert res.status_code == 200
|
||||
|
||||
recipe = Recipe.objects.get(pk=recipe.id)
|
||||
assert recipe.arguments == arguments
|
||||
|
||||
def test_it_can_delete_recipes(self, api_client):
|
||||
recipe = RecipeFactory()
|
||||
|
||||
res = api_client.delete('/api/v2/recipe/%s/' % recipe.id)
|
||||
assert res.status_code == 204
|
||||
|
||||
recipes = Recipe.objects.all()
|
||||
assert recipes.count() == 0
|
||||
|
||||
def test_available_if_admin_enabled(self, api_client, settings):
|
||||
settings.ADMIN_ENABLED = True
|
||||
res = api_client.get('/api/v2/recipe/')
|
||||
assert res.status_code == 200
|
||||
assert res.data == []
|
||||
|
||||
def test_readonly_if_admin_disabled(self, api_client, settings):
|
||||
settings.ADMIN_ENABLED = False
|
||||
res = api_client.get('/api/v2/recipe/')
|
||||
assert res.status_code == 200
|
||||
|
||||
recipe = RecipeFactory(name='unchanged')
|
||||
res = api_client.patch('/api/v2/recipe/%s/' % recipe.id, {'name': 'changed'})
|
||||
assert res.status_code == 403
|
||||
assert res.data['detail'] == AdminEnabledOrReadOnly.message
|
||||
|
||||
def test_history(self, api_client):
|
||||
recipe = RecipeFactory(name='version 1')
|
||||
recipe.revise(name='version 2')
|
||||
recipe.revise(name='version 3')
|
||||
|
||||
res = api_client.get('/api/v2/recipe/%s/history/' % recipe.id)
|
||||
|
||||
assert res.data[0]['recipe']['name'] == 'version 3'
|
||||
assert res.data[1]['recipe']['name'] == 'version 2'
|
||||
assert res.data[2]['recipe']['name'] == 'version 1'
|
||||
|
||||
def test_it_can_enable_recipes(self, api_client):
|
||||
recipe = RecipeFactory(enabled=False, approver=UserFactory())
|
||||
|
||||
res = api_client.post('/api/v2/recipe/%s/enable/' % recipe.id)
|
||||
assert res.status_code == 200
|
||||
assert res.data['enabled'] is True
|
||||
|
||||
recipe = Recipe.objects.all()[0]
|
||||
assert recipe.enabled
|
||||
|
||||
def test_cannot_enable_unapproved_recipes(self, api_client):
|
||||
recipe = RecipeFactory(enabled=False)
|
||||
|
||||
res = api_client.post('/api/v2/recipe/%s/enable/' % recipe.id)
|
||||
assert res.status_code == 409
|
||||
assert res.data['enabled'] == 'Cannot enable a recipe that is not approved.'
|
||||
|
||||
def test_it_can_disable_recipes(self, api_client):
|
||||
recipe = RecipeFactory(approver=UserFactory(), enabled=True)
|
||||
|
||||
res = api_client.post('/api/v2/recipe/%s/disable/' % recipe.id)
|
||||
assert res.status_code == 200
|
||||
assert res.data['enabled'] is False
|
||||
|
||||
recipe = Recipe.objects.all()[0]
|
||||
assert not recipe.is_approved
|
||||
assert not recipe.enabled
|
||||
|
||||
def test_filtering_by_enabled_lowercase(self, api_client):
|
||||
r1 = RecipeFactory(approver=UserFactory(), enabled=True)
|
||||
RecipeFactory(enabled=False)
|
||||
|
||||
res = api_client.get('/api/v2/recipe/?enabled=true')
|
||||
assert res.status_code == 200
|
||||
assert [r['id'] for r in res.data] == [r1.id]
|
||||
|
||||
def test_filtering_by_enabled_fuzz(self, api_client):
|
||||
"""
|
||||
Test that we don't return 500 responses when we get unexpected boolean filters.
|
||||
|
||||
This was a real case that showed up in our error logging.
|
||||
"""
|
||||
url = "/api/v2/recipe/?enabled=javascript%3a%2f*<%2fscript><svg%2fonload%3d'%2b%2f'%2f%2b"
|
||||
res = api_client.get(url)
|
||||
assert res.status_code == 400
|
||||
assert res.data == {
|
||||
'messages': [
|
||||
"'javascript:/*</script><svg/onload='+/'/+' value must be either True or False.",
|
||||
],
|
||||
}
|
||||
|
||||
def test_list_view_includes_cache_headers(self, api_client):
|
||||
res = api_client.get('/api/v2/recipe/')
|
||||
assert res.status_code == 200
|
||||
# It isn't important to assert a particular value for max_age
|
||||
assert 'max-age=' in res['Cache-Control']
|
||||
assert 'public' in res['Cache-Control']
|
||||
|
||||
def test_detail_view_includes_cache_headers(self, api_client):
|
||||
recipe = RecipeFactory()
|
||||
res = api_client.get(f'/api/v2/recipe/{recipe.id}/')
|
||||
assert res.status_code == 200
|
||||
# It isn't important to assert a particular value for max-age
|
||||
assert 'max-age=' in res['Cache-Control']
|
||||
assert 'public' in res['Cache-Control']
|
||||
|
||||
def test_list_sets_no_cookies(self, api_client):
|
||||
res = api_client.get('/api/v2/recipe/')
|
||||
assert res.status_code == 200
|
||||
assert 'Cookies' not in res
|
||||
|
||||
def test_detail_sets_no_cookies(self, api_client):
|
||||
recipe = RecipeFactory()
|
||||
res = api_client.get('/api/v2/recipe/{id}/'.format(id=recipe.id))
|
||||
assert res.status_code == 200
|
||||
assert res.client.cookies == {}
|
||||
|
||||
def test_list_filter_status(self, api_client):
|
||||
r1 = RecipeFactory(enabled=False)
|
||||
r2 = RecipeFactory(approver=UserFactory(), enabled=True)
|
||||
|
||||
res = api_client.get('/api/v2/recipe/?status=enabled')
|
||||
assert res.status_code == 200
|
||||
assert len(res.data) == 1
|
||||
assert res.data[0]['id'] == r2.id
|
||||
|
||||
res = api_client.get('/api/v2/recipe/?status=disabled')
|
||||
assert res.status_code == 200
|
||||
assert len(res.data) == 1
|
||||
assert res.data[0]['id'] == r1.id
|
||||
|
||||
def test_list_filter_channels(self, api_client):
|
||||
r1 = RecipeFactory(channels=[ChannelFactory(slug='beta')])
|
||||
r2 = RecipeFactory(channels=[ChannelFactory(slug='release')])
|
||||
|
||||
res = api_client.get('/api/v2/recipe/?channels=beta')
|
||||
assert res.status_code == 200
|
||||
assert len(res.data) == 1
|
||||
assert res.data[0]['id'] == r1.id
|
||||
|
||||
res = api_client.get('/api/v2/recipe/?channels=beta,release')
|
||||
assert res.status_code == 200
|
||||
assert len(res.data) == 2
|
||||
for recipe in res.data:
|
||||
assert recipe['id'] in [r1.id, r2.id]
|
||||
|
||||
def test_list_filter_countries(self, api_client):
|
||||
r1 = RecipeFactory(countries=[CountryFactory(code='US')])
|
||||
r2 = RecipeFactory(countries=[CountryFactory(code='CA')])
|
||||
|
||||
res = api_client.get('/api/v2/recipe/?countries=US')
|
||||
assert res.status_code == 200
|
||||
assert len(res.data) == 1
|
||||
assert res.data[0]['id'] == r1.id
|
||||
|
||||
res = api_client.get('/api/v2/recipe/?countries=US,CA')
|
||||
assert res.status_code == 200
|
||||
assert len(res.data) == 2
|
||||
for recipe in res.data:
|
||||
assert recipe['id'] in [r1.id, r2.id]
|
||||
|
||||
def test_list_filter_locales(self, api_client):
|
||||
r1 = RecipeFactory(locales=[LocaleFactory(code='en-US')])
|
||||
r2 = RecipeFactory(locales=[LocaleFactory(code='fr-CA')])
|
||||
|
||||
res = api_client.get('/api/v2/recipe/?locales=en-US')
|
||||
assert res.status_code == 200
|
||||
assert len(res.data) == 1
|
||||
assert res.data[0]['id'] == r1.id
|
||||
|
||||
res = api_client.get('/api/v2/recipe/?locales=en-US,fr-CA')
|
||||
assert res.status_code == 200
|
||||
assert len(res.data) == 2
|
||||
for recipe in res.data:
|
||||
assert recipe['id'] in [r1.id, r2.id]
|
||||
|
||||
def test_list_filter_text(self, api_client):
|
||||
r1 = RecipeFactory(name='first', extra_filter_expression='1 + 1 == 2')
|
||||
r2 = RecipeFactory(name='second', extra_filter_expression='one + one == two')
|
||||
|
||||
res = api_client.get('/api/v2/recipe/?text=first')
|
||||
assert res.status_code == 200
|
||||
assert len(res.data) == 1
|
||||
assert res.data[0]['id'] == r1.id
|
||||
|
||||
res = api_client.get('/api/v2/recipe/?text=one')
|
||||
assert res.status_code == 200
|
||||
assert len(res.data) == 1
|
||||
assert res.data[0]['id'] == r2.id
|
||||
|
||||
res = api_client.get('/api/v2/recipe/?text=t')
|
||||
assert res.status_code == 200
|
||||
assert len(res.data) == 2
|
||||
for recipe in res.data:
|
||||
assert recipe['id'] in [r1.id, r2.id]
|
||||
|
||||
def test_update_recipe_action(self, api_client):
|
||||
r = RecipeFactory()
|
||||
a = ActionFactory(name='test')
|
||||
|
||||
res = api_client.patch(f'/api/v2/recipe/{r.pk}/', {'action_id': a.id})
|
||||
assert res.status_code == 200
|
||||
|
||||
r.refresh_from_db()
|
||||
assert r.action == a
|
||||
|
||||
def test_update_recipe_locale(self, api_client):
|
||||
l1 = LocaleFactory(code='fr-FR')
|
||||
l2 = LocaleFactory(code='en-US')
|
||||
r = RecipeFactory(locales=[l1])
|
||||
|
||||
res = api_client.patch(f'/api/v2/recipe/{r.pk}/', {'locales': ['en-US']})
|
||||
assert res.status_code == 200
|
||||
|
||||
r.refresh_from_db()
|
||||
assert list(r.locales.all()) == [l2]
|
||||
|
||||
def test_update_recipe_country(self, api_client):
|
||||
c1 = CountryFactory(code='US')
|
||||
c2 = CountryFactory(code='CA')
|
||||
r = RecipeFactory(countries=[c1])
|
||||
|
||||
res = api_client.patch(f'/api/v2/recipe/{r.pk}/', {'countries': ['CA']})
|
||||
assert res.status_code == 200
|
||||
|
||||
r.refresh_from_db()
|
||||
assert list(r.countries.all()) == [c2]
|
||||
|
||||
def test_update_recipe_channel(self, api_client):
|
||||
c1 = ChannelFactory(slug='release')
|
||||
c2 = ChannelFactory(slug='beta')
|
||||
r = RecipeFactory(channels=[c1])
|
||||
|
||||
res = api_client.patch(f'/api/v2/recipe/{r.pk}/', {'channels': ['beta']})
|
||||
assert res.status_code == 200
|
||||
|
||||
r.refresh_from_db()
|
||||
assert list(r.channels.all()) == [c2]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRecipeRevisionAPI(object):
|
||||
def test_it_works(self, api_client):
|
||||
res = api_client.get('/api/v2/recipe_revision/')
|
||||
assert res.status_code == 200
|
||||
assert res.data == []
|
||||
|
||||
def test_it_serves_revisions(self, api_client):
|
||||
recipe = RecipeFactory()
|
||||
res = api_client.get('/api/v2/recipe_revision/%s/' % recipe.latest_revision.id)
|
||||
assert res.status_code == 200
|
||||
assert res.data['id'] == recipe.latest_revision.id
|
||||
|
||||
def test_request_approval(self, api_client):
|
||||
recipe = RecipeFactory()
|
||||
res = api_client.post(
|
||||
'/api/v2/recipe_revision/{}/request_approval/'.format(recipe.latest_revision.id))
|
||||
assert res.status_code == 201
|
||||
assert res.data['id'] == recipe.latest_revision.approval_request.id
|
||||
|
||||
def test_cannot_open_second_approval_request(self, api_client):
|
||||
recipe = RecipeFactory()
|
||||
ApprovalRequestFactory(revision=recipe.latest_revision)
|
||||
res = api_client.post(
|
||||
'/api/v2/recipe_revision/{}/request_approval/'.format(recipe.latest_revision.id))
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestApprovalRequestAPI(object):
|
||||
def test_it_works(self, api_client):
|
||||
res = api_client.get('/api/v2/approval_request/')
|
||||
assert res.status_code == 200
|
||||
assert res.data == []
|
||||
|
||||
def test_approve(self, api_client):
|
||||
r = RecipeFactory()
|
||||
a = ApprovalRequestFactory(revision=r.latest_revision)
|
||||
res = api_client.post('/api/v2/approval_request/{}/approve/'.format(a.id),
|
||||
{'comment': 'r+'})
|
||||
assert res.status_code == 200
|
||||
|
||||
r.refresh_from_db()
|
||||
assert r.is_approved
|
||||
assert r.approved_revision.approval_request.comment == 'r+'
|
||||
|
||||
def test_approve_no_comment(self, api_client):
|
||||
r = RecipeFactory()
|
||||
a = ApprovalRequestFactory(revision=r.latest_revision)
|
||||
res = api_client.post('/api/v2/approval_request/{}/approve/'.format(a.id))
|
||||
assert res.status_code == 400
|
||||
assert res.data['comment'] == 'This field is required.'
|
||||
|
||||
def test_approve_not_actionable(self, api_client):
|
||||
r = RecipeFactory()
|
||||
a = ApprovalRequestFactory(revision=r.latest_revision)
|
||||
a.approve(UserFactory(), 'r+')
|
||||
|
||||
res = api_client.post('/api/v2/approval_request/{}/approve/'.format(a.id),
|
||||
{'comment': 'r+'})
|
||||
assert res.status_code == 400
|
||||
assert res.data['error'] == 'This approval request has already been approved or rejected.'
|
||||
|
||||
def test_reject(self, api_client):
|
||||
r = RecipeFactory()
|
||||
a = ApprovalRequestFactory(revision=r.latest_revision)
|
||||
res = api_client.post('/api/v2/approval_request/{}/reject/'.format(a.id),
|
||||
{'comment': 'r-'})
|
||||
assert res.status_code == 200
|
||||
|
||||
r.latest_revision.approval_request.refresh_from_db()
|
||||
assert r.latest_revision.approval_status == r.latest_revision.REJECTED
|
||||
assert r.latest_revision.approval_request.comment == 'r-'
|
||||
|
||||
def test_reject_no_comment(self, api_client):
|
||||
r = RecipeFactory()
|
||||
a = ApprovalRequestFactory(revision=r.latest_revision)
|
||||
res = api_client.post('/api/v2/approval_request/{}/reject/'.format(a.id))
|
||||
assert res.status_code == 400
|
||||
assert res.data['comment'] == 'This field is required.'
|
||||
|
||||
def test_reject_not_actionable(self, api_client):
|
||||
r = RecipeFactory()
|
||||
a = ApprovalRequestFactory(revision=r.latest_revision)
|
||||
a.approve(UserFactory(), 'r+')
|
||||
|
||||
res = api_client.post('/api/v2/approval_request/{}/reject/'.format(a.id),
|
||||
{'comment': '-r'})
|
||||
assert res.status_code == 400
|
||||
assert res.data['error'] == 'This approval request has already been approved or rejected.'
|
||||
|
||||
def test_close(self, api_client):
|
||||
r = RecipeFactory()
|
||||
a = ApprovalRequestFactory(revision=r.latest_revision)
|
||||
res = api_client.post('/api/v2/approval_request/{}/close/'.format(a.id))
|
||||
assert res.status_code == 204
|
||||
|
||||
with pytest.raises(ApprovalRequest.DoesNotExist):
|
||||
ApprovalRequest.objects.get(pk=a.pk)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestApprovalFlow(object):
|
||||
|
||||
def verify_signatures(self, api_client, expected_count=None):
|
||||
res = api_client.get('/api/v1/recipe/signed/')
|
||||
assert res.status_code == 200
|
||||
signed_data = res.json()
|
||||
|
||||
if expected_count is not None:
|
||||
assert len(signed_data) == expected_count
|
||||
|
||||
for recipe_and_signature in signed_data:
|
||||
recipe = recipe_and_signature['recipe']
|
||||
expected_signature = recipe_and_signature['signature']['signature']
|
||||
data = canonical_json_dumps(recipe).encode()
|
||||
actual_signature = fake_sign([data])[0]['signature']
|
||||
assert actual_signature == expected_signature
|
||||
|
||||
def test_full_approval_flow(self, api_client, mocked_autograph):
|
||||
# The `mocked_autograph` fixture is provided so that recipes can be signed
|
||||
|
||||
action = ActionFactory()
|
||||
user1 = UserFactory(is_superuser=True)
|
||||
user2 = UserFactory(is_superuser=True)
|
||||
api_client.force_authenticate(user1)
|
||||
|
||||
# Create a recipe
|
||||
res = api_client.post('/api/v2/recipe/', {
|
||||
'action_id': action.id,
|
||||
'arguments': {},
|
||||
'name': 'test recipe',
|
||||
'extra_filter_expression': 'counter == 0',
|
||||
'enabled': 'false',
|
||||
})
|
||||
assert res.status_code == 201
|
||||
recipe_data_0 = res.json()
|
||||
|
||||
# Request approval for it
|
||||
res = api_client.post('/api/v2/recipe_revision/{}/request_approval/'
|
||||
.format(recipe_data_0['latest_revision']['id']))
|
||||
approval_data = res.json()
|
||||
assert res.status_code == 201
|
||||
|
||||
# The requester isn't allowed to approve a recipe
|
||||
res = api_client.post('/api/v2/approval_request/{}/approve/'.format(approval_data['id']),
|
||||
{'comment': 'r+'})
|
||||
assert res.status_code == 403 # Forbidden
|
||||
|
||||
# Approve the recipe
|
||||
api_client.force_authenticate(user2)
|
||||
res = api_client.post('/api/v2/approval_request/{}/approve/'.format(approval_data['id']),
|
||||
{'comment': 'r+'})
|
||||
assert res.status_code == 200
|
||||
|
||||
# It is now visible in the API
|
||||
res = api_client.get('/api/v2/recipe/{}/'.format(recipe_data_0['id']))
|
||||
assert res.status_code == 200
|
||||
recipe_data_1 = res.json()
|
||||
self.verify_signatures(api_client, expected_count=1)
|
||||
|
||||
# Make another change
|
||||
api_client.force_authenticate(user1)
|
||||
res = api_client.patch('/api/v2/recipe/{}/'.format(recipe_data_1['id']), {
|
||||
'extra_filter_expression': 'counter == 1',
|
||||
})
|
||||
assert res.status_code == 200
|
||||
|
||||
# The change should not be visible yet, since it isn't approved
|
||||
res = api_client.get('/api/v2/recipe/{}/'.format(recipe_data_1['id']))
|
||||
assert res.status_code == 200
|
||||
recipe_data_2 = res.json()
|
||||
assert recipe_data_2['extra_filter_expression'] == 'counter == 0'
|
||||
self.verify_signatures(api_client, expected_count=1)
|
||||
|
||||
# Request approval for the change
|
||||
res = api_client.post('/api/v2/recipe_revision/{}/request_approval/'
|
||||
.format(recipe_data_2['latest_revision']['id']))
|
||||
approval_data = res.json()
|
||||
recipe_data_2['approval_request'] = approval_data
|
||||
recipe_data_2['latest_revision']['approval_request'] = approval_data
|
||||
assert res.status_code == 201
|
||||
|
||||
# The change should not be visible yet, since it isn't approved
|
||||
res = api_client.get('/api/v2/recipe/{}/'.format(recipe_data_1['id']))
|
||||
assert res.status_code == 200
|
||||
assert res.json() == recipe_data_2
|
||||
self.verify_signatures(api_client, expected_count=1)
|
||||
|
||||
# Reject the change
|
||||
api_client.force_authenticate(user2)
|
||||
res = api_client.post('/api/v2/approval_request/{}/reject/'.format(approval_data['id']),
|
||||
{'comment': 'r-'})
|
||||
approval_data = res.json()
|
||||
recipe_data_2['approval_request'] = approval_data
|
||||
recipe_data_2['latest_revision']['approval_request'] = approval_data
|
||||
assert res.status_code == 200
|
||||
|
||||
# The change should not be visible yet, since it isn't approved
|
||||
res = api_client.get('/api/v2/recipe/{}/'.format(recipe_data_1['id']))
|
||||
assert res.status_code == 200
|
||||
assert res.json() == recipe_data_2
|
||||
self.verify_signatures(api_client, expected_count=1)
|
||||
|
||||
# Make a third version of the recipe
|
||||
api_client.force_authenticate(user1)
|
||||
res = api_client.patch('/api/v2/recipe/{}/'.format(recipe_data_1['id']), {
|
||||
'extra_filter_expression': 'counter == 2',
|
||||
})
|
||||
recipe_data_3 = res.json()
|
||||
assert res.status_code == 200
|
||||
|
||||
# Request approval
|
||||
res = api_client.post('/api/v2/recipe_revision/{}/request_approval/'
|
||||
.format(recipe_data_3['latest_revision']['id']))
|
||||
approval_data = res.json()
|
||||
assert res.status_code == 201
|
||||
|
||||
# Approve the change
|
||||
api_client.force_authenticate(user2)
|
||||
res = api_client.post('/api/v2/approval_request/{}/approve/'.format(approval_data['id']),
|
||||
{'comment': 'r+'})
|
||||
assert res.status_code == 200
|
||||
|
||||
# The change should be visible now, since it is approved
|
||||
res = api_client.get('/api/v2/recipe/{}/'.format(recipe_data_1['id']))
|
||||
assert res.status_code == 200
|
||||
recipe_data_4 = res.json()
|
||||
assert recipe_data_4['extra_filter_expression'] == 'counter == 2'
|
||||
self.verify_signatures(api_client, expected_count=1)
|
||||
|
||||
def test_cancel_approval(self, api_client, mocked_autograph):
|
||||
action = ActionFactory()
|
||||
user1 = UserFactory(is_superuser=True)
|
||||
user2 = UserFactory(is_superuser=True)
|
||||
api_client.force_authenticate(user1)
|
||||
|
||||
# Create a recipe
|
||||
res = api_client.post('/api/v2/recipe/', {
|
||||
'action_id': action.id,
|
||||
'arguments': {},
|
||||
'name': 'test recipe',
|
||||
'extra_filter_expression': 'counter == 0',
|
||||
'enabled': 'false',
|
||||
})
|
||||
assert res.status_code == 201
|
||||
recipe_id = res.json()['id']
|
||||
revision_id = res.json()['latest_revision']['id']
|
||||
|
||||
# Request approval
|
||||
res = api_client.post(f'/api/v2/recipe_revision/{revision_id}/request_approval/')
|
||||
assert res.status_code == 201
|
||||
approval_request_id = res.json()['id']
|
||||
|
||||
# Approve the recipe
|
||||
api_client.force_authenticate(user2)
|
||||
res = api_client.post(
|
||||
f'/api/v2/approval_request/{approval_request_id}/approve/',
|
||||
{'comment': 'r+'}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
# Make another change
|
||||
api_client.force_authenticate(user1)
|
||||
res = api_client.patch(
|
||||
f'/api/v2/recipe/{recipe_id}/',
|
||||
{'extra_filter_expression': 'counter == 1'}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
revision_id = res.json()['latest_revision']['id']
|
||||
|
||||
# Request approval for the second change
|
||||
res = api_client.post(f'/api/v2/recipe_revision/{revision_id}/request_approval/')
|
||||
approval_request_id = res.json()['id']
|
||||
assert res.status_code == 201
|
||||
|
||||
# Cancel the approval request
|
||||
res = api_client.post(f'/api/v2/approval_request/{approval_request_id}/close/')
|
||||
assert res.status_code == 204
|
||||
|
||||
# The API should still have correct signatures
|
||||
self.verify_signatures(api_client, expected_count=1)
|
|
@ -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)
|
|
@ -1,34 +1,35 @@
|
|||
from django.conf.urls import url, include
|
||||
|
||||
from normandy.base.api.routers import MixedViewRouter
|
||||
from normandy.recipes.api.views import (
|
||||
ActionImplementationView,
|
||||
ActionViewSet,
|
||||
ApprovalRequestViewSet,
|
||||
ClassifyClient,
|
||||
Filters,
|
||||
RecipeViewSet,
|
||||
RecipeRevisionViewSet,
|
||||
)
|
||||
from normandy.recipes.api.v1 import views as api_v1_views
|
||||
from normandy.recipes.api.v2 import views as api_v2_views
|
||||
|
||||
|
||||
# API Router
|
||||
router = MixedViewRouter()
|
||||
router.register('action', ActionViewSet)
|
||||
router.register('recipe', RecipeViewSet)
|
||||
router.register('recipe_revision', RecipeRevisionViewSet)
|
||||
router.register(r'approval_request', ApprovalRequestViewSet)
|
||||
v1_router = MixedViewRouter()
|
||||
v1_router.register('action', api_v1_views.ActionViewSet)
|
||||
v1_router.register('recipe', api_v1_views.RecipeViewSet)
|
||||
v1_router.register('recipe_revision', api_v1_views.RecipeRevisionViewSet)
|
||||
v1_router.register(r'approval_request', api_v1_views.ApprovalRequestViewSet)
|
||||
|
||||
router.register_view('classify_client', ClassifyClient, name='classify-client', allow_cdn=False)
|
||||
router.register_view('filters', Filters, name='filters')
|
||||
v1_router.register_view('classify_client', api_v1_views.ClassifyClient, name='classify-client',
|
||||
allow_cdn=False)
|
||||
v1_router.register_view('filters', api_v1_views.Filters, name='filters')
|
||||
|
||||
v2_router = MixedViewRouter()
|
||||
v2_router.register('action', api_v2_views.ActionViewSet)
|
||||
v2_router.register('recipe', api_v2_views.RecipeViewSet)
|
||||
v2_router.register('recipe_revision', api_v2_views.RecipeRevisionViewSet)
|
||||
v2_router.register(r'approval_request', api_v2_views.ApprovalRequestViewSet)
|
||||
|
||||
app_name = 'recipes'
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^api/v1/', include(router.urls)),
|
||||
url(r'^api/v2/', include(v2_router.urls)),
|
||||
url(r'^api/v1/', include(v1_router.urls)),
|
||||
url(
|
||||
r'^api/v1/action/(?P<name>[_\-\w]+)/implementation/(?P<impl_hash>[0-9a-f]{40})/$',
|
||||
ActionImplementationView.as_view(),
|
||||
api_v1_views.ActionImplementationView.as_view(),
|
||||
name='action-implementation'
|
||||
),
|
||||
]
|
||||
|
|
Загрузка…
Ссылка в новой задаче