From 8f3931e02d0f0ba803075ca65dc8617ee959456f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 15 Jan 2013 17:50:39 +0000 Subject: Update docs --- docs/tutorial/6-resource-orientated-projects.md | 119 +++++++++++++++--------- 1 file changed, 76 insertions(+), 43 deletions(-) diff --git a/docs/tutorial/6-resource-orientated-projects.md b/docs/tutorial/6-resource-orientated-projects.md index 9ee599ae..97fb5d69 100644 --- a/docs/tutorial/6-resource-orientated-projects.md +++ b/docs/tutorial/6-resource-orientated-projects.md @@ -1,49 +1,93 @@ # Tutorial 6 - Resources -Resource classes are just View classes that don't have any handler methods bound to them. The actions on a resource are defined, +REST framework includes an abstraction for dealing with resources, that allows the developer to concentrate on modelling the state and interactions of the API, and leave the URL construction to be handled automatically, based on common conventions. -This allows us to: +To work with resources, we can use either the `Resource` class, which does not define any default handlers, or the `ModelResource` class, which provides a default set of CRUD operations. -* Encapsulate common behaviour across a class of views, in a single Resource class. -* Separate out the actions of a Resource from the specifics of how those actions should be bound to a particular set of URLs. +Resource classes are very similar to class based views, except that they provide operations such as `read`, or `update`, and not HTTP method handlers such as `get` or `put`. Resources are only bound to HTTP method handlers at the last moment, when they are instantiated into views, typically by using a `Router` class which handles the complexities of defining the URL conf for you. -## Refactoring to use Resources, not Views +## Refactoring to use Resources, instead of Views -For instance, we can re-write our 4 sets of views into something more compact... +Let's take our current set of views, and refactor them into resources. +We'll remove our existing `views.py` module, and instead create a `resources.py` -resources.py +Our `UserResource` is simple, since we just want the default model CRUD behavior, so we inherit from `ModelResource` and include the same set of attributes we used for the corresponding view classes. - class BlogPostResource(ModelResource): - serializer_class = BlogPostSerializer - model = BlogPost - permissions_classes = (permissions.IsAuthenticatedOrReadOnly,) - throttle_classes = (throttles.UserRateThrottle,) + class UserResource(resources.ModelResource): + model = User + serializer_class = UserSerializer + +There's a little bit more work to do for the `SnippetResource`. Again, we want the +default set of CRUD behavior, but we also want to include an endpoint for snippet highlights. + + class SnippetResource(resources.ModelResource): + model = Snippet + serializer_class = SnippetSerializer + permission_classes = (permissions.IsAuthenticatedOrReadOnly, + IsOwnerOrReadOnly,) + + @link(renderer_classes=[renderers.StaticHTMLRenderer]) + def highlight(self, request, *args, **kwargs): + snippet = self.get_object() + return Response(snippet.highlighted) + + def pre_save(self, obj): + obj.owner = self.request.user + +Notice that we've used the `@link` decorator for the `highlight` endpoint. This decorator can be used for non-CRUD endpoints that are "safe" operations that do not change server state. Using `@link` indicates that we want to use a `GET` method for these operations. For non-CRUD operations we can also use the `@action` decorator for any operations that change server state, which ensures that the `POST` method will be used for the operation. - class CommentResource(ModelResource): - serializer_class = CommentSerializer - model = Comment - permissions_classes = (permissions.IsAuthenticatedOrReadOnly,) - throttle_classes = (throttles.UserRateThrottle,) ## Binding Resources to URLs explicitly -The handler methods only get bound to the actions when we define the URLConf. Here's our urls.py: - comment_root = CommentResource.as_view(actions={ +The handler methods only get bound to the actions when we define the URLConf. +To see what's going on under the hood let's first explicitly create a set of views from our resources. + +In the `urls.py` file we first need to bind our resources to concrete views. + + snippet_list = SnippetResource.as_view(actions={ + 'get': 'list', + 'post': 'create' + }) + snippet_detail = SnippetResource.as_view(actions={ + 'get': 'retrieve', + 'put': 'update', + 'delete': 'destroy' + }) + snippet_highlight = SnippetResource.as_view(actions={ + 'get': 'highlight' + }) + user_list = UserResource.as_view(actions={ 'get': 'list', 'post': 'create' }) - comment_instance = CommentInstance.as_view(actions={ + user_detail = UserResource.as_view(actions={ 'get': 'retrieve', 'put': 'update', 'delete': 'destroy' }) - ... # And for blog post - - urlpatterns = patterns('blogpost.views', - url(r'^$', comment_root), - url(r'^(?P[0-9]+)$', comment_instance) - ... # And for blog post - ) + +We've now got a set of views exactly as we did before, that we can register with the URL conf. + +Replace the remainder of the `urls.py` file with the following: + + urlpatterns = format_suffix_patterns(patterns('snippets.views', + url(r'^$', 'api_root'), + url(r'^snippets/$', + snippet_list, + name='snippet-list'), + url(r'^snippets/(?P[0-9]+)/$', + snippet_detail, + name='snippet-detail'), + url(r'^snippets/(?P[0-9]+)/highlight/$', + snippet_highlight, + name='snippet-highlight'), + url(r'^users/$', + user_list, + name='user-list'), + url(r'^users/(?P[0-9]+)/$', + user_detail, + name='user-detail') + )) ## Using Routers @@ -52,25 +96,14 @@ Right now that hasn't really saved us a lot of code. However, now that we're us from blog import resources from rest_framework.routers import DefaultRouter - router = DefaultRouter() - router.register(resources.BlogPostResource) - router.register(resources.CommentResource) + router = DefaultRouter(include_root=True, include_format_suffixes=True) + router.register(resources.SnippetResource) + router.register(resources.UserResource) urlpatterns = router.urlpatterns ## Trade-offs between views vs resources. -Writing resource-oriented code can be a good thing. It helps ensure that URL conventions will be consistent across your APIs, and minimises the amount of code you need to write. - -The trade-off is that the behaviour is less explict. It can be more difficult to determine what code path is being followed, or where to override some behaviour. - -## Onwards and upwards. - -We've reached the end of our tutorial. If you want to get more involved in the REST framework project, here's a few places you can start: - -* Contribute on GitHub by reviewing issues, and submitting issues or pull requests. -* Join the REST framework group, and help build the community. -* Follow me [on Twitter][twitter] and say hi. +Writing resource-oriented code can be a good thing. It helps ensure that URL conventions will be consistent across your APIs, minimises the amount of code you need to write, and allows you to concentrate on the interactions and representations your API provides rather than the specifics of the URL conf. -**Now go build some awesome things.** +That doesn't mean it's always the right approach to take. There's a similar set of trade-offs to consider as when using class-based views. Using resources is less explicit than building your views individually. -[twitter]: https://twitter.com/_tomchristie -- cgit v1.2.3 From 52847a215d4e8de88e81d9ae79ce8bee9a36a9a2 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 15 Jan 2013 17:50:51 +0000 Subject: Fix implementation --- rest_framework/mixins.py | 3 -- rest_framework/resources.py | 67 ++++++++++++++++----------------------------- 2 files changed, 23 insertions(+), 47 deletions(-) diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py index 8873e4ae..9bd566da 100644 --- a/rest_framework/mixins.py +++ b/rest_framework/mixins.py @@ -25,9 +25,6 @@ class CreateModelMixin(object): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def pre_save(self, obj): - pass - class ListModelMixin(object): """ diff --git a/rest_framework/resources.py b/rest_framework/resources.py index dd8a5471..d4019a94 100644 --- a/rest_framework/resources.py +++ b/rest_framework/resources.py @@ -1,31 +1,27 @@ ##### RESOURCES AND ROUTERS ARE NOT YET IMPLEMENTED - PLACEHOLDER ONLY ##### from functools import update_wrapper -import inspect from django.utils.decorators import classonlymethod -from rest_framework import views, generics - - -def wrapped(source, dest): - """ - Copy public, non-method attributes from source to dest, and return dest. - """ - for attr in [attr for attr in dir(source) - if not attr.startswith('_') and not inspect.ismethod(attr)]: - setattr(dest, attr, getattr(source, attr)) - return dest +from rest_framework import views, generics, mixins ##### RESOURCES AND ROUTERS ARE NOT YET IMPLEMENTED - PLACEHOLDER ONLY ##### class ResourceMixin(object): """ - Clone Django's `View.as_view()` behaviour *except* using REST framework's - 'method -> action' binding for resources. + This is the magic. + + Overrides `.as_view()` so that it takes an `actions` keyword that performs + the binding of HTTP methods to actions on the Resource. + + For example, to create a concrete view binding the 'GET' and 'POST' methods + to the 'list' and 'create' actions... + + my_resource = MyResource.as_view({'get': 'list', 'post': 'create'}) """ @classonlymethod - def as_view(cls, actions, **initkwargs): + def as_view(cls, actions=None, **initkwargs): """ Main entry point for a request-response process. """ @@ -61,36 +57,19 @@ class ResourceMixin(object): return view -##### RESOURCES AND ROUTERS ARE NOT YET IMPLEMENTED - PLACEHOLDER ONLY ##### - class Resource(ResourceMixin, views.APIView): pass -##### RESOURCES AND ROUTERS ARE NOT YET IMPLEMENTED - PLACEHOLDER ONLY ##### - -class ModelResource(ResourceMixin, views.APIView): - # TODO: Actually delegation won't work - root_class = generics.ListCreateAPIView - detail_class = generics.RetrieveUpdateDestroyAPIView - - def root_view(self): - return wrapped(self, self.root_class()) - - def detail_view(self): - return wrapped(self, self.detail_class()) - - def list(self, request, *args, **kwargs): - return self.root_view().list(request, args, kwargs) - - def create(self, request, *args, **kwargs): - return self.root_view().create(request, args, kwargs) - - def retrieve(self, request, *args, **kwargs): - return self.detail_view().retrieve(request, args, kwargs) - - def update(self, request, *args, **kwargs): - return self.detail_view().update(request, args, kwargs) - - def destroy(self, request, *args, **kwargs): - return self.detail_view().destroy(request, args, kwargs) +# Note the inheritence of both MultipleObjectAPIView *and* SingleObjectAPIView +# is a bit weird given the diamond inheritence, but it will work for now. +# There's some implementation clean up that can happen later. +class ModelResource(mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + ResourceMixin, + generics.MultipleObjectAPIView, + generics.SingleObjectAPIView): + pass -- cgit v1.2.3 From 4a7139e41d2500776c30e663c1cebce74b49270d Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 15 Jan 2013 21:49:24 +0000 Subject: Tweaks --- docs/tutorial/6-resource-orientated-projects.md | 20 ++++++++------- rest_framework/routers.py | 33 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 rest_framework/routers.py diff --git a/docs/tutorial/6-resource-orientated-projects.md b/docs/tutorial/6-resource-orientated-projects.md index 97fb5d69..019371d7 100644 --- a/docs/tutorial/6-resource-orientated-projects.md +++ b/docs/tutorial/6-resource-orientated-projects.md @@ -44,23 +44,25 @@ To see what's going on under the hood let's first explicitly create a set of vie In the `urls.py` file we first need to bind our resources to concrete views. - snippet_list = SnippetResource.as_view(actions={ + from snippets import resources + + snippet_list = resources.SnippetResource.as_view({ 'get': 'list', 'post': 'create' }) - snippet_detail = SnippetResource.as_view(actions={ + snippet_detail = resources.SnippetResource.as_view({ 'get': 'retrieve', 'put': 'update', 'delete': 'destroy' }) - snippet_highlight = SnippetResource.as_view(actions={ + snippet_highlight = resources.SnippetResource.as_view({ 'get': 'highlight' }) - user_list = UserResource.as_view(actions={ + user_list = resources.UserResource.as_view({ 'get': 'list', 'post': 'create' }) - user_detail = UserResource.as_view(actions={ + user_detail = resources.UserResource.as_view({ 'get': 'retrieve', 'put': 'update', 'delete': 'destroy' @@ -93,12 +95,12 @@ Replace the remainder of the `urls.py` file with the following: Right now that hasn't really saved us a lot of code. However, now that we're using Resources rather than Views, we actually don't need to design the urlconf ourselves. The conventions for wiring up resources into views and urls can be handled automatically, using `Router` classes. All we need to do is register the appropriate resources with a router, and let it do the rest. Here's our re-wired `urls.py` file. - from blog import resources + from snippets import resources from rest_framework.routers import DefaultRouter - router = DefaultRouter(include_root=True, include_format_suffixes=True) - router.register(resources.SnippetResource) - router.register(resources.UserResource) + router = DefaultRouter() + router.register('snippets', resources.SnippetResource) + router.register('users', resources.UserResource) urlpatterns = router.urlpatterns ## Trade-offs between views vs resources. diff --git a/rest_framework/routers.py b/rest_framework/routers.py new file mode 100644 index 00000000..a5aef5b7 --- /dev/null +++ b/rest_framework/routers.py @@ -0,0 +1,33 @@ +# Not properly implemented yet, just the basic idea + + +class BaseRouter(object): + def __init__(self): + self.resources = [] + + def register(self, name, resource): + self.resources.append((name, resource)) + + @property + def urlpatterns(self): + ret = [] + + for name, resource in self.resources: + list_actions = { + 'get': getattr(resource, 'list', None), + 'post': getattr(resource, 'create', None) + } + detail_actions = { + 'get': getattr(resource, 'retrieve', None), + 'put': getattr(resource, 'update', None), + 'delete': getattr(resource, 'destroy', None) + } + list_regex = r'^%s/$' % name + detail_regex = r'^%s/(?P[0-9]+)/$' % name + list_name = '%s-list' + detail_name = '%s-detail' + + ret += url(list_regex, resource.as_view(list_actions), list_name) + ret += url(detail_regex, resource.as_view(detail_actions), detail_name) + + return ret -- cgit v1.2.3 From 4c6396108704d38f534a16577de59178b1d0df3b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 17 Jan 2013 12:30:28 +0000 Subject: Tweak resource docs --- docs/tutorial/6-resource-orientated-projects.md | 66 ++++++++----------------- 1 file changed, 21 insertions(+), 45 deletions(-) diff --git a/docs/tutorial/6-resource-orientated-projects.md b/docs/tutorial/6-resource-orientated-projects.md index 019371d7..37ab1419 100644 --- a/docs/tutorial/6-resource-orientated-projects.md +++ b/docs/tutorial/6-resource-orientated-projects.md @@ -42,65 +42,41 @@ Notice that we've used the `@link` decorator for the `highlight` endpoint. This The handler methods only get bound to the actions when we define the URLConf. To see what's going on under the hood let's first explicitly create a set of views from our resources. -In the `urls.py` file we first need to bind our resources to concrete views. +In the `urls.py` file we first need to bind our resource classes into a set of concrete views. - from snippets import resources + from snippets.resources import SnippetResource, UserResource + + snippet_list = SnippetResource.as_view({'get': 'list', 'post': 'create'}) + snippet_detail = SnippetResource.as_view({'get': 'retrieve', 'put': 'update', 'delete': 'destroy'}) + snippet_highlight = SnippetResource.as_view({'get': 'highlight'}) + user_list = UserResource.as_view({'get': 'list', 'post': 'create'}) + user_detail = UserResource.as_view({'get': 'retrieve', 'put': 'update', 'delete': 'destroy'}) - snippet_list = resources.SnippetResource.as_view({ - 'get': 'list', - 'post': 'create' - }) - snippet_detail = resources.SnippetResource.as_view({ - 'get': 'retrieve', - 'put': 'update', - 'delete': 'destroy' - }) - snippet_highlight = resources.SnippetResource.as_view({ - 'get': 'highlight' - }) - user_list = resources.UserResource.as_view({ - 'get': 'list', - 'post': 'create' - }) - user_detail = resources.UserResource.as_view({ - 'get': 'retrieve', - 'put': 'update', - 'delete': 'destroy' - }) - -We've now got a set of views exactly as we did before, that we can register with the URL conf. - -Replace the remainder of the `urls.py` file with the following: +Notice how create multiple views onto a single resource class, by binding the http methods to the required action for each view. + +Now that we've bound our resources into concrete views, that we can register the views with the URL conf as usual. urlpatterns = format_suffix_patterns(patterns('snippets.views', url(r'^$', 'api_root'), - url(r'^snippets/$', - snippet_list, - name='snippet-list'), - url(r'^snippets/(?P[0-9]+)/$', - snippet_detail, - name='snippet-detail'), - url(r'^snippets/(?P[0-9]+)/highlight/$', - snippet_highlight, - name='snippet-highlight'), - url(r'^users/$', - user_list, - name='user-list'), - url(r'^users/(?P[0-9]+)/$', - user_detail, - name='user-detail') + url(r'^snippets/$', snippet_list, name='snippet-list'), + url(r'^snippets/(?P[0-9]+)/$', snippet_detail, name='snippet-detail'), + url(r'^snippets/(?P[0-9]+)/highlight/$', snippet_highlight, name='snippet-highlight'), + url(r'^users/$', user_list, name='user-list'), + url(r'^users/(?P[0-9]+)/$', user_detail, name='user-detail') )) ## Using Routers -Right now that hasn't really saved us a lot of code. However, now that we're using Resources rather than Views, we actually don't need to design the urlconf ourselves. The conventions for wiring up resources into views and urls can be handled automatically, using `Router` classes. All we need to do is register the appropriate resources with a router, and let it do the rest. Here's our re-wired `urls.py` file. +Now that we're using Resources rather than Views, we actually don't need to design the URL conf ourselves. The conventions for wiring up resources into views and urls can be handled automatically, using `Router` classes. All we need to do is register the appropriate resources with a router, and let it do the rest. + +Here's our re-wired `urls.py` file. from snippets import resources from rest_framework.routers import DefaultRouter router = DefaultRouter() - router.register('snippets', resources.SnippetResource) - router.register('users', resources.UserResource) + router.register(r'^snippets/', resources.SnippetResource, 'snippet') + router.register(r'^users/', resources.UserResource, 'user') urlpatterns = router.urlpatterns ## Trade-offs between views vs resources. -- cgit v1.2.3 From 84be169353f0dd2ceb06fe459b72aa2452fcbeb5 Mon Sep 17 00:00:00 2001 From: Craig de Stigter Date: Fri, 1 Mar 2013 16:13:04 +1300 Subject: fix function names and dotted lookups for use in PrimaryKeyRelatedField.field_to_native (they work in RelatedField.field_to_native already) --- rest_framework/relations.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 0c108717..ef465b3c 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -215,12 +215,20 @@ class PrimaryKeyRelatedField(RelatedField): def field_to_native(self, obj, field_name): if self.many: # To-many relationship - try: + + queryset = None + if not self.source: # Prefer obj.serializable_value for performance reasons - queryset = obj.serializable_value(self.source or field_name) - except AttributeError: + try: + queryset = obj.serializable_value(field_name) + except AttributeError: + pass + if queryset is None: # RelatedManager (reverse relationship) - queryset = getattr(obj, self.source or field_name) + source = self.source or field_name + queryset = obj + for component in source.split('.'): + queryset = get_component(queryset, component) # Forward relationship return [self.to_native(item.pk) for item in queryset.all()] -- cgit v1.2.3 From 922ee61d8611b41e2944b6503af736b1790abe83 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 18 Mar 2013 21:05:13 +0000 Subject: Remove erronous pre_save --- rest_framework/generics.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 55918267..36ecf915 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -82,9 +82,6 @@ class GenericAPIView(views.APIView): """ pass - def pre_save(self, obj): - pass - class MultipleObjectAPIView(MultipleObjectMixin, GenericAPIView): """ -- cgit v1.2.3 From 6111dcef5216ff12fb9353bb8c7d71b021634513 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 22 Mar 2013 22:58:04 +0000 Subject: Update release notes --- docs/topics/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index f506c610..935bd905 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -42,6 +42,7 @@ You can determine your currently installed version using `pip freeze`: ### Master +* Serializers now support bulk create and bulk update operations. * Regression fix: Date and time fields return date/time objects by default. Fixes regressions caused by 2.2.2. See [#743][743] for more details. * Bugfix: Fix 500 error is OAuth not attempted with OAuthAuthentication class installed. * `Serializer.save()` now supports arbitrary keyword args which are passed through to the object `.save()` method. Mixins use `force_insert` and `force_update` where appropriate, resulting in one less database query. -- cgit v1.2.3 From dab158e1fd827285032e331c10acc60e9719ace3 Mon Sep 17 00:00:00 2001 From: Sitong Peng Date: Mon, 25 Mar 2013 09:27:12 -0700 Subject: Tiny typo --- docs/tutorial/4-authentication-and-permissions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/4-authentication-and-permissions.md b/docs/tutorial/4-authentication-and-permissions.md index 3ee755a2..878672bb 100644 --- a/docs/tutorial/4-authentication-and-permissions.md +++ b/docs/tutorial/4-authentication-and-permissions.md @@ -179,7 +179,7 @@ Now, if you open a browser again, you find that the 'DELETE' and 'PUT' actions o ## Authenticating with the API -Because we now have a set of permissions on the API, we need to authenticate our requests to it if we want to edit any snippets. We havn't set up any [authentication classes][authentication], so the defaults are currently applied, which are `SessionAuthentication` and `BasicAuthentication`. +Because we now have a set of permissions on the API, we need to authenticate our requests to it if we want to edit any snippets. We haven't set up any [authentication classes][authentication], so the defaults are currently applied, which are `SessionAuthentication` and `BasicAuthentication`. When we interact with the API through the web browser, we can login, and the browser session will then provide the required authentication for the requests. -- cgit v1.2.3 From dca24cd91488af62152b7e711cd6869b2d60c0ec Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 25 Mar 2013 16:44:36 +0000 Subject: Added @stoneg for typo fix #754. Thank you! --- docs/topics/credits.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index b533daa9..7dd9cd2c 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -112,6 +112,7 @@ The following people have helped make REST framework great. * Bouke Haarsma - [bouke] * Pierre Dulac - [dulaccc] * Dave Kuhn - [kuhnza] +* Sitong Peng - [stoneg] Many thanks to everyone who's contributed to the project. @@ -258,3 +259,4 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [bouke]: https://github.com/bouke [dulaccc]: https://github.com/dulaccc [kuhnza]: https://github.com/kuhnza +[stoneg]: https://github.com/stoneg -- cgit v1.2.3 From 7eefcf7e53f2bc37733a601041f23d354c7729f5 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 25 Mar 2013 20:26:34 +0000 Subject: Bulk update, allow_add_remove flag --- docs/api-guide/serializers.md | 10 ++++---- rest_framework/serializers.py | 16 +++++++----- rest_framework/tests/serializer_bulk_update.py | 34 ++++++++++++++++++++++---- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index 42e81cad..aaff760e 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -242,17 +242,17 @@ This allows you to write views that update or create multiple items when a `PUT` # True serialize.save() # `.save()` will be called on each updated or newly created instance. -Bulk updates will update any instances that already exist, and create new instances for data items that do not have a corresponding instance. +By default bulk updates will be limited to updating instances that already exist in the provided queryset. -When performing a bulk update you may want any items that are not present in the incoming data to be deleted. To do so, pass `allow_delete=True` to the serializer. +When performing a bulk update you may want to allow new items to be created, and missing items to be deleted. To do so, pass `allow_add_remove=True` to the serializer. - serializer = BookSerializer(queryset, data=data, many=True, allow_delete=True) + serializer = BookSerializer(queryset, data=data, many=True, allow_add_remove=True) serializer.is_valid() # True - serializer.save() # `.save()` will be called on each updated or newly created instance. + serializer.save() # `.save()` will be called on updated or newly created instances. # `.delete()` will be called on any other items in the `queryset`. -Passing `allow_delete=True` ensures that any update operations will completely overwrite the existing queryset, rather than simply updating any objects found in the incoming data. +Passing `allow_delete=True` ensures that any update operations will completely overwrite the existing queryset, rather than simply updating existing objects. #### How identity is determined when performing bulk updates diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 6aca2f57..1b2b0821 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -130,14 +130,14 @@ class BaseSerializer(WritableField): def __init__(self, instance=None, data=None, files=None, context=None, partial=False, many=None, - allow_delete=False, **kwargs): + allow_add_remove=False, **kwargs): super(BaseSerializer, self).__init__(**kwargs) self.opts = self._options_class(self.Meta) self.parent = None self.root = None self.partial = partial self.many = many - self.allow_delete = allow_delete + self.allow_add_remove = allow_add_remove self.context = context or {} @@ -154,8 +154,8 @@ class BaseSerializer(WritableField): if many and instance is not None and not hasattr(instance, '__iter__'): raise ValueError('instance should be a queryset or other iterable with many=True') - if allow_delete and not many: - raise ValueError('allow_delete should only be used for bulk updates, but you have not set many=True') + if allow_add_remove and not many: + raise ValueError('allow_add_remove should only be used for bulk updates, but you have not set many=True') ##### # Methods to determine which fields to use when (de)serializing objects. @@ -448,6 +448,10 @@ class BaseSerializer(WritableField): # Determine which object we're updating identity = self.get_identity(item) self.object = identity_to_objects.pop(identity, None) + if self.object is None and not self.allow_add_remove: + ret.append(None) + errors.append({'non_field_errors': ['Cannot create a new item, only existing items may be updated.']}) + continue ret.append(self.from_native(item, None)) errors.append(self._errors) @@ -457,7 +461,7 @@ class BaseSerializer(WritableField): self._errors = any(errors) and errors or [] else: - self._errors = {'non_field_errors': ['Expected a list of items']} + self._errors = {'non_field_errors': ['Expected a list of items.']} else: ret = self.from_native(data, files) @@ -508,7 +512,7 @@ class BaseSerializer(WritableField): else: self.save_object(self.object, **kwargs) - if self.allow_delete and self._deleted: + if self.allow_add_remove and self._deleted: [self.delete_object(item) for item in self._deleted] return self.object diff --git a/rest_framework/tests/serializer_bulk_update.py b/rest_framework/tests/serializer_bulk_update.py index afc1a1a9..8b0ded1a 100644 --- a/rest_framework/tests/serializer_bulk_update.py +++ b/rest_framework/tests/serializer_bulk_update.py @@ -98,7 +98,7 @@ class BulkCreateSerializerTests(TestCase): serializer = self.BookSerializer(data=data, many=True) self.assertEqual(serializer.is_valid(), False) - expected_errors = {'non_field_errors': ['Expected a list of items']} + expected_errors = {'non_field_errors': ['Expected a list of items.']} self.assertEqual(serializer.errors, expected_errors) @@ -115,7 +115,7 @@ class BulkCreateSerializerTests(TestCase): serializer = self.BookSerializer(data=data, many=True) self.assertEqual(serializer.is_valid(), False) - expected_errors = {'non_field_errors': ['Expected a list of items']} + expected_errors = {'non_field_errors': ['Expected a list of items.']} self.assertEqual(serializer.errors, expected_errors) @@ -201,11 +201,12 @@ class BulkUpdateSerializerTests(TestCase): 'author': 'Haruki Murakami' } ] - serializer = self.BookSerializer(self.books(), data=data, many=True, allow_delete=True) + serializer = self.BookSerializer(self.books(), data=data, many=True, allow_add_remove=True) self.assertEqual(serializer.is_valid(), True) self.assertEqual(serializer.data, data) serializer.save() new_data = self.BookSerializer(self.books(), many=True).data + self.assertEqual(data, new_data) def test_bulk_update_and_create(self): @@ -223,13 +224,36 @@ class BulkUpdateSerializerTests(TestCase): 'author': 'Haruki Murakami' } ] - serializer = self.BookSerializer(self.books(), data=data, many=True, allow_delete=True) + serializer = self.BookSerializer(self.books(), data=data, many=True, allow_add_remove=True) self.assertEqual(serializer.is_valid(), True) self.assertEqual(serializer.data, data) serializer.save() new_data = self.BookSerializer(self.books(), many=True).data self.assertEqual(data, new_data) + def test_bulk_update_invalid_create(self): + """ + Bulk update serialization without allow_add_remove may not create items. + """ + data = [ + { + 'id': 0, + 'title': 'The electric kool-aid acid test', + 'author': 'Tom Wolfe' + }, { + 'id': 3, + 'title': 'Kafka on the shore', + 'author': 'Haruki Murakami' + } + ] + expected_errors = [ + {}, + {'non_field_errors': ['Cannot create a new item, only existing items may be updated.']} + ] + serializer = self.BookSerializer(self.books(), data=data, many=True) + self.assertEqual(serializer.is_valid(), False) + self.assertEqual(serializer.errors, expected_errors) + def test_bulk_update_error(self): """ Incorrect bulk update serialization should return error data. @@ -249,6 +273,6 @@ class BulkUpdateSerializerTests(TestCase): {}, {'id': ['Enter a whole number.']} ] - serializer = self.BookSerializer(self.books(), data=data, many=True, allow_delete=True) + serializer = self.BookSerializer(self.books(), data=data, many=True, allow_add_remove=True) self.assertEqual(serializer.is_valid(), False) self.assertEqual(serializer.errors, expected_errors) -- cgit v1.2.3 From 8387cb5d1655e4d29cf8bca1919038091427e584 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 25 Mar 2013 20:28:17 +0000 Subject: Docs fix --- docs/api-guide/serializers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index aaff760e..dfa0cace 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -252,7 +252,7 @@ When performing a bulk update you may want to allow new items to be created, and serializer.save() # `.save()` will be called on updated or newly created instances. # `.delete()` will be called on any other items in the `queryset`. -Passing `allow_delete=True` ensures that any update operations will completely overwrite the existing queryset, rather than simply updating existing objects. +Passing `allow_add_remove=True` ensures that any update operations will completely overwrite the existing queryset, rather than simply updating existing objects. #### How identity is determined when performing bulk updates -- cgit v1.2.3 From 92c929094c88125ea4a2fd359ec99d2b4114f081 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 26 Mar 2013 07:48:53 +0000 Subject: Version 2.2.5 --- docs/api-guide/fields.md | 7 ++++++- docs/api-guide/serializers.md | 2 +- docs/topics/release-notes.md | 5 ++++- rest_framework/__init__.py | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index 02876936..42f89f46 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -197,12 +197,16 @@ If you want to override this behavior, you'll need to declare the `DateTimeField class Meta: model = Comment +Note that by default, datetime representations are deteremined by the renderer in use, although this can be explicitly overridden as detailed below. + +In the case of JSON this means the default datetime representation uses the [ECMA 262 date time string specification][ecma262]. This is a subset of ISO 8601 which uses millisecond precision, and includes the 'Z' suffix for the UTC timezone, for example: `2013-01-29T12:34:56.123Z`. + **Signature:** `DateTimeField(format=None, input_formats=None)` * `format` - A string representing the output format. If not specified, this defaults to `None`, which indicates that python `datetime` objects should be returned by `to_native`. In this case the datetime encoding will be determined by the renderer. * `input_formats` - A list of strings representing the input formats which may be used to parse the date. If not specified, the `DATETIME_INPUT_FORMATS` setting will be used, which defaults to `['iso-8601']`. -DateTime format strings may either be [python strftime formats][strftime] which explicitly specifiy the format, or the special string `'iso-8601'`, which indicates that [ISO 8601][iso8601] style datetimes should be used. (eg `'2013-01-29T12:34:56.000000'`) +DateTime format strings may either be [python strftime formats][strftime] which explicitly specifiy the format, or the special string `'iso-8601'`, which indicates that [ISO 8601][iso8601] style datetimes should be used. (eg `'2013-01-29T12:34:56.000000Z'`) ## DateField @@ -318,5 +322,6 @@ As an example, let's create a field that can be used represent the class name of [cite]: https://docs.djangoproject.com/en/dev/ref/forms/api/#django.forms.Form.cleaned_data [FILE_UPLOAD_HANDLERS]: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-FILE_UPLOAD_HANDLERS +[ecma262]: http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.15 [strftime]: http://docs.python.org/2/library/datetime.html#strftime-and-strptime-behavior [iso8601]: http://www.w3.org/TR/NOTE-datetime diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index dfa0cace..1a3c3431 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -256,7 +256,7 @@ Passing `allow_add_remove=True` ensures that any update operations will complete #### How identity is determined when performing bulk updates -Performing a bulk update is slightly more complicated than performing a bulk creation, because the serializer needs a way of determining how the items in the incoming data should be matched against the existing object instances. +Performing a bulk update is slightly more complicated than performing a bulk creation, because the serializer needs a way to determine how the items in the incoming data should be matched against the existing object instances. By default the serializer class will use the `id` key on the incoming data to determine the canonical identity of an object. If you need to change this behavior you should override the `get_identity` method on the `Serializer` class. For example: diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index f506c610..e63aee49 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -40,8 +40,11 @@ You can determine your currently installed version using `pip freeze`: ## 2.2.x series -### Master +### 2.2.5 +**Date**: 26th March 2013 + +* Serializer support for bulk create and bulk update operations. * Regression fix: Date and time fields return date/time objects by default. Fixes regressions caused by 2.2.2. See [#743][743] for more details. * Bugfix: Fix 500 error is OAuth not attempted with OAuthAuthentication class installed. * `Serializer.save()` now supports arbitrary keyword args which are passed through to the object `.save()` method. Mixins use `force_insert` and `force_update` where appropriate, resulting in one less database query. diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index cf005636..c86403d8 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -1,4 +1,4 @@ -__version__ = '2.2.4' +__version__ = '2.2.5' VERSION = __version__ # synonym -- cgit v1.2.3 From f1b8fee4f1e0ea2503d4e0453bdc3049edaa2598 Mon Sep 17 00:00:00 2001 From: Fernando Rocha Date: Wed, 27 Mar 2013 14:05:46 -0300 Subject: client credentials should be optional (fix #759) client credentials should only be required on token request Signed-off-by: Fernando Rocha --- rest_framework/authentication.py | 32 ++++++++++++++++++-------------- rest_framework/tests/authentication.py | 12 ++++++++++++ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 8f4ec536..f4626a2e 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -2,14 +2,16 @@ Provides a set of pluggable authentication policies. """ from __future__ import unicode_literals +import base64 +from datetime import datetime + from django.contrib.auth import authenticate from django.core.exceptions import ImproperlyConfigured from rest_framework import exceptions, HTTP_HEADER_ENCODING from rest_framework.compat import CsrfViewMiddleware from rest_framework.compat import oauth, oauth_provider, oauth_provider_store -from rest_framework.compat import oauth2_provider, oauth2_provider_forms, oauth2_provider_backends +from rest_framework.compat import oauth2_provider, oauth2_provider_forms from rest_framework.authtoken.models import Token -import base64 def get_authorization_header(request): @@ -314,22 +316,24 @@ class OAuth2Authentication(BaseAuthentication): """ Authenticate the request, given the access token. """ + client = None # Authenticate the client - oauth2_client_form = oauth2_provider_forms.ClientAuthForm(request.REQUEST) - if not oauth2_client_form.is_valid(): - raise exceptions.AuthenticationFailed('Client could not be validated') - client = oauth2_client_form.cleaned_data.get('client') - - # Retrieve the `OAuth2AccessToken` instance from the access_token - auth_backend = oauth2_provider_backends.AccessTokenBackend() - token = auth_backend.authenticate(access_token, client) - if token is None: - raise exceptions.AuthenticationFailed('Invalid token') + if 'client_id' in request.REQUEST: + oauth2_client_form = oauth2_provider_forms.ClientAuthForm(request.REQUEST) + if not oauth2_client_form.is_valid(): + raise exceptions.AuthenticationFailed('Client could not be validated') + client = oauth2_client_form.cleaned_data.get('client') - user = token.user + try: + token = oauth2_provider.models.AccessToken.objects.select_related('user') + if client is not None: + token = token.filter(client=client) + token = token.get(token=access_token, expires__gt=datetime.now()) + except oauth2_provider.models.AccessToken.DoesNotExist: + raise exceptions.AuthenticationFailed('Invalid token') - if not user.is_active: + if not token.user.is_active: msg = 'User inactive or deleted: %s' % user.username raise exceptions.AuthenticationFailed(msg) diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index b663ca48..375b19bd 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -516,6 +516,18 @@ class OAuth2Tests(TestCase): response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') + def test_get_form_passing_auth_without_client_params(self): + """ + Ensure GETing form over OAuth without client credentials + + Regression test for issue #759: + https://github.com/tomchristie/django-rest-framework/issues/759 + """ + auth = self._create_authorization_header() + response = self.csrf_client.get('/oauth2-test/', HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_post_form_passing_auth(self): """Ensure POSTing form over OAuth with correct credentials passes and does not require CSRF""" -- cgit v1.2.3 From 5f48b4a77e0a767694a32310a6368cd32b9a924c Mon Sep 17 00:00:00 2001 From: glic3rinu Date: Wed, 27 Mar 2013 22:43:41 +0100 Subject: Refactored urlize_quoted_links code, now based on Django 1.5 urlize --- rest_framework/templatetags/rest_framework.py | 79 +++++++++++++++------------ 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index c21ddcd7..50e485db 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals, absolute_import from django import template from django.core.urlresolvers import reverse, NoReverseMatch from django.http import QueryDict -from django.utils.html import escape +from django.utils.html import escape, smart_urlquote from django.utils.safestring import SafeData, mark_safe from rest_framework.compat import urlparse from rest_framework.compat import force_text @@ -112,22 +112,6 @@ def replace_query_param(url, key, val): class_re = re.compile(r'(?<=class=["\'])(.*)(?=["\'])') -# Bunch of stuff cloned from urlize -LEADING_PUNCTUATION = ['(', '<', '<', '"', "'"] -TRAILING_PUNCTUATION = ['.', ',', ')', '>', '\n', '>', '"', "'"] -DOTS = ['·', '*', '\xe2\x80\xa2', '•', '•', '•'] -unencoded_ampersands_re = re.compile(r'&(?!(\w+|#\d+);)') -word_split_re = re.compile(r'(\s+)') -punctuation_re = re.compile('^(?P(?:%s)*)(?P.*?)(?P(?:%s)*)$' % \ - ('|'.join([re.escape(x) for x in LEADING_PUNCTUATION]), - '|'.join([re.escape(x) for x in TRAILING_PUNCTUATION]))) -simple_email_re = re.compile(r'^\S+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+$') -link_target_attribute_re = re.compile(r'(]*?)target=[^\s>]+') -html_gunk_re = re.compile(r'(?:
|<\/i>|<\/b>|<\/em>|<\/strong>|<\/?smallcaps>|<\/?uppercase>)', re.IGNORECASE) -hard_coded_bullets_re = re.compile(r'((?:

(?:%s).*?[a-zA-Z].*?

\s*)+)' % '|'.join([re.escape(x) for x in DOTS]), re.DOTALL) -trailing_empty_content_re = re.compile(r'(?:

(?: |\s|
)*?

\s*)+\Z') - - # And the template tags themselves... @register.simple_tag @@ -195,15 +179,25 @@ def add_class(value, css_class): return value +# Bunch of stuff cloned from urlize +TRAILING_PUNCTUATION = ['.', ',', ':', ';', '.)'] +WRAPPING_PUNCTUATION = [('(', ')'), ('<', '>'), ('[', ']'), ('<', '>'), + ('"', '"'), ("'", "'")] +word_split_re = re.compile(r'(\s+)') +simple_url_re = re.compile(r'^https?://\w', re.IGNORECASE) +simple_url_2_re = re.compile(r'^www\.|^(?!http)\w[^@]+\.(com|edu|gov|int|mil|net|org)$', re.IGNORECASE) +simple_email_re = re.compile(r'^\S+@\S+\.\S+$') + + @register.filter def urlize_quoted_links(text, trim_url_limit=None, nofollow=True, autoescape=True): """ Converts any URLs in text into clickable links. - Works on http://, https://, www. links and links ending in .org, .net or - .com. Links can have trailing punctuation (periods, commas, close-parens) - and leading punctuation (opening parens) and it'll still do the right - thing. + Works on http://, https://, www. links, and also on links ending in one of + the original seven gTLDs (.com, .edu, .gov, .int, .mil, .net, and .org). + Links can have trailing punctuation (periods, commas, close-parens) and + leading punctuation (opening parens) and it'll still do the right thing. If trim_url_limit is not None, the URLs in link text longer than this limit will truncated to trim_url_limit-3 characters and appended with an elipsis. @@ -216,24 +210,41 @@ def urlize_quoted_links(text, trim_url_limit=None, nofollow=True, autoescape=Tru trim_url = lambda x, limit=trim_url_limit: limit is not None and (len(x) > limit and ('%s...' % x[:max(0, limit - 3)])) or x safe_input = isinstance(text, SafeData) words = word_split_re.split(force_text(text)) - nofollow_attr = nofollow and ' rel="nofollow"' or '' for i, word in enumerate(words): match = None if '.' in word or '@' in word or ':' in word: - match = punctuation_re.match(word) - if match: - lead, middle, trail = match.groups() + # Deal with punctuation. + lead, middle, trail = '', word, '' + for punctuation in TRAILING_PUNCTUATION: + if middle.endswith(punctuation): + middle = middle[:-len(punctuation)] + trail = punctuation + trail + for opening, closing in WRAPPING_PUNCTUATION: + if middle.startswith(opening): + middle = middle[len(opening):] + lead = lead + opening + # Keep parentheses at the end only if they're balanced. + if (middle.endswith(closing) + and middle.count(closing) == middle.count(opening) + 1): + middle = middle[:-len(closing)] + trail = closing + trail + # Make URL we want to point to. url = None - if middle.startswith('http://') or middle.startswith('https://'): - url = middle - elif middle.startswith('www.') or ('@' not in middle and \ - middle and middle[0] in string.ascii_letters + string.digits and \ - (middle.endswith('.org') or middle.endswith('.net') or middle.endswith('.com'))): - url = 'http://%s' % middle - elif '@' in middle and not ':' in middle and simple_email_re.match(middle): - url = 'mailto:%s' % middle + nofollow_attr = ' rel="nofollow"' if nofollow else '' + if simple_url_re.match(middle): + url = smart_urlquote(middle) + elif simple_url_2_re.match(middle): + url = smart_urlquote('http://%s' % middle) + elif not ':' in middle and simple_email_re.match(middle): + local, domain = middle.rsplit('@', 1) + try: + domain = domain.encode('idna').decode('ascii') + except UnicodeError: + continue + url = 'mailto:%s@%s' % (local, domain) nofollow_attr = '' + # Make link. if url: trimmed = trim_url(middle) @@ -251,4 +262,4 @@ def urlize_quoted_links(text, trim_url_limit=None, nofollow=True, autoescape=Tru words[i] = mark_safe(word) elif autoescape: words[i] = escape(word) - return mark_safe(''.join(words)) + return ''.join(words) -- cgit v1.2.3 From 2c0363ddaec22ac54385f7e0c2e1401ed3ff0879 Mon Sep 17 00:00:00 2001 From: glic3rinu Date: Wed, 27 Mar 2013 22:58:11 +0100 Subject: Added quotes to TRAILING_PUNCTUATION used by urlize_quoted_links --- rest_framework/templatetags/rest_framework.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index 50e485db..78a3a9a1 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -180,7 +180,7 @@ def add_class(value, css_class): # Bunch of stuff cloned from urlize -TRAILING_PUNCTUATION = ['.', ',', ':', ';', '.)'] +TRAILING_PUNCTUATION = ['.', ',', ':', ';', '.)', '"', "'"] WRAPPING_PUNCTUATION = [('(', ')'), ('<', '>'), ('[', ']'), ('<', '>'), ('"', '"'), ("'", "'")] word_split_re = re.compile(r'(\s+)') -- cgit v1.2.3 From b2cea84fae4f721e8eb6432b3d1bab1309e21a00 Mon Sep 17 00:00:00 2001 From: Fernando Rocha Date: Wed, 27 Mar 2013 19:00:36 -0300 Subject: Complete remove of client checks from oauth2 Signed-off-by: Fernando Rocha --- docs/api-guide/authentication.md | 2 +- rest_framework/authentication.py | 12 ++---------- rest_framework/tests/authentication.py | 9 --------- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 541c6575..f1dd6f5f 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -294,7 +294,7 @@ The only thing needed to make the `OAuth2Authentication` class work is to insert The command line to test the authentication looks like: - curl -H "Authorization: Bearer " http://localhost:8000/api/?client_id=YOUR_CLIENT_ID\&client_secret=YOUR_CLIENT_SECRET + curl -H "Authorization: Bearer " http://localhost:8000/api/ --- diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index f4626a2e..145d4295 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -316,19 +316,11 @@ class OAuth2Authentication(BaseAuthentication): """ Authenticate the request, given the access token. """ - client = None - - # Authenticate the client - if 'client_id' in request.REQUEST: - oauth2_client_form = oauth2_provider_forms.ClientAuthForm(request.REQUEST) - if not oauth2_client_form.is_valid(): - raise exceptions.AuthenticationFailed('Client could not be validated') - client = oauth2_client_form.cleaned_data.get('client') try: token = oauth2_provider.models.AccessToken.objects.select_related('user') - if client is not None: - token = token.filter(client=client) + # TODO: Change to timezone aware datetime when oauth2_provider add + # support to it. token = token.get(token=access_token, expires__gt=datetime.now()) except oauth2_provider.models.AccessToken.DoesNotExist: raise exceptions.AuthenticationFailed('Invalid token') diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 375b19bd..629db422 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -499,15 +499,6 @@ class OAuth2Tests(TestCase): response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) - @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') - def test_get_form_with_wrong_client_data_failing_auth(self): - """Ensure GETing form over OAuth with incorrect client credentials fails""" - auth = self._create_authorization_header() - params = self._client_credentials_params() - params['client_id'] += 'a' - response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) - self.assertEqual(response.status_code, 401) - @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_get_form_passing_auth(self): """Ensure GETing form over OAuth with correct client credentials succeed""" -- cgit v1.2.3 From 8ec60a22e1c14792b7021ff9b4e940e16528788a Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Thu, 28 Mar 2013 00:57:23 +0100 Subject: Remove client credentials from all OAuth 2 tests --- rest_framework/tests/authentication.py | 45 ++++++++-------------------------- 1 file changed, 10 insertions(+), 35 deletions(-) diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 629db422..8e6d3e51 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -466,17 +466,13 @@ class OAuth2Tests(TestCase): def _create_authorization_header(self, token=None): return "Bearer {0}".format(token or self.access_token.token) - def _client_credentials_params(self): - return {'client_id': self.CLIENT_ID, 'client_secret': self.CLIENT_SECRET} - @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_get_form_with_wrong_authorization_header_token_type_failing(self): """Ensure that a wrong token type lead to the correct HTTP error status code""" auth = "Wrong token-type-obsviously" response = self.csrf_client.get('/oauth2-test/', {}, HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) - params = self._client_credentials_params() - response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + response = self.csrf_client.get('/oauth2-test/', HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') @@ -485,8 +481,7 @@ class OAuth2Tests(TestCase): auth = "Bearer wrong token format" response = self.csrf_client.get('/oauth2-test/', {}, HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) - params = self._client_credentials_params() - response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + response = self.csrf_client.get('/oauth2-test/', HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') @@ -495,27 +490,13 @@ class OAuth2Tests(TestCase): auth = "Bearer wrong-token" response = self.csrf_client.get('/oauth2-test/', {}, HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) - params = self._client_credentials_params() - response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + response = self.csrf_client.get('/oauth2-test/', HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_get_form_passing_auth(self): """Ensure GETing form over OAuth with correct client credentials succeed""" auth = self._create_authorization_header() - params = self._client_credentials_params() - response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) - self.assertEqual(response.status_code, 200) - - @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') - def test_get_form_passing_auth_without_client_params(self): - """ - Ensure GETing form over OAuth without client credentials - - Regression test for issue #759: - https://github.com/tomchristie/django-rest-framework/issues/759 - """ - auth = self._create_authorization_header() response = self.csrf_client.get('/oauth2-test/', HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) @@ -523,8 +504,7 @@ class OAuth2Tests(TestCase): def test_post_form_passing_auth(self): """Ensure POSTing form over OAuth with correct credentials passes and does not require CSRF""" auth = self._create_authorization_header() - params = self._client_credentials_params() - response = self.csrf_client.post('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + response = self.csrf_client.post('/oauth2-test/', HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') @@ -532,16 +512,14 @@ class OAuth2Tests(TestCase): """Ensure POSTing when there is no OAuth access token in db fails""" self.access_token.delete() auth = self._create_authorization_header() - params = self._client_credentials_params() - response = self.csrf_client.post('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + response = self.csrf_client.post('/oauth2-test/', HTTP_AUTHORIZATION=auth) self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_post_form_with_refresh_token_failing_auth(self): """Ensure POSTing with refresh token instead of access token fails""" auth = self._create_authorization_header(token=self.refresh_token.token) - params = self._client_credentials_params() - response = self.csrf_client.post('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + response = self.csrf_client.post('/oauth2-test/', HTTP_AUTHORIZATION=auth) self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') @@ -550,8 +528,7 @@ class OAuth2Tests(TestCase): self.access_token.expires = datetime.datetime.now() - datetime.timedelta(seconds=10) # 10 seconds late self.access_token.save() auth = self._create_authorization_header() - params = self._client_credentials_params() - response = self.csrf_client.post('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + response = self.csrf_client.post('/oauth2-test/', HTTP_AUTHORIZATION=auth) self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) self.assertIn('Invalid token', response.content) @@ -562,10 +539,9 @@ class OAuth2Tests(TestCase): read_only_access_token.scope = oauth2_provider_scope.SCOPE_NAME_DICT['read'] read_only_access_token.save() auth = self._create_authorization_header(token=read_only_access_token.token) - params = self._client_credentials_params() - response = self.csrf_client.get('/oauth2-with-scope-test/', params, HTTP_AUTHORIZATION=auth) + response = self.csrf_client.get('/oauth2-with-scope-test/', HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - response = self.csrf_client.post('/oauth2-with-scope-test/', params, HTTP_AUTHORIZATION=auth) + response = self.csrf_client.post('/oauth2-with-scope-test/', HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') @@ -575,6 +551,5 @@ class OAuth2Tests(TestCase): read_write_access_token.scope = oauth2_provider_scope.SCOPE_NAME_DICT['write'] read_write_access_token.save() auth = self._create_authorization_header(token=read_write_access_token.token) - params = self._client_credentials_params() - response = self.csrf_client.post('/oauth2-with-scope-test/', params, HTTP_AUTHORIZATION=auth) + response = self.csrf_client.post('/oauth2-with-scope-test/', HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) -- cgit v1.2.3 From fa61b2b2f10bf07e3cb87ca947ce7f0ca51a2ede Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Thu, 28 Mar 2013 01:05:51 +0100 Subject: Remove oauth2-provider backends reference from compat.py --- rest_framework/compat.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 7b2ef738..c3e423e8 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -445,14 +445,12 @@ except ImportError: # OAuth 2 support is optional try: import provider.oauth2 as oauth2_provider - from provider.oauth2 import backends as oauth2_provider_backends from provider.oauth2 import models as oauth2_provider_models from provider.oauth2 import forms as oauth2_provider_forms from provider import scope as oauth2_provider_scope from provider import constants as oauth2_constants except ImportError: oauth2_provider = None - oauth2_provider_backends = None oauth2_provider_models = None oauth2_provider_forms = None oauth2_provider_scope = None -- cgit v1.2.3 From b10663e02408404844aca4b362aa24df816aca98 Mon Sep 17 00:00:00 2001 From: Kevin Stone Date: Wed, 27 Mar 2013 17:55:36 -0700 Subject: Fixed DjangoFilterBackend not returning a query set. Fixed bug unveiled in #682. Signed-off-by: Kevin Stone --- rest_framework/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 6fea46fa..413fa0d2 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -55,6 +55,6 @@ class DjangoFilterBackend(BaseFilterBackend): filter_class = self.get_filter_class(view) if filter_class: - return filter_class(request.QUERY_PARAMS, queryset=queryset) + return filter_class(request.QUERY_PARAMS, queryset=queryset).qs return queryset -- cgit v1.2.3 From d4df617f8c1980c1d5f1b91a6b9928185c4c4dce Mon Sep 17 00:00:00 2001 From: Kevin Stone Date: Wed, 27 Mar 2013 18:29:50 -0700 Subject: Added unit test for failing DjangoFilterBackend on SingleObjectMixin that was resolved in b10663e02408404844aca4b362aa24df816aca98 Signed-off-by: Kevin Stone --- rest_framework/tests/filterset.py | 75 +++++++++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py index 238da56e..1a71558c 100644 --- a/rest_framework/tests/filterset.py +++ b/rest_framework/tests/filterset.py @@ -1,11 +1,12 @@ from __future__ import unicode_literals import datetime from decimal import Decimal +from django.core.urlresolvers import reverse from django.test import TestCase from django.test.client import RequestFactory from django.utils import unittest from rest_framework import generics, status, filters -from rest_framework.compat import django_filters +from rest_framework.compat import django_filters, patterns, url from rest_framework.tests.models import FilterableItem, BasicModel factory = RequestFactory() @@ -46,12 +47,21 @@ if django_filters: filter_class = MisconfiguredFilter filter_backend = filters.DjangoFilterBackend + class FilterClassDetailView(generics.RetrieveAPIView): + model = FilterableItem + filter_class = SeveralFieldsFilter + filter_backend = filters.DjangoFilterBackend + + urlpatterns = patterns('', + url(r'^(?P\d+)/$', FilterClassDetailView.as_view(), name='detail-view'), + url(r'^$', FilterClassRootView.as_view(), name='root-view'), + ) -class IntegrationTestFiltering(TestCase): - """ - Integration tests for filtered list views. - """ +class CommonFilteringTestCase(TestCase): + def _serialize_object(self, obj): + return {'id': obj.id, 'text': obj.text, 'decimal': obj.decimal, 'date': obj.date} + def setUp(self): """ Create 10 FilterableItem instances. @@ -65,10 +75,16 @@ class IntegrationTestFiltering(TestCase): self.objects = FilterableItem.objects self.data = [ - {'id': obj.id, 'text': obj.text, 'decimal': obj.decimal, 'date': obj.date} + self._serialize_object(obj) for obj in self.objects.all() ] + +class IntegrationTestFiltering(CommonFilteringTestCase): + """ + Integration tests for filtered list views. + """ + @unittest.skipUnless(django_filters, 'django-filters not installed') def test_get_filtered_fields_root_view(self): """ @@ -167,3 +183,50 @@ class IntegrationTestFiltering(TestCase): request = factory.get('/?integer=%s' % search_integer) response = view(request).render() self.assertEqual(response.status_code, status.HTTP_200_OK) + + +class IntegrationTestDetailFiltering(CommonFilteringTestCase): + """ + Integration tests for filtered detail views. + """ + urls = 'rest_framework.tests.filterset' + + def _get_url(self, item): + return reverse('detail-view', kwargs=dict(pk=item.pk)) + + @unittest.skipUnless(django_filters, 'django-filters not installed') + def test_get_filtered_detail_view(self): + """ + GET requests to filtered RetrieveAPIView that have a filter_class set + should return filtered results. + """ + item = self.objects.all()[0] + data = self._serialize_object(item) + + # Basic test with no filter. + response = self.client.get(self._get_url(item)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, data) + + # Tests that the decimal filter set that should fail. + search_decimal = Decimal('4.25') + high_item = self.objects.filter(decimal__gt=search_decimal)[0] + response = self.client.get('{url}?decimal={param}'.format(url=self._get_url(high_item), param=search_decimal)) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # Tests that the decimal filter set that should succeed. + search_decimal = Decimal('4.25') + low_item = self.objects.filter(decimal__lt=search_decimal)[0] + low_item_data = self._serialize_object(low_item) + response = self.client.get('{url}?decimal={param}'.format(url=self._get_url(low_item), param=search_decimal)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, low_item_data) + + # Tests that multiple filters works. + search_decimal = Decimal('5.25') + search_date = datetime.date(2012, 10, 2) + valid_item = self.objects.filter(decimal__lt=search_decimal, date__gt=search_date)[0] + valid_item_data = self._serialize_object(valid_item) + response = self.client.get('{url}?decimal={decimal}&date={date}'.format(url=self._get_url(valid_item), decimal=search_decimal, date=search_date)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, valid_item_data) -- cgit v1.2.3 From 3774ba3ed2af918563eb6ed945cc13aa7fa2345a Mon Sep 17 00:00:00 2001 From: glic3rinu Date: Thu, 28 Mar 2013 12:01:08 +0100 Subject: Added force_text to compat --- rest_framework/compat.py | 31 +++++++++++++++++++++++++++ rest_framework/templatetags/rest_framework.py | 3 ++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 7b2ef738..f0bb9c08 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -395,6 +395,37 @@ except ImportError: kw = dict((k, int(v)) for k, v in kw.iteritems() if v is not None) return datetime.datetime(**kw) + +# smart_urlquote is new on Django 1.4 +try: + from django.utils.html import smart_urlquote +except ImportError: + try: + from urllib.parse import quote, urlsplit, urlunsplit + except ImportError: # Python 2 + from urllib import quote + from urlparse import urlsplit, urlunsplit + + def smart_urlquote(url): + "Quotes a URL if it isn't already quoted." + # Handle IDN before quoting. + scheme, netloc, path, query, fragment = urlsplit(url) + try: + netloc = netloc.encode('idna').decode('ascii') # IDN -> ACE + except UnicodeError: # invalid domain part + pass + else: + url = urlunsplit((scheme, netloc, path, query, fragment)) + + # An URL is considered unquoted if it contains no % characters or + # contains a % not followed by two hexadecimal digits. See #9655. + if '%' not in url or unquoted_percents_re.search(url): + # See http://bugs.python.org/issue2637 + url = quote(force_bytes(url), safe=b'!*\'();:@&=+$,/?#[]~') + + return force_text(url) + + # Markdown is optional try: import markdown diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index 78a3a9a1..33bae241 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -2,11 +2,12 @@ from __future__ import unicode_literals, absolute_import from django import template from django.core.urlresolvers import reverse, NoReverseMatch from django.http import QueryDict -from django.utils.html import escape, smart_urlquote +from django.utils.html import escape from django.utils.safestring import SafeData, mark_safe from rest_framework.compat import urlparse from rest_framework.compat import force_text from rest_framework.compat import six +from rest_framework.compat import smart_urlquote import re import string -- cgit v1.2.3 From 9c32f048b51ec6852236363932f0ab0dcc7473ac Mon Sep 17 00:00:00 2001 From: glic3rinu Date: Thu, 28 Mar 2013 12:01:47 +0100 Subject: Cleaned imports on templatetags/rest_framework module --- rest_framework/templatetags/rest_framework.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index 33bae241..b6ab2de3 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -4,12 +4,8 @@ from django.core.urlresolvers import reverse, NoReverseMatch from django.http import QueryDict from django.utils.html import escape from django.utils.safestring import SafeData, mark_safe -from rest_framework.compat import urlparse -from rest_framework.compat import force_text -from rest_framework.compat import six -from rest_framework.compat import smart_urlquote -import re -import string +from rest_framework.compat import urlparse, force_text, six, smart_urlquote +import re, string register = template.Library() -- cgit v1.2.3 From fb105d138cdcb178ed08c6616c0c25f4a03fb2e1 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 28 Mar 2013 14:54:25 +0000 Subject: Minor tweak --- docs/topics/contributing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/contributing.md b/docs/topics/contributing.md index a13f4461..1d1fe892 100644 --- a/docs/topics/contributing.md +++ b/docs/topics/contributing.md @@ -18,7 +18,7 @@ When answering questions make sure to help future contributors find their way ar # Issues -Usage questions should be directed to the [discussion group][google-group]. Feature requests, bug reports and other issues should be raised on the GitHub [issue tracker][issues]. +It's really helpful if you make sure you address issues to the correct channel. Usage questions should be directed to the [discussion group][google-group]. Feature requests, bug reports and other issues should be raised on the GitHub [issue tracker][issues]. Some tips on good issue reporting: -- cgit v1.2.3 From d243538547982781635e01d9b6e74afbbd628e16 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 28 Mar 2013 14:54:42 +0000 Subject: Note on using curl with token auth --- docs/api-guide/authentication.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 541c6575..757b8673 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -119,6 +119,8 @@ To use the `TokenAuthentication` scheme, include `rest_framework.authtoken` in y ... 'rest_framework.authtoken' ) + +Make sure to run `manage.py syncdb` after changing your settings. You'll also need to create tokens for your users. @@ -140,6 +142,10 @@ Unauthenticated responses that are denied permission will result in an `HTTP 401 WWW-Authenticate: Token +The `curl` command line tool may be useful for testing token authenticated APIs. For example: + + curl -X GET http://127.0.0.1:8000/api/example/ -H 'Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b' + --- **Note:** If you use `TokenAuthentication` in production you must ensure that your API is only available over `https` only. -- cgit v1.2.3 From 69cbafc64f65a23b4ed4c652a8965873a18929a0 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 28 Mar 2013 15:58:53 +0000 Subject: Add search and next/prev --- docs/css/default.css | 11 ++++++++ docs/template.html | 31 ++++++++++++++++++++++ mkdocs.py | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/docs/css/default.css b/docs/css/default.css index c160b63d..173d70e0 100644 --- a/docs/css/default.css +++ b/docs/css/default.css @@ -277,3 +277,14 @@ footer a { footer a:hover { color: gray; } + +.btn-inverse { + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#606060), to(#404040)) !important; + background-image: -webkit-linear-gradient(top, #606060, #404040) !important; +} + +.modal-open .modal,.btn:focus{outline:none;} + +@media (max-width: 650px) { + .repo-link.btn-inverse {display: none;} +} diff --git a/docs/template.html b/docs/template.html index 3e0f29aa..7e929762 100644 --- a/docs/template.html +++ b/docs/template.html @@ -41,6 +41,9 @@