From 3fcc01273c5efef26d911e50c02a4a43f89b34eb Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 27 Jun 2013 20:29:52 +0100 Subject: [PATCH 01/72] Remove deprecated code --- rest_framework/compat.py | 5 +- rest_framework/fields.py | 7 --- rest_framework/permissions.py | 12 +---- rest_framework/relations.py | 69 ++++--------------------- rest_framework/serializers.py | 32 ++---------- rest_framework/tests/test_serializer.py | 4 +- 6 files changed, 21 insertions(+), 108 deletions(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index b748dcc51..161fffa8d 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -494,11 +494,14 @@ try: if provider_version in ('0.2.3', '0.2.4'): # 0.2.3 and 0.2.4 are supported version that do not support # timezone aware datetimes - from datetime.datetime import now as provider_now + import datetime + provider_now = datetime.datetime.now else: # Any other supported version does use timezone aware datetimes from django.utils.timezone import now as provider_now except ImportError: + import traceback + traceback.print_exc() oauth2_provider = None oauth2_provider_models = None oauth2_provider_forms = None diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 35848b4ce..2e23715de 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -224,13 +224,6 @@ class WritableField(Field): validators=[], error_messages=None, widget=None, default=None, blank=None): - # 'blank' is to be deprecated in favor of 'required' - if blank is not None: - warnings.warn('The `blank` keyword argument is deprecated. ' - 'Use the `required` keyword argument instead.', - DeprecationWarning, stacklevel=2) - required = not(blank) - super(WritableField, self).__init__(source=source, label=label, help_text=help_text) self.read_only = read_only diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index 1036663e0..0c7b02ffa 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -2,13 +2,10 @@ Provides a set of pluggable permission policies. """ from __future__ import unicode_literals -import inspect -import warnings +from rest_framework.compat import oauth2_provider_scope, oauth2_constants SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] -from rest_framework.compat import oauth2_provider_scope, oauth2_constants - class BasePermission(object): """ @@ -25,13 +22,6 @@ class BasePermission(object): """ Return `True` if permission is granted, `False` otherwise. """ - if len(inspect.getargspec(self.has_permission).args) == 4: - warnings.warn( - 'The `obj` argument in `has_permission` is deprecated. ' - 'Use `has_object_permission()` instead for object permissions.', - DeprecationWarning, stacklevel=2 - ) - return self.has_permission(request, view, obj) return True diff --git a/rest_framework/relations.py b/rest_framework/relations.py index edaf76d6e..ede694e31 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -40,14 +40,6 @@ class RelatedField(WritableField): many = False def __init__(self, *args, **kwargs): - - # 'null' is to be deprecated in favor of 'required' - if 'null' in kwargs: - warnings.warn('The `null` keyword argument is deprecated. ' - 'Use the `required` keyword argument instead.', - DeprecationWarning, stacklevel=2) - kwargs['required'] = not kwargs.pop('null') - queryset = kwargs.pop('queryset', None) self.many = kwargs.pop('many', self.many) if self.many: @@ -424,14 +416,11 @@ class HyperlinkedRelatedField(RelatedField): request = self.context.get('request', None) format = self.format or self.context.get('format', None) - if request is None: - msg = ( - "Using `HyperlinkedRelatedField` without including the request " - "in the serializer context is deprecated. " - "Add `context={'request': request}` when instantiating " - "the serializer." - ) - warnings.warn(msg, DeprecationWarning, stacklevel=4) + assert request is not None, ( + "`HyperlinkedRelatedField` requires the request in the serializer " + "context. Add `context={'request': request}` when instantiating " + "the serializer." + ) # If the object has not yet been saved then we cannot hyperlink to it. if getattr(obj, 'pk', None) is None: @@ -530,11 +519,11 @@ class HyperlinkedIdentityField(Field): format = self.context.get('format', None) view_name = self.view_name - if request is None: - warnings.warn("Using `HyperlinkedIdentityField` without including the " - "request in the serializer context is deprecated. " - "Add `context={'request': request}` when instantiating the serializer.", - DeprecationWarning, stacklevel=4) + assert request is not None, ( + "`HyperlinkedIdentityField` requires the request in the serializer" + " context. Add `context={'request': request}` when instantiating " + "the serializer." + ) # By default use whatever format is given for the current context # unless the target is a different type to the source. @@ -593,41 +582,3 @@ class HyperlinkedIdentityField(Field): pass raise NoReverseMatch() - - -### Old-style many classes for backwards compat - -class ManyRelatedField(RelatedField): - def __init__(self, *args, **kwargs): - warnings.warn('`ManyRelatedField()` is deprecated. ' - 'Use `RelatedField(many=True)` instead.', - DeprecationWarning, stacklevel=2) - kwargs['many'] = True - super(ManyRelatedField, self).__init__(*args, **kwargs) - - -class ManyPrimaryKeyRelatedField(PrimaryKeyRelatedField): - def __init__(self, *args, **kwargs): - warnings.warn('`ManyPrimaryKeyRelatedField()` is deprecated. ' - 'Use `PrimaryKeyRelatedField(many=True)` instead.', - DeprecationWarning, stacklevel=2) - kwargs['many'] = True - super(ManyPrimaryKeyRelatedField, self).__init__(*args, **kwargs) - - -class ManySlugRelatedField(SlugRelatedField): - def __init__(self, *args, **kwargs): - warnings.warn('`ManySlugRelatedField()` is deprecated. ' - 'Use `SlugRelatedField(many=True)` instead.', - DeprecationWarning, stacklevel=2) - kwargs['many'] = True - super(ManySlugRelatedField, self).__init__(*args, **kwargs) - - -class ManyHyperlinkedRelatedField(HyperlinkedRelatedField): - def __init__(self, *args, **kwargs): - warnings.warn('`ManyHyperlinkedRelatedField()` is deprecated. ' - 'Use `HyperlinkedRelatedField(many=True)` instead.', - DeprecationWarning, stacklevel=2) - kwargs['many'] = True - super(ManyHyperlinkedRelatedField, self).__init__(*args, **kwargs) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 023f7ccfb..ae39cce88 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -15,7 +15,6 @@ import copy import datetime import types from decimal import Decimal -from django.core.paginator import Page from django.db import models from django.forms import widgets from django.utils.datastructures import SortedDict @@ -141,7 +140,7 @@ class BaseSerializer(WritableField): _dict_class = SortedDictWithMetadata def __init__(self, instance=None, data=None, files=None, - context=None, partial=False, many=None, + context=None, partial=False, many=False, allow_add_remove=False, **kwargs): super(BaseSerializer, self).__init__(**kwargs) self.opts = self._options_class(self.Meta) @@ -348,12 +347,7 @@ class BaseSerializer(WritableField): if value is None: return None - if self.many is not None: - many = self.many - else: - many = hasattr(value, '__iter__') and not isinstance(value, (Page, dict, six.text_type)) - - if many: + if self.many: return [self.to_native(item) for item in value] return self.to_native(value) @@ -424,16 +418,7 @@ class BaseSerializer(WritableField): if self._errors is None: data, files = self.init_data, self.init_files - if self.many is not None: - many = self.many - else: - many = hasattr(data, '__iter__') and not isinstance(data, (Page, dict, six.text_type)) - if many: - warnings.warn('Implict list/queryset serialization is deprecated. ' - 'Use the `many=True` flag when instantiating the serializer.', - DeprecationWarning, stacklevel=3) - - if many: + if self.many: ret = [] errors = [] update = self.object is not None @@ -486,16 +471,7 @@ class BaseSerializer(WritableField): if self._data is None: obj = self.object - if self.many is not None: - many = self.many - else: - many = hasattr(obj, '__iter__') and not isinstance(obj, (Page, dict)) - if many: - warnings.warn('Implict list/queryset serialization is deprecated. ' - 'Use the `many=True` flag when instantiating the serializer.', - DeprecationWarning, stacklevel=2) - - if many: + if self.many: self._data = [self.to_native(item) for item in obj] else: self._data = self.to_native(obj) diff --git a/rest_framework/tests/test_serializer.py b/rest_framework/tests/test_serializer.py index 8b87a0847..151eb6484 100644 --- a/rest_framework/tests/test_serializer.py +++ b/rest_framework/tests/test_serializer.py @@ -1268,7 +1268,7 @@ class NestedSerializerContextTests(TestCase): model = Album fields = ("photo_set", "callable") - photo_set = PhotoSerializer(source="photo_set") + photo_set = PhotoSerializer(source="photo_set", many=True) callable = serializers.SerializerMethodField("_callable") def _callable(self, instance): @@ -1280,7 +1280,7 @@ class NestedSerializerContextTests(TestCase): albums = None class AlbumCollectionSerializer(serializers.Serializer): - albums = AlbumSerializer(source="albums") + albums = AlbumSerializer(source="albums", many=True) album1 = Album.objects.create(title="album 1") album2 = Album.objects.create(title="album 2") From 379ad8a82485e61b180ee823ba49799d39446aeb Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 27 Jun 2013 20:36:14 +0100 Subject: [PATCH 02/72] pending deprecations -> deprecated --- rest_framework/generics.py | 32 +++++++++++++++---------------- rest_framework/mixins.py | 8 ++++---- rest_framework/relations.py | 36 +++++++++++++++++------------------ rest_framework/serializers.py | 8 ++++---- 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 99e9782e2..874a142c8 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -108,11 +108,11 @@ class GenericAPIView(views.APIView): deprecated_style = False if page_size is not None: warnings.warn('The `page_size` parameter to `paginate_queryset()` ' - 'is due to be deprecated. ' + 'is deprecated. ' 'Note that the return style of this method is also ' 'changed, and will simply return a page object ' 'when called without a `page_size` argument.', - PendingDeprecationWarning, stacklevel=2) + DeprecationWarning, stacklevel=2) deprecated_style = True else: # Determine the required page size. @@ -123,10 +123,10 @@ class GenericAPIView(views.APIView): if not self.allow_empty: warnings.warn( - 'The `allow_empty` parameter is due to be deprecated. ' + 'The `allow_empty` parameter is deprecated. ' 'To use `allow_empty=False` style behavior, You should override ' '`get_queryset()` and explicitly raise a 404 on empty querysets.', - PendingDeprecationWarning, stacklevel=2 + DeprecationWarning, stacklevel=2 ) paginator = self.paginator_class(queryset, page_size, @@ -166,10 +166,10 @@ class GenericAPIView(views.APIView): if not filter_backends and self.filter_backend: warnings.warn( 'The `filter_backend` attribute and `FILTER_BACKEND` setting ' - 'are due to be deprecated in favor of a `filter_backends` ' + 'are deprecated in favor of a `filter_backends` ' 'attribute and `DEFAULT_FILTER_BACKENDS` setting, that take ' 'a *list* of filter backend classes.', - PendingDeprecationWarning, stacklevel=2 + DeprecationWarning, stacklevel=2 ) filter_backends = [self.filter_backend] @@ -192,8 +192,8 @@ class GenericAPIView(views.APIView): """ if queryset is not None: warnings.warn('The `queryset` parameter to `get_paginate_by()` ' - 'is due to be deprecated.', - PendingDeprecationWarning, stacklevel=2) + 'is deprecated.', + DeprecationWarning, stacklevel=2) if self.paginate_by_param: query_params = self.request.QUERY_PARAMS @@ -272,16 +272,16 @@ class GenericAPIView(views.APIView): filter_kwargs = {self.lookup_field: lookup} elif pk is not None and self.lookup_field == 'pk': warnings.warn( - 'The `pk_url_kwarg` attribute is due to be deprecated. ' + 'The `pk_url_kwarg` attribute is deprecated. ' 'Use the `lookup_field` attribute instead', - PendingDeprecationWarning + DeprecationWarning ) filter_kwargs = {'pk': pk} elif slug is not None and self.lookup_field == 'pk': warnings.warn( - 'The `slug_url_kwarg` attribute is due to be deprecated. ' + 'The `slug_url_kwarg` attribute is deprecated. ' 'Use the `lookup_field` attribute instead', - PendingDeprecationWarning + DeprecationWarning ) filter_kwargs = {self.slug_field: slug} else: @@ -482,9 +482,9 @@ class RetrieveUpdateDestroyAPIView(mixins.RetrieveModelMixin, class MultipleObjectAPIView(GenericAPIView): def __init__(self, *args, **kwargs): warnings.warn( - 'Subclassing `MultipleObjectAPIView` is due to be deprecated. ' + 'Subclassing `MultipleObjectAPIView` is deprecated. ' 'You should simply subclass `GenericAPIView` instead.', - PendingDeprecationWarning, stacklevel=2 + DeprecationWarning, stacklevel=2 ) super(MultipleObjectAPIView, self).__init__(*args, **kwargs) @@ -492,8 +492,8 @@ class MultipleObjectAPIView(GenericAPIView): class SingleObjectAPIView(GenericAPIView): def __init__(self, *args, **kwargs): warnings.warn( - 'Subclassing `SingleObjectAPIView` is due to be deprecated. ' + 'Subclassing `SingleObjectAPIView` is deprecated. ' 'You should simply subclass `GenericAPIView` instead.', - PendingDeprecationWarning, stacklevel=2 + DeprecationWarning, stacklevel=2 ) super(SingleObjectAPIView, self).__init__(*args, **kwargs) diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py index f11def6d4..679dfa6c3 100644 --- a/rest_framework/mixins.py +++ b/rest_framework/mixins.py @@ -24,14 +24,14 @@ def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None) include = [] if pk: - # Pending deprecation + # Deprecated pk_field = obj._meta.pk while pk_field.rel: pk_field = pk_field.rel.to._meta.pk include.append(pk_field.name) if slug_field: - # Pending deprecation + # Deprecated include.append(slug_field) if lookup_field and lookup_field != 'pk': @@ -77,10 +77,10 @@ class ListModelMixin(object): # `.allow_empty = False`, to raise 404 errors on empty querysets. if not self.allow_empty and not self.object_list: warnings.warn( - 'The `allow_empty` parameter is due to be deprecated. ' + 'The `allow_empty` parameter is deprecated. ' 'To use `allow_empty=False` style behavior, You should override ' '`get_queryset()` and explicitly raise a 404 on empty querysets.', - PendingDeprecationWarning + DeprecationWarning ) class_name = self.__class__.__name__ error_msg = self.empty_error % {'class_name': class_name} diff --git a/rest_framework/relations.py b/rest_framework/relations.py index ede694e31..f1f7dea72 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -314,7 +314,7 @@ class HyperlinkedRelatedField(RelatedField): 'incorrect_type': _('Incorrect type. Expected url string, received %s.'), } - # These are all pending deprecation + # These are all deprecated pk_url_kwarg = 'pk' slug_field = 'slug' slug_url_kwarg = None # Defaults to same as `slug_field` unless overridden @@ -328,16 +328,16 @@ class HyperlinkedRelatedField(RelatedField): self.lookup_field = kwargs.pop('lookup_field', self.lookup_field) self.format = kwargs.pop('format', None) - # These are pending deprecation + # These are deprecated if 'pk_url_kwarg' in kwargs: - msg = 'pk_url_kwarg is pending deprecation. Use lookup_field instead.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + msg = 'pk_url_kwarg is deprecated. Use lookup_field instead.' + warnings.warn(msg, DeprecationWarning, stacklevel=2) if 'slug_url_kwarg' in kwargs: - msg = 'slug_url_kwarg is pending deprecation. Use lookup_field instead.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + msg = 'slug_url_kwarg is deprecated. Use lookup_field instead.' + warnings.warn(msg, DeprecationWarning, stacklevel=2) if 'slug_field' in kwargs: - msg = 'slug_field is pending deprecation. Use lookup_field instead.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + msg = 'slug_field is deprecated. Use lookup_field instead.' + warnings.warn(msg, DeprecationWarning, stacklevel=2) self.pk_url_kwarg = kwargs.pop('pk_url_kwarg', self.pk_url_kwarg) self.slug_field = kwargs.pop('slug_field', self.slug_field) @@ -380,9 +380,9 @@ class HyperlinkedRelatedField(RelatedField): # If the lookup succeeds using the default slug params, # then `slug_field` is being used implicitly, and we # we need to warn about the pending deprecation. - msg = 'Implicit slug field hyperlinked fields are pending deprecation.' \ + msg = 'Implicit slug field hyperlinked fields are deprecated.' \ 'You should set `lookup_field=slug` on the HyperlinkedRelatedField.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + warnings.warn(msg, DeprecationWarning, stacklevel=2) return ret except NoReverseMatch: pass @@ -480,7 +480,7 @@ class HyperlinkedIdentityField(Field): lookup_field = 'pk' read_only = True - # These are all pending deprecation + # These are all deprecated pk_url_kwarg = 'pk' slug_field = 'slug' slug_url_kwarg = None # Defaults to same as `slug_field` unless overridden @@ -496,16 +496,16 @@ class HyperlinkedIdentityField(Field): lookup_field = kwargs.pop('lookup_field', None) self.lookup_field = lookup_field or self.lookup_field - # These are pending deprecation + # These are deprecated if 'pk_url_kwarg' in kwargs: - msg = 'pk_url_kwarg is pending deprecation. Use lookup_field instead.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + msg = 'pk_url_kwarg is deprecated. Use lookup_field instead.' + warnings.warn(msg, DeprecationWarning, stacklevel=2) if 'slug_url_kwarg' in kwargs: - msg = 'slug_url_kwarg is pending deprecation. Use lookup_field instead.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + msg = 'slug_url_kwarg is deprecated. Use lookup_field instead.' + warnings.warn(msg, DeprecationWarning, stacklevel=2) if 'slug_field' in kwargs: - msg = 'slug_field is pending deprecation. Use lookup_field instead.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + msg = 'slug_field is deprecated. Use lookup_field instead.' + warnings.warn(msg, DeprecationWarning, stacklevel=2) self.slug_field = kwargs.pop('slug_field', self.slug_field) default_slug_kwarg = self.slug_url_kwarg or self.slug_field diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index ae39cce88..dd9e14ad7 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -593,10 +593,10 @@ class ModelSerializer(Serializer): if len(inspect.getargspec(self.get_nested_field).args) == 2: warnings.warn( 'The `get_nested_field(model_field)` call signature ' - 'is due to be deprecated. ' + 'is deprecated. ' 'Use `get_nested_field(model_field, related_model, ' 'to_many) instead', - PendingDeprecationWarning + DeprecationWarning ) field = self.get_nested_field(model_field) else: @@ -605,10 +605,10 @@ class ModelSerializer(Serializer): if len(inspect.getargspec(self.get_nested_field).args) == 3: warnings.warn( 'The `get_related_field(model_field, to_many)` call ' - 'signature is due to be deprecated. ' + 'signature is deprecated. ' 'Use `get_related_field(model_field, related_model, ' 'to_many) instead', - PendingDeprecationWarning + DeprecationWarning ) field = self.get_related_field(model_field, to_many=to_many) else: From d72603bc6a16112008959c5267839f819c2bc43a Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Wed, 5 Jun 2013 17:39:14 -0400 Subject: [PATCH 03/72] Add support for collection routes to SimpleRouter --- rest_framework/decorators.py | 26 +++++++++++++++ rest_framework/routers.py | 33 +++++++++++++++++-- rest_framework/tests/test_routers.py | 48 +++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index c69756a43..dacd380fb 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -113,6 +113,7 @@ def link(**kwargs): """ def decorator(func): func.bind_to_methods = ['get'] + func.collection = False func.kwargs = kwargs return func return decorator @@ -124,6 +125,31 @@ def action(methods=['post'], **kwargs): """ def decorator(func): func.bind_to_methods = methods + func.collection = False + func.kwargs = kwargs + return func + return decorator + + +def collection_link(**kwargs): + """ + Used to mark a method on a ViewSet that should be routed for GET requests. + """ + def decorator(func): + func.bind_to_methods = ['get'] + func.collection = True + func.kwargs = kwargs + return func + return decorator + + +def collection_action(methods=['post'], **kwargs): + """ + Used to mark a method on a ViewSet that should be routed for POST requests. + """ + def decorator(func): + func.bind_to_methods = methods + func.collection = True func.kwargs = kwargs return func return decorator diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 930011d39..9b859a7c7 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -88,6 +88,17 @@ class SimpleRouter(BaseRouter): name='{basename}-list', initkwargs={'suffix': 'List'} ), + # Dynamically generated collection routes. + # Generated using @collection_action or @collection_link decorators + # on methods of the viewset. + Route( + url=r'^{prefix}/{methodname}{trailing_slash}$', + mapping={ + '{httpmethod}': '{methodname}', + }, + name='{basename}-collection-{methodnamehyphen}', + initkwargs={} + ), # Detail route. Route( url=r'^{prefix}/{lookup}{trailing_slash}$', @@ -107,7 +118,7 @@ class SimpleRouter(BaseRouter): mapping={ '{httpmethod}': '{methodname}', }, - name='{basename}-{methodnamehyphen}', + name='{basename}-dynamic-{methodnamehyphen}', initkwargs={} ), ] @@ -142,20 +153,25 @@ class SimpleRouter(BaseRouter): known_actions = flatten([route.mapping.values() for route in self.routes]) # Determine any `@action` or `@link` decorated methods on the viewset + collection_routes = [] dynamic_routes = [] for methodname in dir(viewset): attr = getattr(viewset, methodname) httpmethods = getattr(attr, 'bind_to_methods', None) + collection = getattr(attr, 'collection', False) if httpmethods: if methodname in known_actions: raise ImproperlyConfigured('Cannot use @action or @link decorator on ' 'method "%s" as it is an existing route' % methodname) httpmethods = [method.lower() for method in httpmethods] - dynamic_routes.append((httpmethods, methodname)) + if collection: + collection_routes.append((httpmethods, methodname)) + else: + dynamic_routes.append((httpmethods, methodname)) ret = [] for route in self.routes: - if route.mapping == {'{httpmethod}': '{methodname}'}: + if route.name == '{basename}-dynamic-{methodnamehyphen}': # Dynamic routes (@link or @action decorator) for httpmethods, methodname in dynamic_routes: initkwargs = route.initkwargs.copy() @@ -166,6 +182,17 @@ class SimpleRouter(BaseRouter): name=replace_methodname(route.name, methodname), initkwargs=initkwargs, )) + elif route.name == '{basename}-collection-{methodnamehyphen}': + # Dynamic routes (@collection_link or @collection_action decorator) + for httpmethods, methodname in collection_routes: + initkwargs = route.initkwargs.copy() + initkwargs.update(getattr(viewset, methodname).kwargs) + ret.append(Route( + url=replace_methodname(route.url, methodname), + mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), + name=replace_methodname(route.name, methodname), + initkwargs=initkwargs, + )) else: # Standard route ret.append(route) diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index 5fcccb741..60f150d2c 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -4,7 +4,7 @@ from django.test import TestCase from django.core.exceptions import ImproperlyConfigured from rest_framework import serializers, viewsets, permissions from rest_framework.compat import include, patterns, url -from rest_framework.decorators import link, action +from rest_framework.decorators import link, action, collection_link, collection_action from rest_framework.response import Response from rest_framework.routers import SimpleRouter, DefaultRouter from rest_framework.test import APIRequestFactory @@ -214,3 +214,49 @@ class TestActionAppliedToExistingRoute(TestCase): with self.assertRaises(ImproperlyConfigured): self.router.urls + + +class StaticAndDynamicViewSet(viewsets.ViewSet): + def list(self, request, *args, **kwargs): + return Response({'method': 'list'}) + + @collection_action() + def collection_action(self, request, *args, **kwargs): + return Response({'method': 'action1'}) + + @action() + def dynamic_action(self, request, *args, **kwargs): + return Response({'method': 'action2'}) + + @collection_link() + def collection_link(self, request, *args, **kwargs): + return Response({'method': 'link1'}) + + @link() + def dynamic_link(self, request, *args, **kwargs): + return Response({'method': 'link2'}) + + +class TestStaticAndDynamicRouter(TestCase): + def setUp(self): + self.router = SimpleRouter() + + def test_link_and_action_decorator(self): + routes = self.router.get_routes(StaticAndDynamicViewSet) + decorator_routes = [r for r in routes if not (r.name.endswith('-list') or r.name.endswith('-detail'))] + # Make sure all these endpoints exist and none have been clobbered + for i, endpoint in enumerate(['collection_action', 'collection_link', 'dynamic_action', 'dynamic_link']): + route = decorator_routes[i] + # check url listing + if endpoint.startswith('collection_'): + self.assertEqual(route.url, + '^{{prefix}}/{0}{{trailing_slash}}$'.format(endpoint)) + else: + self.assertEqual(route.url, + '^{{prefix}}/{{lookup}}/{0}{{trailing_slash}}$'.format(endpoint)) + # check method to function mapping + if endpoint.endswith('action'): + method_map = 'post' + else: + method_map = 'get' + self.assertEqual(route.mapping[method_map], endpoint) From 5b11e23f6fb35834057fba35832a597ce443cc77 Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Wed, 5 Jun 2013 17:41:29 -0400 Subject: [PATCH 04/72] Add docs for collection routes --- docs/api-guide/viewsets.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index 47e59e2b2..9fa6615ba 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -92,7 +92,9 @@ The default routers included with REST framework will provide routes for a stand def destroy(self, request, pk=None): pass -If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link` or `@action` decorators. The `@link` decorator will route `GET` requests, and the `@action` decorator will route `POST` requests. +If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@collection_link`, `@collection_action`, `@link`, or `@action` decorators. The `@collection_link` and `@link` decorator will route `GET` requests, and the `@collection_action` and `@action` decorator will route `POST` requests. + +The `@link` and `@action` decorators contain `pk` in their URL pattern and are intended for methods which require a single instance. The `@collection_link` and `@collection_action` decorators are intended for methods which operate on a collection of objects. For example: @@ -121,13 +123,20 @@ For example: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -The `@action` and `@link` decorators can additionally take extra arguments that will be set for the routed view only. For example... + @collection_link() + def recent_users(self, request): + recent_users = User.objects.all().order('-last_login') + page = self.paginate_queryset(recent_users) + serializer = self.get_pagination_serializer(page) + return Response(serializer.data) + +The decorators can additionally take extra arguments that will be set for the routed view only. For example... @action(permission_classes=[IsAdminOrIsSelf]) def set_password(self, request, pk=None): ... -The `@action` decorator will route `POST` requests by default, but may also accept other HTTP methods, by using the `method` argument. For example: +The `@collection_action` and `@action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `method` argument. For example: @action(methods=['POST', 'DELETE']) def unset_password(self, request, pk=None): From 57cf8b5fa4f62f9b58912f10536a7ae5076ce54c Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Thu, 6 Jun 2013 11:51:52 -0400 Subject: [PATCH 05/72] Rework extra routes doc for better readability --- docs/api-guide/viewsets.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index 9fa6615ba..e83487fb0 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -92,7 +92,7 @@ The default routers included with REST framework will provide routes for a stand def destroy(self, request, pk=None): pass -If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@collection_link`, `@collection_action`, `@link`, or `@action` decorators. The `@collection_link` and `@link` decorator will route `GET` requests, and the `@collection_action` and `@action` decorator will route `POST` requests. +If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link`, `@action`, `@collection_link`, or `@collection_action` decorators. The `@link` and `@collection_link` decorators will route `GET` requests, and the `@action` and `@collection_action` decorators will route `POST` requests. The `@link` and `@action` decorators contain `pk` in their URL pattern and are intended for methods which require a single instance. The `@collection_link` and `@collection_action` decorators are intended for methods which operate on a collection of objects. @@ -136,7 +136,7 @@ The decorators can additionally take extra arguments that will be set for the ro def set_password(self, request, pk=None): ... -The `@collection_action` and `@action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `method` argument. For example: +The `@action` and `@collection_action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `methods` argument. For example: @action(methods=['POST', 'DELETE']) def unset_password(self, request, pk=None): From 8d521c068a254cef604df1f15690275dca986778 Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Sun, 16 Jun 2013 12:43:59 -0400 Subject: [PATCH 06/72] Revert route name change and add key to Route object to identify different route types --- rest_framework/routers.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 9b859a7c7..541df4a9d 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -25,7 +25,7 @@ from rest_framework.reverse import reverse from rest_framework.urlpatterns import format_suffix_patterns -Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs']) +Route = namedtuple('Route', ['key', 'url', 'mapping', 'name', 'initkwargs']) def replace_methodname(format_string, methodname): @@ -80,6 +80,7 @@ class SimpleRouter(BaseRouter): routes = [ # List route. Route( + key='list', url=r'^{prefix}{trailing_slash}$', mapping={ 'get': 'list', @@ -92,15 +93,17 @@ class SimpleRouter(BaseRouter): # Generated using @collection_action or @collection_link decorators # on methods of the viewset. Route( + key='collection', url=r'^{prefix}/{methodname}{trailing_slash}$', mapping={ '{httpmethod}': '{methodname}', }, - name='{basename}-collection-{methodnamehyphen}', + name='{basename}-{methodnamehyphen}', initkwargs={} ), # Detail route. Route( + key='detail', url=r'^{prefix}/{lookup}{trailing_slash}$', mapping={ 'get': 'retrieve', @@ -114,11 +117,12 @@ class SimpleRouter(BaseRouter): # Dynamically generated routes. # Generated using @action or @link decorators on methods of the viewset. Route( + key='dynamic', url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', mapping={ '{httpmethod}': '{methodname}', }, - name='{basename}-dynamic-{methodnamehyphen}', + name='{basename}-{methodnamehyphen}', initkwargs={} ), ] @@ -171,23 +175,25 @@ class SimpleRouter(BaseRouter): ret = [] for route in self.routes: - if route.name == '{basename}-dynamic-{methodnamehyphen}': + if route.key == 'dynamic': # Dynamic routes (@link or @action decorator) for httpmethods, methodname in dynamic_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( + key=route.key, url=replace_methodname(route.url, methodname), mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), name=replace_methodname(route.name, methodname), initkwargs=initkwargs, )) - elif route.name == '{basename}-collection-{methodnamehyphen}': + elif route.key == 'collection': # Dynamic routes (@collection_link or @collection_action decorator) for httpmethods, methodname in collection_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( + key=route.key, url=replace_methodname(route.url, methodname), mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), name=replace_methodname(route.name, methodname), From f02274307826ebf98998e502fecca171bb0de696 Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Sun, 16 Jun 2013 12:51:33 -0400 Subject: [PATCH 07/72] Rename router collection test case --- rest_framework/tests/test_routers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index 60f150d2c..e0a7e292e 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -216,7 +216,7 @@ class TestActionAppliedToExistingRoute(TestCase): self.router.urls -class StaticAndDynamicViewSet(viewsets.ViewSet): +class CollectionAndDynamicViewSet(viewsets.ViewSet): def list(self, request, *args, **kwargs): return Response({'method': 'list'}) @@ -237,12 +237,12 @@ class StaticAndDynamicViewSet(viewsets.ViewSet): return Response({'method': 'link2'}) -class TestStaticAndDynamicRouter(TestCase): +class TestCollectionAndDynamicRouter(TestCase): def setUp(self): self.router = SimpleRouter() def test_link_and_action_decorator(self): - routes = self.router.get_routes(StaticAndDynamicViewSet) + routes = self.router.get_routes(CollectionAndDynamicViewSet) decorator_routes = [r for r in routes if not (r.name.endswith('-list') or r.name.endswith('-detail'))] # Make sure all these endpoints exist and none have been clobbered for i, endpoint in enumerate(['collection_action', 'collection_link', 'dynamic_action', 'dynamic_link']): From e14cbaf6961ad9c94deaf0417d8e8ce5ec96d0ac Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Sat, 13 Jul 2013 11:11:53 -0400 Subject: [PATCH 08/72] Changed collection_* decorators to list_* --- docs/api-guide/viewsets.md | 10 ++++----- rest_framework/decorators.py | 16 +++++++------- rest_framework/routers.py | 31 ++++++++++++++-------------- rest_framework/tests/test_routers.py | 24 ++++++++++----------- 4 files changed, 41 insertions(+), 40 deletions(-) diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index e83487fb0..6d6bb1334 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -92,15 +92,15 @@ The default routers included with REST framework will provide routes for a stand def destroy(self, request, pk=None): pass -If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link`, `@action`, `@collection_link`, or `@collection_action` decorators. The `@link` and `@collection_link` decorators will route `GET` requests, and the `@action` and `@collection_action` decorators will route `POST` requests. +If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link`, `@action`, `@list_link`, or `@list_action` decorators. The `@link` and `@list_link` decorators will route `GET` requests, and the `@action` and `@list_action` decorators will route `POST` requests. -The `@link` and `@action` decorators contain `pk` in their URL pattern and are intended for methods which require a single instance. The `@collection_link` and `@collection_action` decorators are intended for methods which operate on a collection of objects. +The `@link` and `@action` decorators contain `pk` in their URL pattern and are intended for methods which require a single instance. The `@list_link` and `@list_action` decorators are intended for methods which operate on a list of objects. For example: from django.contrib.auth.models import User from rest_framework import viewsets - from rest_framework.decorators import action + from rest_framework.decorators import action, list_link from rest_framework.response import Response from myapp.serializers import UserSerializer, PasswordSerializer @@ -123,7 +123,7 @@ For example: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @collection_link() + @list_link() def recent_users(self, request): recent_users = User.objects.all().order('-last_login') page = self.paginate_queryset(recent_users) @@ -136,7 +136,7 @@ The decorators can additionally take extra arguments that will be set for the ro def set_password(self, request, pk=None): ... -The `@action` and `@collection_action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `methods` argument. For example: +The `@action` and `@list_action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `methods` argument. For example: @action(methods=['POST', 'DELETE']) def unset_password(self, request, pk=None): diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index dacd380fb..92f551db7 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -109,11 +109,11 @@ def permission_classes(permission_classes): def link(**kwargs): """ - Used to mark a method on a ViewSet that should be routed for GET requests. + Used to mark a method on a ViewSet that should be routed for detail GET requests. """ def decorator(func): func.bind_to_methods = ['get'] - func.collection = False + func.detail = True func.kwargs = kwargs return func return decorator @@ -121,35 +121,35 @@ def link(**kwargs): def action(methods=['post'], **kwargs): """ - Used to mark a method on a ViewSet that should be routed for POST requests. + Used to mark a method on a ViewSet that should be routed for detail POST requests. """ def decorator(func): func.bind_to_methods = methods - func.collection = False + func.detail = True func.kwargs = kwargs return func return decorator -def collection_link(**kwargs): +def list_link(**kwargs): """ Used to mark a method on a ViewSet that should be routed for GET requests. """ def decorator(func): func.bind_to_methods = ['get'] - func.collection = True + func.detail = False func.kwargs = kwargs return func return decorator -def collection_action(methods=['post'], **kwargs): +def list_action(methods=['post'], **kwargs): """ Used to mark a method on a ViewSet that should be routed for POST requests. """ def decorator(func): func.bind_to_methods = methods - func.collection = True + func.detail = False func.kwargs = kwargs return func return decorator diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 541df4a9d..c8f711e91 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -89,8 +89,8 @@ class SimpleRouter(BaseRouter): name='{basename}-list', initkwargs={'suffix': 'List'} ), - # Dynamically generated collection routes. - # Generated using @collection_action or @collection_link decorators + # Dynamically generated list routes. + # Generated using @list_action or @list_link decorators # on methods of the viewset. Route( key='collection', @@ -114,7 +114,7 @@ class SimpleRouter(BaseRouter): name='{basename}-detail', initkwargs={'suffix': 'Instance'} ), - # Dynamically generated routes. + # Dynamically generated detail routes. # Generated using @action or @link decorators on methods of the viewset. Route( key='dynamic', @@ -157,27 +157,28 @@ class SimpleRouter(BaseRouter): known_actions = flatten([route.mapping.values() for route in self.routes]) # Determine any `@action` or `@link` decorated methods on the viewset - collection_routes = [] - dynamic_routes = [] + detail_routes = [] + list_routes = [] for methodname in dir(viewset): attr = getattr(viewset, methodname) httpmethods = getattr(attr, 'bind_to_methods', None) - collection = getattr(attr, 'collection', False) + detail = getattr(attr, 'detail', True) if httpmethods: if methodname in known_actions: - raise ImproperlyConfigured('Cannot use @action or @link decorator on ' - 'method "%s" as it is an existing route' % methodname) + raise ImproperlyConfigured('Cannot use @action, @link, @list_action ' + 'or @list_link decorator on method "%s" ' + 'as it is an existing route' % methodname) httpmethods = [method.lower() for method in httpmethods] - if collection: - collection_routes.append((httpmethods, methodname)) + if detail: + detail_routes.append((httpmethods, methodname)) else: - dynamic_routes.append((httpmethods, methodname)) + list_routes.append((httpmethods, methodname)) ret = [] for route in self.routes: if route.key == 'dynamic': - # Dynamic routes (@link or @action decorator) - for httpmethods, methodname in dynamic_routes: + # Dynamic detail routes (@link or @action decorator) + for httpmethods, methodname in detail_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( @@ -188,8 +189,8 @@ class SimpleRouter(BaseRouter): initkwargs=initkwargs, )) elif route.key == 'collection': - # Dynamic routes (@collection_link or @collection_action decorator) - for httpmethods, methodname in collection_routes: + # Dynamic list routes (@list_link or @list_action decorator) + for httpmethods, methodname in list_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index e0a7e292e..393101763 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -4,7 +4,7 @@ from django.test import TestCase from django.core.exceptions import ImproperlyConfigured from rest_framework import serializers, viewsets, permissions from rest_framework.compat import include, patterns, url -from rest_framework.decorators import link, action, collection_link, collection_action +from rest_framework.decorators import link, action, list_link, list_action from rest_framework.response import Response from rest_framework.routers import SimpleRouter, DefaultRouter from rest_framework.test import APIRequestFactory @@ -216,39 +216,39 @@ class TestActionAppliedToExistingRoute(TestCase): self.router.urls -class CollectionAndDynamicViewSet(viewsets.ViewSet): +class DynamicListAndDetailViewSet(viewsets.ViewSet): def list(self, request, *args, **kwargs): return Response({'method': 'list'}) - @collection_action() - def collection_action(self, request, *args, **kwargs): + @list_action() + def list_action(self, request, *args, **kwargs): return Response({'method': 'action1'}) @action() - def dynamic_action(self, request, *args, **kwargs): + def detail_action(self, request, *args, **kwargs): return Response({'method': 'action2'}) - @collection_link() - def collection_link(self, request, *args, **kwargs): + @list_link() + def list_link(self, request, *args, **kwargs): return Response({'method': 'link1'}) @link() - def dynamic_link(self, request, *args, **kwargs): + def detail_link(self, request, *args, **kwargs): return Response({'method': 'link2'}) -class TestCollectionAndDynamicRouter(TestCase): +class TestDynamicListAndDetailRouter(TestCase): def setUp(self): self.router = SimpleRouter() def test_link_and_action_decorator(self): - routes = self.router.get_routes(CollectionAndDynamicViewSet) + routes = self.router.get_routes(DynamicListAndDetailViewSet) decorator_routes = [r for r in routes if not (r.name.endswith('-list') or r.name.endswith('-detail'))] # Make sure all these endpoints exist and none have been clobbered - for i, endpoint in enumerate(['collection_action', 'collection_link', 'dynamic_action', 'dynamic_link']): + for i, endpoint in enumerate(['list_action', 'list_link', 'detail_action', 'detail_link']): route = decorator_routes[i] # check url listing - if endpoint.startswith('collection_'): + if endpoint.startswith('list_'): self.assertEqual(route.url, '^{{prefix}}/{0}{{trailing_slash}}$'.format(endpoint)) else: From ca7ba07b4e42bd1c7c6bb8088c0c5a2c434b56ee Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Sat, 13 Jul 2013 11:12:59 -0400 Subject: [PATCH 09/72] Introduce DynamicDetailRoute and DynamicListRoute to distinguish between different route types --- rest_framework/routers.py | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index c8f711e91..b8f19b66a 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -25,7 +25,9 @@ from rest_framework.reverse import reverse from rest_framework.urlpatterns import format_suffix_patterns -Route = namedtuple('Route', ['key', 'url', 'mapping', 'name', 'initkwargs']) +Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs']) +DynamicDetailRoute = namedtuple('DynamicDetailRoute', ['url', 'name', 'initkwargs']) +DynamicListRoute = namedtuple('DynamicListRoute', ['url', 'name', 'initkwargs']) def replace_methodname(format_string, methodname): @@ -80,7 +82,6 @@ class SimpleRouter(BaseRouter): routes = [ # List route. Route( - key='list', url=r'^{prefix}{trailing_slash}$', mapping={ 'get': 'list', @@ -92,18 +93,13 @@ class SimpleRouter(BaseRouter): # Dynamically generated list routes. # Generated using @list_action or @list_link decorators # on methods of the viewset. - Route( - key='collection', + DynamicListRoute( url=r'^{prefix}/{methodname}{trailing_slash}$', - mapping={ - '{httpmethod}': '{methodname}', - }, name='{basename}-{methodnamehyphen}', initkwargs={} ), # Detail route. Route( - key='detail', url=r'^{prefix}/{lookup}{trailing_slash}$', mapping={ 'get': 'retrieve', @@ -116,12 +112,8 @@ class SimpleRouter(BaseRouter): ), # Dynamically generated detail routes. # Generated using @action or @link decorators on methods of the viewset. - Route( - key='dynamic', + DynamicDetailRoute( url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', - mapping={ - '{httpmethod}': '{methodname}', - }, name='{basename}-{methodnamehyphen}', initkwargs={} ), @@ -154,7 +146,7 @@ class SimpleRouter(BaseRouter): Returns a list of the Route namedtuple. """ - known_actions = flatten([route.mapping.values() for route in self.routes]) + known_actions = flatten([route.mapping.values() for route in self.routes if isinstance(route, Route)]) # Determine any `@action` or `@link` decorated methods on the viewset detail_routes = [] @@ -176,25 +168,23 @@ class SimpleRouter(BaseRouter): ret = [] for route in self.routes: - if route.key == 'dynamic': + if isinstance(route, DynamicDetailRoute): # Dynamic detail routes (@link or @action decorator) for httpmethods, methodname in detail_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( - key=route.key, url=replace_methodname(route.url, methodname), mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), name=replace_methodname(route.name, methodname), initkwargs=initkwargs, )) - elif route.key == 'collection': + elif isinstance(route, DynamicListRoute): # Dynamic list routes (@list_link or @list_action decorator) for httpmethods, methodname in list_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( - key=route.key, url=replace_methodname(route.url, methodname), mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), name=replace_methodname(route.name, methodname), From eaae8fb2d973769a827214e0606a7e41028d5d34 Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Mon, 15 Jul 2013 18:35:13 -0400 Subject: [PATCH 10/72] Combined link_* and action_* decorators into detail_route and list_route, marked the originals as deprecated. --- docs/api-guide/routers.md | 16 +++++----- docs/api-guide/viewsets.md | 16 +++++----- docs/tutorial/6-viewsets-and-routers.md | 8 ++--- rest_framework/decorators.py | 19 +++++++----- rest_framework/routers.py | 14 ++++----- rest_framework/tests/test_routers.py | 40 ++++++++++++------------- 6 files changed, 59 insertions(+), 54 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 865829057..f196dc3cd 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -35,12 +35,12 @@ The example above would generate the following URL patterns: * URL pattern: `^accounts/$` Name: `'account-list'` * URL pattern: `^accounts/{pk}/$` Name: `'account-detail'` -### Extra link and actions +### Registering additional routes -Any methods on the viewset decorated with `@link` or `@action` will also be routed. +Any methods on the viewset decorated with `@detail_route` or `@list_route` will also be routed. For example, a given method like this on the `UserViewSet` class: - @action(permission_classes=[IsAdminOrIsSelf]) + @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf]) def set_password(self, request, pk=None): ... @@ -52,7 +52,7 @@ The following URL pattern would additionally be generated: ## SimpleRouter -This router includes routes for the standard set of `list`, `create`, `retrieve`, `update`, `partial_update` and `destroy` actions. The viewset can also mark additional methods to be routed, using the `@link` or `@action` decorators. +This router includes routes for the standard set of `list`, `create`, `retrieve`, `update`, `partial_update` and `destroy` actions. The viewset can also mark additional methods to be routed, using the `@detail_route` or `@list_route` decorators. @@ -62,8 +62,8 @@ This router includes routes for the standard set of `list`, `create`, `retrieve` - - + +
URL StyleHTTP MethodActionURL Name
PUTupdate
PATCHpartial_update
DELETEdestroy
{prefix}/{lookup}/{methodname}/GET@link decorated method{basename}-{methodname}
POST@action decorated method
{prefix}/{lookup}/{methodname}/GET@detail_route decorated method{basename}-{methodname}
POST@detail_route decorated method
By default the URLs created by `SimpleRouter` are appending with a trailing slash. @@ -86,8 +86,8 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d PUTupdate PATCHpartial_update DELETEdestroy - {prefix}/{lookup}/{methodname}/[.format]GET@link decorated method{basename}-{methodname} - POST@action decorated method + {prefix}/{lookup}/{methodname}/[.format]GET@detail_route decorated method{basename}-{methodname} + POST@detail_route decorated method As with `SimpleRouter` the trailing slashs on the URL routes can be removed by setting the `trailing_slash` argument to `False` when instantiating the router. diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index 6d6bb1334..7a8d5979b 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -92,15 +92,15 @@ The default routers included with REST framework will provide routes for a stand def destroy(self, request, pk=None): pass -If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link`, `@action`, `@list_link`, or `@list_action` decorators. The `@link` and `@list_link` decorators will route `GET` requests, and the `@action` and `@list_action` decorators will route `POST` requests. +If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@detail_route` or `@list_route` decorators. -The `@link` and `@action` decorators contain `pk` in their URL pattern and are intended for methods which require a single instance. The `@list_link` and `@list_action` decorators are intended for methods which operate on a list of objects. +The `@detail_route` decorator contains `pk` in its URL pattern and is intended for methods which require a single instance. The `@list_route` decorator is intended for methods which operate on a list of objects. For example: from django.contrib.auth.models import User from rest_framework import viewsets - from rest_framework.decorators import action, list_link + from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response from myapp.serializers import UserSerializer, PasswordSerializer @@ -111,7 +111,7 @@ For example: queryset = User.objects.all() serializer_class = UserSerializer - @action() + @detail_route(methods=['post']) def set_password(self, request, pk=None): user = self.get_object() serializer = PasswordSerializer(data=request.DATA) @@ -123,7 +123,7 @@ For example: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @list_link() + @list_route() def recent_users(self, request): recent_users = User.objects.all().order('-last_login') page = self.paginate_queryset(recent_users) @@ -132,13 +132,13 @@ For example: The decorators can additionally take extra arguments that will be set for the routed view only. For example... - @action(permission_classes=[IsAdminOrIsSelf]) + @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf]) def set_password(self, request, pk=None): ... -The `@action` and `@list_action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `methods` argument. For example: +By default, the decorators will route `GET` requests, but may also accept other HTTP methods, by using the `methods` argument. For example: - @action(methods=['POST', 'DELETE']) + @detail_route(methods=['post', 'delete']) def unset_password(self, request, pk=None): ... --- diff --git a/docs/tutorial/6-viewsets-and-routers.md b/docs/tutorial/6-viewsets-and-routers.md index f16add39d..f126ba045 100644 --- a/docs/tutorial/6-viewsets-and-routers.md +++ b/docs/tutorial/6-viewsets-and-routers.md @@ -25,7 +25,7 @@ Here we've used `ReadOnlyModelViewSet` class to automatically provide the defaul Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighlight` view classes. We can remove the three views, and again replace them with a single class. - from rest_framework.decorators import link + from rest_framework.decorators import detail_route class SnippetViewSet(viewsets.ModelViewSet): """ @@ -39,7 +39,7 @@ Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighl permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly,) - @link(renderer_classes=[renderers.StaticHTMLRenderer]) + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) def highlight(self, request, *args, **kwargs): snippet = self.get_object() return Response(snippet.highlighted) @@ -49,9 +49,9 @@ Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighl This time we've used the `ModelViewSet` class in order to get the complete set of default read and write operations. -Notice that we've also used the `@link` decorator to create a custom action, named `highlight`. This decorator can be used to add any custom endpoints that don't fit into the standard `create`/`update`/`delete` style. +Notice that we've also used the `@detail_route` decorator to create a custom action, named `highlight`. This decorator can be used to add any custom endpoints that don't fit into the standard `create`/`update`/`delete` style. -Custom actions which use the `@link` decorator will respond to `GET` requests. We could have instead used the `@action` decorator if we wanted an action that responded to `POST` requests. +Custom actions which use the `@detail_route` decorator will respond to `GET` requests. We can use the `methods` argument if we wanted an action that responded to `POST` requests. ## Binding ViewSets to URLs explicitly diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index 92f551db7..1ca176f2c 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -3,13 +3,14 @@ The most important decorator in this module is `@api_view`, which is used for writing function-based views with REST framework. There are also various decorators for setting the API policies on function -based views, as well as the `@action` and `@link` decorators, which are +based views, as well as the `@detail_route` and `@list_route` decorators, which are used to annotate methods on viewsets that should be included by routers. """ from __future__ import unicode_literals from rest_framework.compat import six from rest_framework.views import APIView import types +import warnings def api_view(http_method_names): @@ -111,6 +112,8 @@ def link(**kwargs): """ Used to mark a method on a ViewSet that should be routed for detail GET requests. """ + msg = 'link is pending deprecation. Use detail_route instead.' + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) def decorator(func): func.bind_to_methods = ['get'] func.detail = True @@ -123,6 +126,8 @@ def action(methods=['post'], **kwargs): """ Used to mark a method on a ViewSet that should be routed for detail POST requests. """ + msg = 'action is pending deprecation. Use detail_route instead.' + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) def decorator(func): func.bind_to_methods = methods func.detail = True @@ -131,21 +136,21 @@ def action(methods=['post'], **kwargs): return decorator -def list_link(**kwargs): +def detail_route(methods=['get'], **kwargs): """ - Used to mark a method on a ViewSet that should be routed for GET requests. + Used to mark a method on a ViewSet that should be routed for detail requests. """ def decorator(func): - func.bind_to_methods = ['get'] - func.detail = False + func.bind_to_methods = methods + func.detail = True func.kwargs = kwargs return func return decorator -def list_action(methods=['post'], **kwargs): +def list_route(methods=['get'], **kwargs): """ - Used to mark a method on a ViewSet that should be routed for POST requests. + Used to mark a method on a ViewSet that should be routed for list requests. """ def decorator(func): func.bind_to_methods = methods diff --git a/rest_framework/routers.py b/rest_framework/routers.py index b8f19b66a..b761ba9ae 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -91,7 +91,7 @@ class SimpleRouter(BaseRouter): initkwargs={'suffix': 'List'} ), # Dynamically generated list routes. - # Generated using @list_action or @list_link decorators + # Generated using @list_route decorator # on methods of the viewset. DynamicListRoute( url=r'^{prefix}/{methodname}{trailing_slash}$', @@ -111,7 +111,7 @@ class SimpleRouter(BaseRouter): initkwargs={'suffix': 'Instance'} ), # Dynamically generated detail routes. - # Generated using @action or @link decorators on methods of the viewset. + # Generated using @detail_route decorator on methods of the viewset. DynamicDetailRoute( url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', name='{basename}-{methodnamehyphen}', @@ -148,7 +148,7 @@ class SimpleRouter(BaseRouter): known_actions = flatten([route.mapping.values() for route in self.routes if isinstance(route, Route)]) - # Determine any `@action` or `@link` decorated methods on the viewset + # Determine any `@detail_route` or `@list_route` decorated methods on the viewset detail_routes = [] list_routes = [] for methodname in dir(viewset): @@ -157,8 +157,8 @@ class SimpleRouter(BaseRouter): detail = getattr(attr, 'detail', True) if httpmethods: if methodname in known_actions: - raise ImproperlyConfigured('Cannot use @action, @link, @list_action ' - 'or @list_link decorator on method "%s" ' + raise ImproperlyConfigured('Cannot use @detail_route or @list_route ' + 'decorators on method "%s" ' 'as it is an existing route' % methodname) httpmethods = [method.lower() for method in httpmethods] if detail: @@ -169,7 +169,7 @@ class SimpleRouter(BaseRouter): ret = [] for route in self.routes: if isinstance(route, DynamicDetailRoute): - # Dynamic detail routes (@link or @action decorator) + # Dynamic detail routes (@detail_route decorator) for httpmethods, methodname in detail_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) @@ -180,7 +180,7 @@ class SimpleRouter(BaseRouter): initkwargs=initkwargs, )) elif isinstance(route, DynamicListRoute): - # Dynamic list routes (@list_link or @list_action decorator) + # Dynamic list routes (@list_route decorator) for httpmethods, methodname in list_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index 393101763..c3597e389 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -4,7 +4,7 @@ from django.test import TestCase from django.core.exceptions import ImproperlyConfigured from rest_framework import serializers, viewsets, permissions from rest_framework.compat import include, patterns, url -from rest_framework.decorators import link, action, list_link, list_action +from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response from rest_framework.routers import SimpleRouter, DefaultRouter from rest_framework.test import APIRequestFactory @@ -18,23 +18,23 @@ class BasicViewSet(viewsets.ViewSet): def list(self, request, *args, **kwargs): return Response({'method': 'list'}) - @action() + @detail_route(methods=['post']) def action1(self, request, *args, **kwargs): return Response({'method': 'action1'}) - @action() + @detail_route(methods=['post']) def action2(self, request, *args, **kwargs): return Response({'method': 'action2'}) - @action(methods=['post', 'delete']) + @detail_route(methods=['post', 'delete']) def action3(self, request, *args, **kwargs): return Response({'method': 'action2'}) - @link() + @detail_route() def link1(self, request, *args, **kwargs): return Response({'method': 'link1'}) - @link() + @detail_route() def link2(self, request, *args, **kwargs): return Response({'method': 'link2'}) @@ -175,7 +175,7 @@ class TestActionKeywordArgs(TestCase): class TestViewSet(viewsets.ModelViewSet): permission_classes = [] - @action(permission_classes=[permissions.AllowAny]) + @detail_route(methods=['post'], permission_classes=[permissions.AllowAny]) def custom(self, request, *args, **kwargs): return Response({ 'permission_classes': self.permission_classes @@ -196,14 +196,14 @@ class TestActionKeywordArgs(TestCase): class TestActionAppliedToExistingRoute(TestCase): """ - Ensure `@action` decorator raises an except when applied + Ensure `@detail_route` decorator raises an except when applied to an existing route """ def test_exception_raised_when_action_applied_to_existing_route(self): class TestViewSet(viewsets.ModelViewSet): - @action() + @detail_route(methods=['post']) def retrieve(self, request, *args, **kwargs): return Response({ 'hello': 'world' @@ -220,20 +220,20 @@ class DynamicListAndDetailViewSet(viewsets.ViewSet): def list(self, request, *args, **kwargs): return Response({'method': 'list'}) - @list_action() - def list_action(self, request, *args, **kwargs): + @list_route(methods=['post']) + def list_route_post(self, request, *args, **kwargs): return Response({'method': 'action1'}) - @action() - def detail_action(self, request, *args, **kwargs): + @detail_route(methods=['post']) + def detail_route_post(self, request, *args, **kwargs): return Response({'method': 'action2'}) - @list_link() - def list_link(self, request, *args, **kwargs): + @list_route() + def list_route_get(self, request, *args, **kwargs): return Response({'method': 'link1'}) - @link() - def detail_link(self, request, *args, **kwargs): + @detail_route() + def detail_route_get(self, request, *args, **kwargs): return Response({'method': 'link2'}) @@ -241,11 +241,11 @@ class TestDynamicListAndDetailRouter(TestCase): def setUp(self): self.router = SimpleRouter() - def test_link_and_action_decorator(self): + def test_list_and_detail_route_decorators(self): routes = self.router.get_routes(DynamicListAndDetailViewSet) decorator_routes = [r for r in routes if not (r.name.endswith('-list') or r.name.endswith('-detail'))] # Make sure all these endpoints exist and none have been clobbered - for i, endpoint in enumerate(['list_action', 'list_link', 'detail_action', 'detail_link']): + for i, endpoint in enumerate(['list_route_get', 'list_route_post', 'detail_route_get', 'detail_route_post']): route = decorator_routes[i] # check url listing if endpoint.startswith('list_'): @@ -255,7 +255,7 @@ class TestDynamicListAndDetailRouter(TestCase): self.assertEqual(route.url, '^{{prefix}}/{{lookup}}/{0}{{trailing_slash}}$'.format(endpoint)) # check method to function mapping - if endpoint.endswith('action'): + if endpoint.endswith('_post'): method_map = 'post' else: method_map = 'get' From 4292cc18fa3e4b3f5e67c02c3780cdcbf901a0a1 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 19 Aug 2013 20:53:30 +0100 Subject: [PATCH 11/72] Docs tweaking --- docs/api-guide/routers.md | 11 +++++++---- docs/api-guide/viewsets.md | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 7884c2e94..c84654189 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -48,6 +48,8 @@ The following URL pattern would additionally be generated: * URL pattern: `^users/{pk}/set_password/$` Name: `'user-set-password'` +For more information see the viewset documentation on [marking extra actions for routing][route-decorators]. + # API Guide ## SimpleRouter @@ -58,12 +60,12 @@ This router includes routes for the standard set of `list`, `create`, `retrieve` URL StyleHTTP MethodActionURL Name {prefix}/GETlist{basename}-list POSTcreate + {prefix}/{methodname}/GET, or as specified by `methods` argument`@list_route` decorated method{basename}-{methodname} {prefix}/{lookup}/GETretrieve{basename}-detail PUTupdate PATCHpartial_update DELETEdestroy - {prefix}/{lookup}/{methodname}/GET@detail_route decorated method{basename}-{methodname} - POST@detail_route decorated method + {prefix}/{lookup}/{methodname}/GET, or as specified by `methods` argument`@detail_route` decorated method{basename}-{methodname} By default the URLs created by `SimpleRouter` are appended with a trailing slash. @@ -82,12 +84,12 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d [.format]GETautomatically generated root viewapi-root {prefix}/[.format]GETlist{basename}-list POSTcreate + {prefix}/{methodname}/[.format]GET, or as specified by `methods` argument`@list_route` decorated method{basename}-{methodname} {prefix}/{lookup}/[.format]GETretrieve{basename}-detail PUTupdate PATCHpartial_update DELETEdestroy - {prefix}/{lookup}/{methodname}/[.format]GET@detail_route decorated method{basename}-{methodname} - POST@detail_route decorated method + {prefix}/{lookup}/{methodname}/[.format]GET, or as specified by `methods` argument`@detail_route` decorated method{basename}-{methodname} As with `SimpleRouter` the trailing slashes on the URL routes can be removed by setting the `trailing_slash` argument to `False` when instantiating the router. @@ -144,3 +146,4 @@ If you want to provide totally custom behavior, you can override `BaseRouter` an You may also want to override the `get_default_base_name(self, viewset)` method, or else always explicitly set the `base_name` argument when registering your viewsets with the router. [cite]: http://guides.rubyonrails.org/routing.html +[route-decorators]: viewsets.html#marking-extra-actions-for-routing \ No newline at end of file diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index 95efc229f..9005e7cbc 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -61,7 +61,7 @@ There are two main advantages of using a `ViewSet` class over using a `View` cla Both of these come with a trade-off. Using regular views and URL confs is more explicit and gives you more control. ViewSets are helpful if you want to get up and running quickly, or when you have a large API and you want to enforce a consistent URL configuration throughout. -## Marking extra methods for routing +## Marking extra actions for routing The default routers included with REST framework will provide routes for a standard set of create/retrieve/update/destroy style operations, as shown below: From 8acee2e626746f3096c49b3ebb13aaf7dc882917 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 19 Aug 2013 21:02:22 +0100 Subject: [PATCH 12/72] Commenting link/action decorators as pending deprecation --- rest_framework/decorators.py | 51 ++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index 1ca176f2c..18e41a18d 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -108,6 +108,31 @@ def permission_classes(permission_classes): return decorator +def detail_route(methods=['get'], **kwargs): + """ + Used to mark a method on a ViewSet that should be routed for detail requests. + """ + def decorator(func): + func.bind_to_methods = methods + func.detail = True + func.kwargs = kwargs + return func + return decorator + + +def list_route(methods=['get'], **kwargs): + """ + Used to mark a method on a ViewSet that should be routed for list requests. + """ + def decorator(func): + func.bind_to_methods = methods + func.detail = False + func.kwargs = kwargs + return func + return decorator + +# These are now pending deprecation, in favor of `detail_route` and `list_route`. + def link(**kwargs): """ Used to mark a method on a ViewSet that should be routed for detail GET requests. @@ -133,28 +158,4 @@ def action(methods=['post'], **kwargs): func.detail = True func.kwargs = kwargs return func - return decorator - - -def detail_route(methods=['get'], **kwargs): - """ - Used to mark a method on a ViewSet that should be routed for detail requests. - """ - def decorator(func): - func.bind_to_methods = methods - func.detail = True - func.kwargs = kwargs - return func - return decorator - - -def list_route(methods=['get'], **kwargs): - """ - Used to mark a method on a ViewSet that should be routed for list requests. - """ - def decorator(func): - func.bind_to_methods = methods - func.detail = False - func.kwargs = kwargs - return func - return decorator + return decorator \ No newline at end of file From 815ef50735f50c7aff5255e60f1b484e75178e87 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 21 Aug 2013 21:18:46 +0100 Subject: [PATCH 13/72] If page size query param <= 0, just use default page size. Closes #1028 --- rest_framework/generics.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 874a142c8..bcd62bf9d 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -14,6 +14,15 @@ from rest_framework.settings import api_settings import warnings +def strict_positive_int(integer_string): + """ + Cast a string to a strictly positive integer. + """ + ret = int(integer_string) + if ret <= 0: + raise ValueError() + return ret + def get_object_or_404(queryset, **filter_kwargs): """ Same as Django's standard shortcut, but make sure to raise 404 @@ -198,7 +207,7 @@ class GenericAPIView(views.APIView): if self.paginate_by_param: query_params = self.request.QUERY_PARAMS try: - return int(query_params[self.paginate_by_param]) + return strict_positive_int(query_params[self.paginate_by_param]) except (KeyError, ValueError): pass From 44ceef841543877a700c3fb4a0f84dfecbad0cbb Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 21 Aug 2013 21:30:25 +0100 Subject: [PATCH 14/72] Updating 2.4.0 release notes --- .travis.yml | 1 + docs/topics/release-notes.md | 5 +- rest_framework/compat.py | 2 +- rest_framework/six.py | 389 ----------------------------------- tox.ini | 2 + 5 files changed, 8 insertions(+), 391 deletions(-) delete mode 100644 rest_framework/six.py diff --git a/.travis.yml b/.travis.yml index 6a4532411..f8640db2c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,7 @@ install: - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.0 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.4 --use-mirrors; fi" - "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi" + - "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install six --use-mirrors; fi" - "if [[ ${DJANGO::11} != 'django==1.3' ]]; then pip install django-filter==0.6 --use-mirrors; fi" - export PYTHONPATH=. diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 52abfc08e..f3bb19c67 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -40,9 +40,12 @@ You can determine your currently installed version using `pip freeze`: ## 2.3.x series -### Master +### 2.4.0 +* `@detail_route` and `@list_route` decorators replace `@action` and `@link`. +* `six` no longer bundled. For Django <= 1.4.1, install `six` package. * Support customizable view name and description functions, using the `VIEW_NAME_FUNCTION` and `VIEW_DESCRIPTION_FUNCTION` settings. +* Bugfix: `?page_size=0` query parameter now falls back to default page size for view, instead of always turning pagination off. ### 2.3.7 diff --git a/rest_framework/compat.py b/rest_framework/compat.py index baee3a9c2..178a697f3 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -14,7 +14,7 @@ from django.conf import settings try: from django.utils import six except ImportError: - from rest_framework import six + import six # location of patterns, url, include changes in 1.4 onwards try: diff --git a/rest_framework/six.py b/rest_framework/six.py deleted file mode 100644 index 9e3823128..000000000 --- a/rest_framework/six.py +++ /dev/null @@ -1,389 +0,0 @@ -"""Utilities for writing code that runs on Python 2 and 3""" - -import operator -import sys -import types - -__author__ = "Benjamin Peterson " -__version__ = "1.2.0" - - -# True if we are running on Python 3. -PY3 = sys.version_info[0] == 3 - -if PY3: - string_types = str, - integer_types = int, - class_types = type, - text_type = str - binary_type = bytes - - MAXSIZE = sys.maxsize -else: - string_types = basestring, - integer_types = (int, long) - class_types = (type, types.ClassType) - text_type = unicode - binary_type = str - - if sys.platform == "java": - # Jython always uses 32 bits. - MAXSIZE = int((1 << 31) - 1) - else: - # It's possible to have sizeof(long) != sizeof(Py_ssize_t). - class X(object): - def __len__(self): - return 1 << 31 - try: - len(X()) - except OverflowError: - # 32-bit - MAXSIZE = int((1 << 31) - 1) - else: - # 64-bit - MAXSIZE = int((1 << 63) - 1) - del X - - -def _add_doc(func, doc): - """Add documentation to a function.""" - func.__doc__ = doc - - -def _import_module(name): - """Import module, returning the module after the last dot.""" - __import__(name) - return sys.modules[name] - - -class _LazyDescr(object): - - def __init__(self, name): - self.name = name - - def __get__(self, obj, tp): - result = self._resolve() - setattr(obj, self.name, result) - # This is a bit ugly, but it avoids running this again. - delattr(tp, self.name) - return result - - -class MovedModule(_LazyDescr): - - def __init__(self, name, old, new=None): - super(MovedModule, self).__init__(name) - if PY3: - if new is None: - new = name - self.mod = new - else: - self.mod = old - - def _resolve(self): - return _import_module(self.mod) - - -class MovedAttribute(_LazyDescr): - - def __init__(self, name, old_mod, new_mod, old_attr=None, new_attr=None): - super(MovedAttribute, self).__init__(name) - if PY3: - if new_mod is None: - new_mod = name - self.mod = new_mod - if new_attr is None: - if old_attr is None: - new_attr = name - else: - new_attr = old_attr - self.attr = new_attr - else: - self.mod = old_mod - if old_attr is None: - old_attr = name - self.attr = old_attr - - def _resolve(self): - module = _import_module(self.mod) - return getattr(module, self.attr) - - - -class _MovedItems(types.ModuleType): - """Lazy loading of moved objects""" - - -_moved_attributes = [ - MovedAttribute("cStringIO", "cStringIO", "io", "StringIO"), - MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"), - MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"), - MovedAttribute("map", "itertools", "builtins", "imap", "map"), - MovedAttribute("reload_module", "__builtin__", "imp", "reload"), - MovedAttribute("reduce", "__builtin__", "functools"), - MovedAttribute("StringIO", "StringIO", "io"), - MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"), - MovedAttribute("zip", "itertools", "builtins", "izip", "zip"), - - MovedModule("builtins", "__builtin__"), - MovedModule("configparser", "ConfigParser"), - MovedModule("copyreg", "copy_reg"), - MovedModule("http_cookiejar", "cookielib", "http.cookiejar"), - MovedModule("http_cookies", "Cookie", "http.cookies"), - MovedModule("html_entities", "htmlentitydefs", "html.entities"), - MovedModule("html_parser", "HTMLParser", "html.parser"), - MovedModule("http_client", "httplib", "http.client"), - MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"), - MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"), - MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"), - MovedModule("cPickle", "cPickle", "pickle"), - MovedModule("queue", "Queue"), - MovedModule("reprlib", "repr"), - MovedModule("socketserver", "SocketServer"), - MovedModule("tkinter", "Tkinter"), - MovedModule("tkinter_dialog", "Dialog", "tkinter.dialog"), - MovedModule("tkinter_filedialog", "FileDialog", "tkinter.filedialog"), - MovedModule("tkinter_scrolledtext", "ScrolledText", "tkinter.scrolledtext"), - MovedModule("tkinter_simpledialog", "SimpleDialog", "tkinter.simpledialog"), - MovedModule("tkinter_tix", "Tix", "tkinter.tix"), - MovedModule("tkinter_constants", "Tkconstants", "tkinter.constants"), - MovedModule("tkinter_dnd", "Tkdnd", "tkinter.dnd"), - MovedModule("tkinter_colorchooser", "tkColorChooser", - "tkinter.colorchooser"), - MovedModule("tkinter_commondialog", "tkCommonDialog", - "tkinter.commondialog"), - MovedModule("tkinter_tkfiledialog", "tkFileDialog", "tkinter.filedialog"), - MovedModule("tkinter_font", "tkFont", "tkinter.font"), - MovedModule("tkinter_messagebox", "tkMessageBox", "tkinter.messagebox"), - MovedModule("tkinter_tksimpledialog", "tkSimpleDialog", - "tkinter.simpledialog"), - MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"), - MovedModule("winreg", "_winreg"), -] -for attr in _moved_attributes: - setattr(_MovedItems, attr.name, attr) -del attr - -moves = sys.modules["django.utils.six.moves"] = _MovedItems("moves") - - -def add_move(move): - """Add an item to six.moves.""" - setattr(_MovedItems, move.name, move) - - -def remove_move(name): - """Remove item from six.moves.""" - try: - delattr(_MovedItems, name) - except AttributeError: - try: - del moves.__dict__[name] - except KeyError: - raise AttributeError("no such move, %r" % (name,)) - - -if PY3: - _meth_func = "__func__" - _meth_self = "__self__" - - _func_code = "__code__" - _func_defaults = "__defaults__" - - _iterkeys = "keys" - _itervalues = "values" - _iteritems = "items" -else: - _meth_func = "im_func" - _meth_self = "im_self" - - _func_code = "func_code" - _func_defaults = "func_defaults" - - _iterkeys = "iterkeys" - _itervalues = "itervalues" - _iteritems = "iteritems" - - -try: - advance_iterator = next -except NameError: - def advance_iterator(it): - return it.next() -next = advance_iterator - - -if PY3: - def get_unbound_function(unbound): - return unbound - - Iterator = object - - def callable(obj): - return any("__call__" in klass.__dict__ for klass in type(obj).__mro__) -else: - def get_unbound_function(unbound): - return unbound.im_func - - class Iterator(object): - - def next(self): - return type(self).__next__(self) - - callable = callable -_add_doc(get_unbound_function, - """Get the function out of a possibly unbound function""") - - -get_method_function = operator.attrgetter(_meth_func) -get_method_self = operator.attrgetter(_meth_self) -get_function_code = operator.attrgetter(_func_code) -get_function_defaults = operator.attrgetter(_func_defaults) - - -def iterkeys(d): - """Return an iterator over the keys of a dictionary.""" - return iter(getattr(d, _iterkeys)()) - -def itervalues(d): - """Return an iterator over the values of a dictionary.""" - return iter(getattr(d, _itervalues)()) - -def iteritems(d): - """Return an iterator over the (key, value) pairs of a dictionary.""" - return iter(getattr(d, _iteritems)()) - - -if PY3: - def b(s): - return s.encode("latin-1") - def u(s): - return s - if sys.version_info[1] <= 1: - def int2byte(i): - return bytes((i,)) - else: - # This is about 2x faster than the implementation above on 3.2+ - int2byte = operator.methodcaller("to_bytes", 1, "big") - import io - StringIO = io.StringIO - BytesIO = io.BytesIO -else: - def b(s): - return s - def u(s): - return unicode(s, "unicode_escape") - int2byte = chr - import StringIO - StringIO = BytesIO = StringIO.StringIO -_add_doc(b, """Byte literal""") -_add_doc(u, """Text literal""") - - -if PY3: - import builtins - exec_ = getattr(builtins, "exec") - - - def reraise(tp, value, tb=None): - if value.__traceback__ is not tb: - raise value.with_traceback(tb) - raise value - - - print_ = getattr(builtins, "print") - del builtins - -else: - def exec_(code, globs=None, locs=None): - """Execute code in a namespace.""" - if globs is None: - frame = sys._getframe(1) - globs = frame.f_globals - if locs is None: - locs = frame.f_locals - del frame - elif locs is None: - locs = globs - exec("""exec code in globs, locs""") - - - exec_("""def reraise(tp, value, tb=None): - raise tp, value, tb -""") - - - def print_(*args, **kwargs): - """The new-style print function.""" - fp = kwargs.pop("file", sys.stdout) - if fp is None: - return - def write(data): - if not isinstance(data, basestring): - data = str(data) - fp.write(data) - want_unicode = False - sep = kwargs.pop("sep", None) - if sep is not None: - if isinstance(sep, unicode): - want_unicode = True - elif not isinstance(sep, str): - raise TypeError("sep must be None or a string") - end = kwargs.pop("end", None) - if end is not None: - if isinstance(end, unicode): - want_unicode = True - elif not isinstance(end, str): - raise TypeError("end must be None or a string") - if kwargs: - raise TypeError("invalid keyword arguments to print()") - if not want_unicode: - for arg in args: - if isinstance(arg, unicode): - want_unicode = True - break - if want_unicode: - newline = unicode("\n") - space = unicode(" ") - else: - newline = "\n" - space = " " - if sep is None: - sep = space - if end is None: - end = newline - for i, arg in enumerate(args): - if i: - write(sep) - write(arg) - write(end) - -_add_doc(reraise, """Reraise an exception.""") - - -def with_metaclass(meta, base=object): - """Create a base class with a metaclass.""" - return meta("NewBase", (base,), {}) - - -### Additional customizations for Django ### - -if PY3: - _iterlists = "lists" - _assertRaisesRegex = "assertRaisesRegex" -else: - _iterlists = "iterlists" - _assertRaisesRegex = "assertRaisesRegexp" - - -def iterlists(d): - """Return an iterator over the values of a MultiValueDict.""" - return getattr(d, _iterlists)() - - -def assertRaisesRegex(self, *args, **kwargs): - return getattr(self, _assertRaisesRegex)(*args, **kwargs) - - -add_move(MovedModule("_dummy_thread", "dummy_thread")) -add_move(MovedModule("_thread", "thread")) diff --git a/tox.ini b/tox.ini index aa97fd350..6ec400ddf 100644 --- a/tox.ini +++ b/tox.ini @@ -91,6 +91,7 @@ deps = django==1.3.5 django-oauth-plus==2.0 oauth2==1.5.211 django-oauth2-provider==0.2.3 + six [testenv:py2.6-django1.3] basepython = python2.6 @@ -100,3 +101,4 @@ deps = django==1.3.5 django-oauth-plus==2.0 oauth2==1.5.211 django-oauth2-provider==0.2.3 + six From f631f55f8ebdf3d4e478aa5ca435ad36e86bee0f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 21 Aug 2013 21:35:17 +0100 Subject: [PATCH 15/72] Tweak comment --- rest_framework/compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 178a697f3..66be96a62 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -10,7 +10,7 @@ import django from django.core.exceptions import ImproperlyConfigured from django.conf import settings -# Try to import six from Django, fallback to included `six`. +# Try to import six from Django, fallback to external `six` package. try: from django.utils import six except ImportError: From bf07b8e616bd92e4ae3c2c09b198181d7075e6bd Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 29 Aug 2013 08:53:19 +0100 Subject: [PATCH 16/72] Better docs for customizing dynamic routes. Refs #908 --- docs/api-guide/routers.md | 81 +++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 11 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index f083b3d45..730fa876a 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -123,28 +123,87 @@ The arguments to the `Route` named tuple are: **initkwargs**: A dictionary of any additional arguments that should be passed when instantiating the view. Note that the `suffix` argument is reserved for identifying the viewset type, used when generating the view name and breadcrumb links. +## Customizing dynamic routes + +You can also customize how the `@list_route` and `@detail_route` decorators are routed. +To route either or both of these decorators, include a `DynamicListRoute` and/or `DynamicDetailRoute` named tuple in the `.routes` list. + +The arguments to `DynamicListRoute` and `DynamicDetailRoute` are: + +**url**: A string representing the URL to be routed. May include the same format strings as `Route`, and additionally accepts the `{methodname}` and `{methodnamehyphen}` format strings. + +**name**: The name of the URL as used in `reverse` calls. May include the following format strings: `{basename}`, `{methodname}` and `{methodnamehyphen}`. + +**initkwargs**: A dictionary of any additional arguments that should be passed when instantiating the view. + ## Example The following example will only route to the `list` and `retrieve` actions, and does not use the trailing slash convention. - from rest_framework.routers import Route, SimpleRouter + from rest_framework.routers import Route, DynamicDetailRoute, SimpleRouter - class ReadOnlyRouter(SimpleRouter): + class CustomReadOnlyRouter(SimpleRouter): """ A router for read-only APIs, which doesn't use trailing slashes. """ routes = [ - Route(url=r'^{prefix}$', - mapping={'get': 'list'}, - name='{basename}-list', - initkwargs={'suffix': 'List'}), - Route(url=r'^{prefix}/{lookup}$', - mapping={'get': 'retrieve'}, - name='{basename}-detail', - initkwargs={'suffix': 'Detail'}) + Route( + url=r'^{prefix}$', + mapping={'get': 'list'}, + name='{basename}-list', + initkwargs={'suffix': 'List'} + ), + Route( + url=r'^{prefix}/{lookup}$', + mapping={'get': 'retrieve'}, + name='{basename}-detail', + initkwargs={'suffix': 'Detail'} + ), + DynamicDetailRoute( + url=r'^{prefix}/{lookup}/{methodnamehyphen}$', + name='{basename}-{methodnamehyphen}', + initkwargs={} + ) ] -The `SimpleRouter` class provides another example of setting the `.routes` attribute. +Let's take a look at the routes our `CustomReadOnlyRouter` would generate for a simple viewset. + +`views.py`: + + class UserViewSet(viewsets.ReadOnlyModelViewSet): + """ + A viewset that provides the standard actions + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + @detail_route() + def group_names(self, request): + """ + Returns a list of all the group names that the given + user belongs to. + """ + user = self.get_object() + groups = user.groups.all() + return Response([group.name for group in groups]) + +`urls.py`: + + router = CustomReadOnlyRouter() + router.register('users', UserViewSet) + urlpatterns = router.urls + +The following mappings would be generated... + + + + + + +
URLHTTP MethodActionURL Name
/usersGETlistuser-list
/users/{username}GETretrieveuser-detail
/users/{username}/group-namesGETgroup_namesuser-group-names
+ +For another example of setting the `.routes` attribute, see the source code for the `SimpleRouter` class. ## Advanced custom routers From e441f85109e64345a12e65062fc0e51c5787e67f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 25 Sep 2013 10:30:04 +0100 Subject: [PATCH 17/72] Drop 1.3 support --- .travis.yml | 12 +- rest_framework/authentication.py | 2 +- rest_framework/compat.py | 381 +----------------- rest_framework/fields.py | 4 +- rest_framework/routers.py | 2 +- rest_framework/runtests/settings.py | 5 +- rest_framework/runtests/urls.py | 2 +- rest_framework/serializers.py | 6 +- .../templates/rest_framework/base.html | 1 + .../templates/rest_framework/login_base.html | 1 + rest_framework/templatetags/rest_framework.py | 87 +--- rest_framework/tests/test_authentication.py | 2 +- rest_framework/tests/test_breadcrumbs.py | 2 +- rest_framework/tests/test_filters.py | 3 +- rest_framework/tests/test_htmlrenderer.py | 2 +- .../tests/test_hyperlinkedserializers.py | 4 +- .../tests/test_relations_hyperlink.py | 2 +- rest_framework/tests/test_renderers.py | 3 +- rest_framework/tests/test_request.py | 2 +- rest_framework/tests/test_response.py | 2 +- rest_framework/tests/test_reverse.py | 2 +- rest_framework/tests/test_routers.py | 2 +- rest_framework/tests/test_testing.py | 2 +- rest_framework/tests/test_urlpatterns.py | 2 +- rest_framework/urlpatterns.py | 2 +- rest_framework/urls.py | 2 +- rest_framework/utils/encoders.py | 3 +- tox.ini | 22 +- 28 files changed, 56 insertions(+), 506 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7ebe715a0..456f8e9c0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,18 +10,15 @@ env: - DJANGO="https://www.djangoproject.com/download/1.6a1/tarball/" - DJANGO="django==1.5.1 --use-mirrors" - DJANGO="django==1.4.5 --use-mirrors" - - DJANGO="django==1.3.7 --use-mirrors" install: - pip install $DJANGO - - pip install defusedxml==0.3 + - pip install defusedxml==0.3 --use-mirrors + - pip install django-filter==0.6 --use-mirrors - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2==1.5.211 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.0 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.4 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-guardian==1.1.1 --use-mirrors; fi" - - "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi" - - "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install six --use-mirrors; fi" - - "if [[ ${DJANGO::11} != 'django==1.3' ]]; then pip install django-filter==0.6 --use-mirrors; fi" - export PYTHONPATH=. script: @@ -31,10 +28,5 @@ matrix: exclude: - python: "3.2" env: DJANGO="django==1.4.5 --use-mirrors" - - python: "3.2" - env: DJANGO="django==1.3.7 --use-mirrors" - python: "3.3" env: DJANGO="django==1.4.5 --use-mirrors" - - python: "3.3" - env: DJANGO="django==1.3.7 --use-mirrors" - diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index cf001a24d..db5cce40d 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -6,8 +6,8 @@ import base64 from django.contrib.auth import authenticate from django.core.exceptions import ImproperlyConfigured +from django.middleware.csrf import CsrfViewMiddleware 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, provider_now from rest_framework.authtoken.models import Token diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 1238f043a..f048b10ad 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -5,25 +5,19 @@ versions of django/python, and compatibility wrappers around optional packages. # flake8: noqa from __future__ import unicode_literals - import django from django.core.exceptions import ImproperlyConfigured from django.conf import settings + # Try to import six from Django, fallback to external `six` package. try: from django.utils import six except ImportError: import six -# location of patterns, url, include changes in 1.4 onwards -try: - from django.conf.urls import patterns, url, include -except ImportError: - from django.conf.urls.defaults import patterns, url, include - -# Handle django.utils.encoding rename: -# smart_unicode -> smart_text +# Handle django.utils.encoding rename in 1.5 onwards. +# smart_unicode -> smart_text # force_unicode -> force_text try: from django.utils.encoding import smart_text @@ -41,13 +35,15 @@ try: except ImportError: from django.http import HttpResponse as HttpResponseBase + # django-filter is optional try: import django_filters except ImportError: django_filters = None -# guardian is optional + +# django-guardian is optional try: import guardian except ImportError: @@ -80,14 +76,6 @@ except ImportError: Image = None -def get_concrete_model(model_cls): - try: - return model_cls._meta.concrete_model - except AttributeError: - # 1.3 does not include concrete model - return model_cls - - # Django 1.5 add support for custom auth user model if django.VERSION >= (1, 5): AUTH_USER_MODEL = settings.AUTH_USER_MODEL @@ -95,46 +83,13 @@ else: AUTH_USER_MODEL = 'auth.User' +# View._allowed_methods only present from 1.5 onwards if django.VERSION >= (1, 5): from django.views.generic import View else: - from django.views.generic import View as _View - from django.utils.decorators import classonlymethod - from django.utils.functional import update_wrapper + from django.views.generic import View as DjangoView - class View(_View): - # 1.3 does not include head method in base View class - # See: https://code.djangoproject.com/ticket/15668 - @classonlymethod - def as_view(cls, **initkwargs): - """ - Main entry point for a request-response process. - """ - # sanitize keyword arguments - for key in initkwargs: - if key in cls.http_method_names: - raise TypeError("You tried to pass in the %s method name as a " - "keyword argument to %s(). Don't do that." - % (key, cls.__name__)) - if not hasattr(cls, key): - raise TypeError("%s() received an invalid keyword %r" % ( - cls.__name__, key)) - - def view(request, *args, **kwargs): - self = cls(**initkwargs) - if hasattr(self, 'get') and not hasattr(self, 'head'): - self.head = self.get - return self.dispatch(request, *args, **kwargs) - - # take name and docstring from class - update_wrapper(view, cls, updated=()) - - # and possible attributes set by decorators - # like csrf_exempt from dispatch - update_wrapper(view, cls.dispatch, assigned=()) - return view - - # _allowed_methods only present from 1.5 onwards + class View(DjangoView): def _allowed_methods(self): return [m.upper() for m in self.http_method_names if hasattr(self, m)] @@ -144,316 +99,16 @@ if 'patch' not in View.http_method_names: View.http_method_names = View.http_method_names + ['patch'] -# PUT, DELETE do not require CSRF until 1.4. They should. Make it better. -if django.VERSION >= (1, 4): - from django.middleware.csrf import CsrfViewMiddleware -else: - import hashlib - import re - import random - import logging - - from django.conf import settings - from django.core.urlresolvers import get_callable - - try: - from logging import NullHandler - except ImportError: - class NullHandler(logging.Handler): - def emit(self, record): - pass - - logger = logging.getLogger('django.request') - - if not logger.handlers: - logger.addHandler(NullHandler()) - - def same_origin(url1, url2): - """ - Checks if two URLs are 'same-origin' - """ - p1, p2 = urlparse.urlparse(url1), urlparse.urlparse(url2) - return p1[0:2] == p2[0:2] - - def constant_time_compare(val1, val2): - """ - Returns True if the two strings are equal, False otherwise. - - The time taken is independent of the number of characters that match. - """ - if len(val1) != len(val2): - return False - result = 0 - for x, y in zip(val1, val2): - result |= ord(x) ^ ord(y) - return result == 0 - - # Use the system (hardware-based) random number generator if it exists. - if hasattr(random, 'SystemRandom'): - randrange = random.SystemRandom().randrange - else: - randrange = random.randrange - - _MAX_CSRF_KEY = 18446744073709551616 # 2 << 63 - - REASON_NO_REFERER = "Referer checking failed - no Referer." - REASON_BAD_REFERER = "Referer checking failed - %s does not match %s." - REASON_NO_CSRF_COOKIE = "CSRF cookie not set." - REASON_BAD_TOKEN = "CSRF token missing or incorrect." - - def _get_failure_view(): - """ - Returns the view to be used for CSRF rejections - """ - return get_callable(settings.CSRF_FAILURE_VIEW) - - def _get_new_csrf_key(): - return hashlib.md5("%s%s" % (randrange(0, _MAX_CSRF_KEY), settings.SECRET_KEY)).hexdigest() - - def get_token(request): - """ - Returns the the CSRF token required for a POST form. The token is an - alphanumeric value. - - A side effect of calling this function is to make the the csrf_protect - decorator and the CsrfViewMiddleware add a CSRF cookie and a 'Vary: Cookie' - header to the outgoing response. For this reason, you may need to use this - function lazily, as is done by the csrf context processor. - """ - request.META["CSRF_COOKIE_USED"] = True - return request.META.get("CSRF_COOKIE", None) - - def _sanitize_token(token): - # Allow only alphanum, and ensure we return a 'str' for the sake of the post - # processing middleware. - token = re.sub('[^a-zA-Z0-9]', '', str(token.decode('ascii', 'ignore'))) - if token == "": - # In case the cookie has been truncated to nothing at some point. - return _get_new_csrf_key() - else: - return token - - class CsrfViewMiddleware(object): - """ - Middleware that requires a present and correct csrfmiddlewaretoken - for POST requests that have a CSRF cookie, and sets an outgoing - CSRF cookie. - - This middleware should be used in conjunction with the csrf_token template - tag. - """ - # The _accept and _reject methods currently only exist for the sake of the - # requires_csrf_token decorator. - def _accept(self, request): - # Avoid checking the request twice by adding a custom attribute to - # request. This will be relevant when both decorator and middleware - # are used. - request.csrf_processing_done = True - return None - - def _reject(self, request, reason): - return _get_failure_view()(request, reason=reason) - - def process_view(self, request, callback, callback_args, callback_kwargs): - - if getattr(request, 'csrf_processing_done', False): - return None - - try: - csrf_token = _sanitize_token(request.COOKIES[settings.CSRF_COOKIE_NAME]) - # Use same token next time - request.META['CSRF_COOKIE'] = csrf_token - except KeyError: - csrf_token = None - # Generate token and store it in the request, so it's available to the view. - request.META["CSRF_COOKIE"] = _get_new_csrf_key() - - # Wait until request.META["CSRF_COOKIE"] has been manipulated before - # bailing out, so that get_token still works - if getattr(callback, 'csrf_exempt', False): - return None - - # Assume that anything not defined as 'safe' by RC2616 needs protection. - if request.method not in ('GET', 'HEAD', 'OPTIONS', 'TRACE'): - if getattr(request, '_dont_enforce_csrf_checks', False): - # Mechanism to turn off CSRF checks for test suite. It comes after - # the creation of CSRF cookies, so that everything else continues to - # work exactly the same (e.g. cookies are sent etc), but before the - # any branches that call reject() - return self._accept(request) - - if request.is_secure(): - # Suppose user visits http://example.com/ - # An active network attacker,(man-in-the-middle, MITM) sends a - # POST form which targets https://example.com/detonate-bomb/ and - # submits it via javascript. - # - # The attacker will need to provide a CSRF cookie and token, but - # that is no problem for a MITM and the session independent - # nonce we are using. So the MITM can circumvent the CSRF - # protection. This is true for any HTTP connection, but anyone - # using HTTPS expects better! For this reason, for - # https://example.com/ we need additional protection that treats - # http://example.com/ as completely untrusted. Under HTTPS, - # Barth et al. found that the Referer header is missing for - # same-domain requests in only about 0.2% of cases or less, so - # we can use strict Referer checking. - referer = request.META.get('HTTP_REFERER') - if referer is None: - logger.warning('Forbidden (%s): %s' % (REASON_NO_REFERER, request.path), - extra={ - 'status_code': 403, - 'request': request, - } - ) - return self._reject(request, REASON_NO_REFERER) - - # Note that request.get_host() includes the port - good_referer = 'https://%s/' % request.get_host() - if not same_origin(referer, good_referer): - reason = REASON_BAD_REFERER % (referer, good_referer) - logger.warning('Forbidden (%s): %s' % (reason, request.path), - extra={ - 'status_code': 403, - 'request': request, - } - ) - return self._reject(request, reason) - - if csrf_token is None: - # No CSRF cookie. For POST requests, we insist on a CSRF cookie, - # and in this way we can avoid all CSRF attacks, including login - # CSRF. - logger.warning('Forbidden (%s): %s' % (REASON_NO_CSRF_COOKIE, request.path), - extra={ - 'status_code': 403, - 'request': request, - } - ) - return self._reject(request, REASON_NO_CSRF_COOKIE) - - # check non-cookie token for match - request_csrf_token = "" - if request.method == "POST": - request_csrf_token = request.POST.get('csrfmiddlewaretoken', '') - - if request_csrf_token == "": - # Fall back to X-CSRFToken, to make things easier for AJAX, - # and possible for PUT/DELETE - request_csrf_token = request.META.get('HTTP_X_CSRFTOKEN', '') - - if not constant_time_compare(request_csrf_token, csrf_token): - logger.warning('Forbidden (%s): %s' % (REASON_BAD_TOKEN, request.path), - extra={ - 'status_code': 403, - 'request': request, - } - ) - return self._reject(request, REASON_BAD_TOKEN) - - return self._accept(request) - -# timezone support is new in Django 1.4 -try: - from django.utils import timezone -except ImportError: - timezone = None - -# dateparse is ALSO new in Django 1.4 -try: - from django.utils.dateparse import parse_date, parse_datetime, parse_time -except ImportError: - import datetime - import re - - date_re = re.compile( - r'(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})$' - ) - - datetime_re = re.compile( - r'(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})' - r'[T ](?P\d{1,2}):(?P\d{1,2})' - r'(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?' - r'(?PZ|[+-]\d{1,2}:\d{1,2})?$' - ) - - time_re = re.compile( - r'(?P\d{1,2}):(?P\d{1,2})' - r'(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?' - ) - - def parse_date(value): - match = date_re.match(value) - if match: - kw = dict((k, int(v)) for k, v in match.groupdict().iteritems()) - return datetime.date(**kw) - - def parse_time(value): - match = time_re.match(value) - if match: - kw = match.groupdict() - if kw['microsecond']: - kw['microsecond'] = kw['microsecond'].ljust(6, '0') - kw = dict((k, int(v)) for k, v in kw.iteritems() if v is not None) - return datetime.time(**kw) - - def parse_datetime(value): - """Parse datetime, but w/o the timezone awareness in 1.4""" - match = datetime_re.match(value) - if match: - kw = match.groupdict() - if kw['microsecond']: - kw['microsecond'] = kw['microsecond'].ljust(6, '0') - 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: - import re - from django.utils.encoding import smart_str - try: - from urllib.parse import quote, urlsplit, urlunsplit - except ImportError: # Python 2 - from urllib import quote - from urlparse import urlsplit, urlunsplit - - unquoted_percents_re = re.compile(r'%(?![0-9A-Fa-f]{2})') - - 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(smart_str(url), safe=b'!*\'();:@&=+$,/?#[]~') - - return force_text(url) - - -# RequestFactory only provide `generic` from 1.5 onwards - +# RequestFactory only provides `generic` from 1.5 onwards from django.test.client import RequestFactory as DjangoRequestFactory from django.test.client import FakePayload try: # In 1.5 the test client uses force_bytes from django.utils.encoding import force_bytes_or_smart_bytes except ImportError: - # In 1.3 and 1.4 the test client just uses smart_str + # In 1.4 the test client just uses smart_str from django.utils.encoding import smart_str as force_bytes_or_smart_bytes - class RequestFactory(DjangoRequestFactory): def generic(self, method, path, data='', content_type='application/octet-stream', **extra): @@ -478,6 +133,7 @@ class RequestFactory(DjangoRequestFactory): r.update(extra) return self.request(**r) + # Markdown is optional try: import markdown @@ -492,7 +148,6 @@ try: safe_mode = False md = markdown.Markdown(extensions=extensions, safe_mode=safe_mode) return md.convert(text) - except ImportError: apply_markdown = None @@ -510,14 +165,16 @@ try: except ImportError: etree = None -# OAuth is optional + +# OAuth2 is optional try: # Note: The `oauth2` package actually provides oauth1.0a support. Urg. import oauth2 as oauth except ImportError: oauth = None -# OAuth is optional + +# OAuthProvider is optional try: import oauth_provider from oauth_provider.store import store as oauth_provider_store @@ -525,6 +182,7 @@ except (ImportError, ImproperlyConfigured): oauth_provider = None oauth_provider_store = None + # OAuth 2 support is optional try: import provider.oauth2 as oauth2_provider @@ -542,8 +200,6 @@ try: # Any other supported version does use timezone aware datetimes from django.utils.timezone import now as provider_now except ImportError: - import traceback - traceback.print_exc() oauth2_provider = None oauth2_provider_models = None oauth2_provider_forms = None @@ -551,7 +207,8 @@ except ImportError: oauth2_constants = None provider_now = None -# Handle lazy strings + +# Handle lazy strings across Py2/Py3 from django.utils.functional import Promise if six.PY3: diff --git a/rest_framework/fields.py b/rest_framework/fields.py index b3a9b0dfe..f340510d3 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -18,12 +18,14 @@ from django.conf import settings from django.db.models.fields import BLANK_CHOICE_DASH from django.http import QueryDict from django.forms import widgets +from django.utils import timezone from django.utils.encoding import is_protected_type from django.utils.translation import ugettext_lazy as _ from django.utils.datastructures import SortedDict +from django.utils.dateparse import parse_date, parse_datetime, parse_time from rest_framework import ISO_8601 from rest_framework.compat import ( - timezone, parse_date, parse_datetime, parse_time, BytesIO, six, smart_text, + BytesIO, six, smart_text, force_text, is_non_str_iterable ) from rest_framework.settings import api_settings diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 1c7a81585..790299cc3 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -17,9 +17,9 @@ from __future__ import unicode_literals import itertools from collections import namedtuple +from django.conf.urls import patterns, url from django.core.exceptions import ImproperlyConfigured from rest_framework import views -from rest_framework.compat import patterns, url from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.urlpatterns import format_suffix_patterns diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py index be7216580..12aa73e7b 100644 --- a/rest_framework/runtests/settings.py +++ b/rest_framework/runtests/settings.py @@ -93,10 +93,7 @@ INSTALLED_APPS = ( 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', - # Uncomment the next line to enable the admin: - # 'django.contrib.admin', - # Uncomment the next line to enable admin documentation: - # 'django.contrib.admindocs', + 'django.contrib.staticfiles', 'rest_framework', 'rest_framework.authtoken', 'rest_framework.tests', diff --git a/rest_framework/runtests/urls.py b/rest_framework/runtests/urls.py index ed5baeae6..dff710115 100644 --- a/rest_framework/runtests/urls.py +++ b/rest_framework/runtests/urls.py @@ -1,7 +1,7 @@ """ Blank URLConf just to keep runtests.py happy. """ -from rest_framework.compat import patterns +from django.conf.urls import patterns urlpatterns = patterns('', ) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index f17757625..9e3881a2c 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -18,7 +18,7 @@ from decimal import Decimal from django.db import models from django.forms import widgets from django.utils.datastructures import SortedDict -from rest_framework.compat import get_concrete_model, six +from rest_framework.compat import six # Note: We do the following so that users of the framework can use this style: # @@ -575,7 +575,7 @@ class ModelSerializer(Serializer): cls = self.opts.model assert cls is not None, \ "Serializer class '%s' is missing 'model' Meta option" % self.__class__.__name__ - opts = get_concrete_model(cls)._meta + opts = cls._meta.concrete_model._meta ret = SortedDict() nested = bool(self.opts.depth) @@ -784,7 +784,7 @@ class ModelSerializer(Serializer): Return a list of field names to exclude from model validation. """ cls = self.opts.model - opts = get_concrete_model(cls)._meta + opts = cls._meta.concrete_model._meta exclusions = [field.name for field in opts.fields + opts.many_to_many] for field_name, field in self.fields.items(): diff --git a/rest_framework/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html index 2776d5500..47377d51f 100644 --- a/rest_framework/templates/rest_framework/base.html +++ b/rest_framework/templates/rest_framework/base.html @@ -1,4 +1,5 @@ {% load url from future %} +{% load staticfiles %} {% load rest_framework %} diff --git a/rest_framework/templates/rest_framework/login_base.html b/rest_framework/templates/rest_framework/login_base.html index be9a0072a..be83c2f53 100644 --- a/rest_framework/templates/rest_framework/login_base.html +++ b/rest_framework/templates/rest_framework/login_base.html @@ -1,4 +1,5 @@ {% load url from future %} +{% load staticfiles %} {% load rest_framework %} diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index e9c1cdd54..55f361490 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -2,97 +2,14 @@ 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, force_text, six, smart_urlquote +from rest_framework.compat import urlparse, force_text, six import re, string register = template.Library() -# Note we don't use 'load staticfiles', because we need a 1.3 compatible -# version, so instead we include the `static` template tag ourselves. - -# When 1.3 becomes unsupported by REST framework, we can instead start to -# use the {% load staticfiles %} tag, remove the following code, -# and add a dependency that `django.contrib.staticfiles` must be installed. - -# Note: We can't put this into the `compat` module because the compat import -# from rest_framework.compat import ... -# conflicts with this rest_framework template tag module. - -try: # Django 1.5+ - from django.contrib.staticfiles.templatetags.staticfiles import StaticFilesNode - - @register.tag('static') - def do_static(parser, token): - return StaticFilesNode.handle_token(parser, token) - -except ImportError: - try: # Django 1.4 - from django.contrib.staticfiles.storage import staticfiles_storage - - @register.simple_tag - def static(path): - """ - A template tag that returns the URL to a file - using staticfiles' storage backend - """ - return staticfiles_storage.url(path) - - except ImportError: # Django 1.3 - from urlparse import urljoin - from django import template - from django.templatetags.static import PrefixNode - - class StaticNode(template.Node): - def __init__(self, varname=None, path=None): - if path is None: - raise template.TemplateSyntaxError( - "Static template nodes must be given a path to return.") - self.path = path - self.varname = varname - - def url(self, context): - path = self.path.resolve(context) - return self.handle_simple(path) - - def render(self, context): - url = self.url(context) - if self.varname is None: - return url - context[self.varname] = url - return '' - - @classmethod - def handle_simple(cls, path): - return urljoin(PrefixNode.handle_simple("STATIC_URL"), path) - - @classmethod - def handle_token(cls, parser, token): - """ - Class method to parse prefix node and return a Node. - """ - bits = token.split_contents() - - if len(bits) < 2: - raise template.TemplateSyntaxError( - "'%s' takes at least one argument (path to file)" % bits[0]) - - path = parser.compile_filter(bits[1]) - - if len(bits) >= 2 and bits[-2] == 'as': - varname = bits[3] - else: - varname = None - - return cls(varname, path) - - @register.tag('static') - def do_static_13(parser, token): - return StaticNode.handle_token(parser, token) - - def replace_query_param(url, key, val): """ Given a URL and a key/val pair, set or replace an item in the query diff --git a/rest_framework/tests/test_authentication.py b/rest_framework/tests/test_authentication.py index a44813b69..e9a817c0f 100644 --- a/rest_framework/tests/test_authentication.py +++ b/rest_framework/tests/test_authentication.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +from django.conf.urls import patterns, url, include from django.contrib.auth.models import User from django.http import HttpResponse from django.test import TestCase @@ -18,7 +19,6 @@ from rest_framework.authentication import ( OAuth2Authentication ) from rest_framework.authtoken.models import Token -from rest_framework.compat import patterns, url, include from rest_framework.compat import oauth2_provider, oauth2_provider_models, oauth2_provider_scope from rest_framework.compat import oauth, oauth_provider from rest_framework.test import APIRequestFactory, APIClient diff --git a/rest_framework/tests/test_breadcrumbs.py b/rest_framework/tests/test_breadcrumbs.py index 41ddf2cea..33740cbb2 100644 --- a/rest_framework/tests/test_breadcrumbs.py +++ b/rest_framework/tests/test_breadcrumbs.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals +from django.conf.urls import patterns, url from django.test import TestCase -from rest_framework.compat import patterns, url from rest_framework.utils.breadcrumbs import get_breadcrumbs from rest_framework.views import APIView diff --git a/rest_framework/tests/test_filters.py b/rest_framework/tests/test_filters.py index 379db29d8..9697c5eef 100644 --- a/rest_framework/tests/test_filters.py +++ b/rest_framework/tests/test_filters.py @@ -1,12 +1,13 @@ from __future__ import unicode_literals import datetime from decimal import Decimal +from django.conf.urls import patterns, url from django.db import models from django.core.urlresolvers import reverse from django.test import TestCase from django.utils import unittest from rest_framework import generics, serializers, status, filters -from rest_framework.compat import django_filters, patterns, url +from rest_framework.compat import django_filters from rest_framework.test import APIRequestFactory from rest_framework.tests.models import BasicModel diff --git a/rest_framework/tests/test_htmlrenderer.py b/rest_framework/tests/test_htmlrenderer.py index 8957a43c7..6c570dfdc 100644 --- a/rest_framework/tests/test_htmlrenderer.py +++ b/rest_framework/tests/test_htmlrenderer.py @@ -1,11 +1,11 @@ from __future__ import unicode_literals from django.core.exceptions import PermissionDenied +from django.conf.urls import patterns, url from django.http import Http404 from django.test import TestCase from django.template import TemplateDoesNotExist, Template import django.template.loader from rest_framework import status -from rest_framework.compat import patterns, url from rest_framework.decorators import api_view, renderer_classes from rest_framework.renderers import TemplateHTMLRenderer from rest_framework.response import Response diff --git a/rest_framework/tests/test_hyperlinkedserializers.py b/rest_framework/tests/test_hyperlinkedserializers.py index 61e613d75..ea7f70f2f 100644 --- a/rest_framework/tests/test_hyperlinkedserializers.py +++ b/rest_framework/tests/test_hyperlinkedserializers.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import json +from django.conf.urls import patterns, url from django.test import TestCase from rest_framework import generics, status, serializers -from rest_framework.compat import patterns, url from rest_framework.test import APIRequestFactory from rest_framework.tests.models import ( Anchor, BasicModel, ManyToManyModel, BlogPost, BlogPostComment, @@ -24,7 +24,7 @@ class BlogPostCommentSerializer(serializers.ModelSerializer): class PhotoSerializer(serializers.Serializer): description = serializers.CharField() - album_url = serializers.HyperlinkedRelatedField(source='album', view_name='album-detail', queryset=Album.objects.all(), lookup_field='title', slug_url_kwarg='title') + album_url = serializers.HyperlinkedRelatedField(source='album', view_name='album-detail', queryset=Album.objects.all(), lookup_field='title') def restore_object(self, attrs, instance=None): return Photo(**attrs) diff --git a/rest_framework/tests/test_relations_hyperlink.py b/rest_framework/tests/test_relations_hyperlink.py index 3c4d39af6..fa6b01aca 100644 --- a/rest_framework/tests/test_relations_hyperlink.py +++ b/rest_framework/tests/test_relations_hyperlink.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals +from django.conf.urls import patterns, url from django.test import TestCase from rest_framework import serializers -from rest_framework.compat import patterns, url from rest_framework.test import APIRequestFactory from rest_framework.tests.models import ( BlogPost, diff --git a/rest_framework/tests/test_renderers.py b/rest_framework/tests/test_renderers.py index df6f4aa63..9d1dd77e5 100644 --- a/rest_framework/tests/test_renderers.py +++ b/rest_framework/tests/test_renderers.py @@ -2,12 +2,13 @@ from __future__ import unicode_literals from decimal import Decimal +from django.conf.urls import patterns, url, include from django.core.cache import cache from django.test import TestCase from django.utils import unittest from django.utils.translation import ugettext_lazy as _ from rest_framework import status, permissions -from rest_framework.compat import yaml, etree, patterns, url, include, six, StringIO +from rest_framework.compat import yaml, etree, six, StringIO from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.renderers import BaseRenderer, JSONRenderer, YAMLRenderer, \ diff --git a/rest_framework/tests/test_request.py b/rest_framework/tests/test_request.py index 969d8024a..d63634258 100644 --- a/rest_framework/tests/test_request.py +++ b/rest_framework/tests/test_request.py @@ -2,13 +2,13 @@ Tests for content parsing, and form-overloaded content parsing. """ from __future__ import unicode_literals +from django.conf.urls import patterns from django.contrib.auth.models import User from django.contrib.auth import authenticate, login, logout from django.contrib.sessions.middleware import SessionMiddleware from django.test import TestCase from rest_framework import status from rest_framework.authentication import SessionAuthentication -from rest_framework.compat import patterns from rest_framework.parsers import ( BaseParser, FormParser, diff --git a/rest_framework/tests/test_response.py b/rest_framework/tests/test_response.py index eea3c6418..1c4c551cf 100644 --- a/rest_framework/tests/test_response.py +++ b/rest_framework/tests/test_response.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals +from django.conf.urls import patterns, url, include from django.test import TestCase from rest_framework.tests.models import BasicModel, BasicModelSerializer -from rest_framework.compat import patterns, url, include from rest_framework.response import Response from rest_framework.views import APIView from rest_framework import generics diff --git a/rest_framework/tests/test_reverse.py b/rest_framework/tests/test_reverse.py index 690a30b11..320b125d2 100644 --- a/rest_framework/tests/test_reverse.py +++ b/rest_framework/tests/test_reverse.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals +from django.conf.urls import patterns, url from django.test import TestCase -from rest_framework.compat import patterns, url from rest_framework.reverse import reverse from rest_framework.test import APIRequestFactory diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index 3f456fefd..1c34648f4 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals +from django.conf.urls import patterns, url, include from django.db import models from django.test import TestCase from django.core.exceptions import ImproperlyConfigured from rest_framework import serializers, viewsets, permissions -from rest_framework.compat import include, patterns, url from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response from rest_framework.routers import SimpleRouter, DefaultRouter diff --git a/rest_framework/tests/test_testing.py b/rest_framework/tests/test_testing.py index 48b8956b5..c08dd4932 100644 --- a/rest_framework/tests/test_testing.py +++ b/rest_framework/tests/test_testing.py @@ -1,9 +1,9 @@ # -- coding: utf-8 -- from __future__ import unicode_literals +from django.conf.urls import patterns, url from django.contrib.auth.models import User from django.test import TestCase -from rest_framework.compat import patterns, url from rest_framework.decorators import api_view from rest_framework.response import Response from rest_framework.test import APIClient, APIRequestFactory, force_authenticate diff --git a/rest_framework/tests/test_urlpatterns.py b/rest_framework/tests/test_urlpatterns.py index 8132ec4c8..e0060e690 100644 --- a/rest_framework/tests/test_urlpatterns.py +++ b/rest_framework/tests/test_urlpatterns.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals from collections import namedtuple +from django.conf.urls import patterns, url, include from django.core import urlresolvers from django.test import TestCase from rest_framework.test import APIRequestFactory -from rest_framework.compat import patterns, url, include from rest_framework.urlpatterns import format_suffix_patterns diff --git a/rest_framework/urlpatterns.py b/rest_framework/urlpatterns.py index d9143bb4c..a62530c74 100644 --- a/rest_framework/urlpatterns.py +++ b/rest_framework/urlpatterns.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals +from django.conf.urls import url, include from django.core.urlresolvers import RegexURLResolver -from rest_framework.compat import url, include from rest_framework.settings import api_settings diff --git a/rest_framework/urls.py b/rest_framework/urls.py index 9c4719f1d..87ec0f0ae 100644 --- a/rest_framework/urls.py +++ b/rest_framework/urls.py @@ -13,7 +13,7 @@ your authentication settings include `SessionAuthentication`. ) """ from __future__ import unicode_literals -from rest_framework.compat import patterns, url +from django.conf.urls import patterns, url template_name = {'template_name': 'rest_framework/login.html'} diff --git a/rest_framework/utils/encoders.py b/rest_framework/utils/encoders.py index 7efd5417b..13a855509 100644 --- a/rest_framework/utils/encoders.py +++ b/rest_framework/utils/encoders.py @@ -2,9 +2,10 @@ Helper classes for parsers. """ from __future__ import unicode_literals +from django.utils import timezone from django.utils.datastructures import SortedDict from django.utils.functional import Promise -from rest_framework.compat import timezone, force_text +from rest_framework.compat import force_text from rest_framework.serializers import DictWithMetadata, SortedDictWithMetadata import datetime import decimal diff --git a/tox.ini b/tox.ini index 6e3b8e0a8..7bd140e1c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] downloadcache = {toxworkdir}/cache/ -envlist = py3.3-django1.6,py3.2-django1.6,py2.7-django1.6,py2.6-django1.6,py3.3-django1.5,py3.2-django1.5,py2.7-django1.5,py2.6-django1.5,py2.7-django1.4,py2.6-django1.4,py2.7-django1.3,py2.6-django1.3 +envlist = py3.3-django1.6,py3.2-django1.6,py2.7-django1.6,py2.6-django1.6,py3.3-django1.5,py3.2-django1.5,py2.7-django1.5,py2.6-django1.5,py2.7-django1.4,py2.6-django1.4 [testenv] commands = {envpython} rest_framework/runtests/runtests.py @@ -88,23 +88,3 @@ deps = django==1.4.3 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 - -[testenv:py2.7-django1.3] -basepython = python2.7 -deps = django==1.3.5 - django-filter==0.5.4 - defusedxml==0.3 - django-oauth-plus==2.0 - oauth2==1.5.211 - django-oauth2-provider==0.2.3 - django-guardian==1.1.1 - -[testenv:py2.6-django1.3] -basepython = python2.6 -deps = django==1.3.5 - django-filter==0.5.4 - defusedxml==0.3 - django-oauth-plus==2.0 - oauth2==1.5.211 - django-oauth2-provider==0.2.3 - django-guardian==1.1.1 From 1bd8fe415296739521fd2e75c0b604cbf3dd3a83 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 25 Sep 2013 10:36:08 +0100 Subject: [PATCH 18/72] Whitespace fix --- rest_framework/compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index f048b10ad..efd2581fe 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -17,7 +17,7 @@ except ImportError: import six # Handle django.utils.encoding rename in 1.5 onwards. -# smart_unicode -> smart_text +# smart_unicode -> smart_text # force_unicode -> force_text try: from django.utils.encoding import smart_text From ab4be47379ba49092063f843fd446919534db776 Mon Sep 17 00:00:00 2001 From: Omer Katz Date: Thu, 3 Oct 2013 17:34:34 +0200 Subject: [PATCH 19/72] Fixed code example. --- docs/api-guide/routers.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 730fa876a..f20a695b6 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -42,12 +42,15 @@ The example above would generate the following URL patterns: Any methods on the viewset decorated with `@detail_route` or `@list_route` will also be routed. For example, given a method like this on the `UserViewSet` class: - from myapp.permissions import IsAdminOrIsSelf + from myapp.permissions import IsAdminOrIsSelf from rest_framework.decorators import detail_route - - @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf]) - def set_password(self, request, pk=None): + + class UserViewSet(ModelViewSet): ... + + @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf]) + def set_password(self, request, pk=None): + ... The following URL pattern would additionally be generated: From 9ab0759e38492d9950d66299ed5c58155d39e696 Mon Sep 17 00:00:00 2001 From: kahnjw Date: Fri, 6 Dec 2013 14:21:33 -0800 Subject: [PATCH 20/72] Add tests to pass for get_ident method in BaseThrottle class. --- rest_framework/tests/test_throttling.py | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/rest_framework/tests/test_throttling.py b/rest_framework/tests/test_throttling.py index 41bff6926..031276968 100644 --- a/rest_framework/tests/test_throttling.py +++ b/rest_framework/tests/test_throttling.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals from django.test import TestCase from django.contrib.auth.models import User from django.core.cache import cache +from rest_framework.settings import api_settings from rest_framework.test import APIRequestFactory from rest_framework.views import APIView from rest_framework.throttling import BaseThrottle, UserRateThrottle, ScopedRateThrottle @@ -275,3 +276,67 @@ class ScopedRateThrottleTests(TestCase): self.increment_timer() response = self.unscoped_view(request) self.assertEqual(200, response.status_code) + +class XffTestingBase(TestCase): + def setUp(self): + + class Throttle(ScopedRateThrottle): + THROTTLE_RATES = {'test_limit': '1/day'} + TIMER_SECONDS = 0 + timer = lambda self: self.TIMER_SECONDS + + class View(APIView): + throttle_classes = (Throttle,) + throttle_scope = 'test_limit' + + def get(self, request): + return Response('test_limit') + + cache.clear() + self.throttle = Throttle() + self.view = View.as_view() + self.request = APIRequestFactory().get('/some_uri') + self.request.META['REMOTE_ADDR'] = '3.3.3.3' + self.request.META['HTTP_X_FORWARDED_FOR'] = '0.0.0.0, 1.1.1.1, 2.2.2.2' + + def config_proxy(self, num_proxies): + setattr(api_settings, 'NUM_PROXIES', num_proxies) + + +class IdWithXffBasicTests(XffTestingBase): + def test_accepts_request_under_limit(self): + self.config_proxy(0) + self.assertEqual(200, self.view(self.request).status_code) + + def test_denies_request_over_limit(self): + self.config_proxy(0) + self.view(self.request) + self.assertEqual(429, self.view(self.request).status_code) + + +class XffSpoofingTests(XffTestingBase): + def test_xff_spoofing_doesnt_change_machine_id_with_one_app_proxy(self): + self.config_proxy(1) + self.view(self.request) + self.request.META['HTTP_X_FORWARDED_FOR'] = '4.4.4.4, 5.5.5.5, 2.2.2.2' + self.assertEqual(429, self.view(self.request).status_code) + + def test_xff_spoofing_doesnt_change_machine_id_with_two_app_proxies(self): + self.config_proxy(2) + self.view(self.request) + self.request.META['HTTP_X_FORWARDED_FOR'] = '4.4.4.4, 1.1.1.1, 2.2.2.2' + self.assertEqual(429, self.view(self.request).status_code) + + +class XffUniqueMachinesTest(XffTestingBase): + def test_unique_clients_are_counted_independently_with_one_proxy(self): + self.config_proxy(1) + self.view(self.request) + self.request.META['HTTP_X_FORWARDED_FOR'] = '0.0.0.0, 1.1.1.1, 7.7.7.7' + self.assertEqual(200, self.view(self.request).status_code) + + def test_unique_clients_are_counted_independently_with_two_proxies(self): + self.config_proxy(2) + self.view(self.request) + self.request.META['HTTP_X_FORWARDED_FOR'] = '0.0.0.0, 7.7.7.7, 2.2.2.2' + self.assertEqual(200, self.view(self.request).status_code) From 89f26c5e040febd27bc9142b0096ca119bb3fa32 Mon Sep 17 00:00:00 2001 From: kahnjw Date: Fri, 6 Dec 2013 14:21:52 -0800 Subject: [PATCH 21/72] Add get_ident method to pass new tests. --- rest_framework/settings.py | 1 + rest_framework/throttling.py | 25 ++++++++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 8abaf1409..383de72ea 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -63,6 +63,7 @@ DEFAULTS = { 'user': None, 'anon': None, }, + 'NUM_PROXIES': None, # Pagination 'PAGINATE_BY': None, diff --git a/rest_framework/throttling.py b/rest_framework/throttling.py index a946d837f..60e46d479 100644 --- a/rest_framework/throttling.py +++ b/rest_framework/throttling.py @@ -18,6 +18,21 @@ class BaseThrottle(object): """ raise NotImplementedError('.allow_request() must be overridden') + def get_ident(self, request): + """ + Identify the machine making the request by parsing HTTP_X_FORWARDED_FOR + if present and number of proxies is > 0. If not use all of + HTTP_X_FORWARDED_FOR if it is available, if not use REMOTE_ADDR. + """ + xff = request.META.get('HTTP_X_FORWARDED_FOR') + remote_addr = request.META.get('REMOTE_ADDR') + num_proxies = api_settings.NUM_PROXIES + + if xff and num_proxies: + return xff.split(',')[-min(num_proxies, len(xff))].strip() + + return xff if xff else remote_addr + def wait(self): """ Optionally, return a recommended number of seconds to wait before @@ -152,13 +167,9 @@ class AnonRateThrottle(SimpleRateThrottle): if request.user.is_authenticated(): return None # Only throttle unauthenticated requests. - ident = request.META.get('HTTP_X_FORWARDED_FOR') - if ident is None: - ident = request.META.get('REMOTE_ADDR') - return self.cache_format % { 'scope': self.scope, - 'ident': ident + 'ident': self.get_ident(request) } @@ -176,7 +187,7 @@ class UserRateThrottle(SimpleRateThrottle): if request.user.is_authenticated(): ident = request.user.id else: - ident = request.META.get('REMOTE_ADDR', None) + ident = self.get_ident(request) return self.cache_format % { 'scope': self.scope, @@ -224,7 +235,7 @@ class ScopedRateThrottle(SimpleRateThrottle): if request.user.is_authenticated(): ident = request.user.id else: - ident = request.META.get('REMOTE_ADDR', None) + ident = self.get_ident(request) return self.cache_format % { 'scope': self.scope, From 100a933279e3119e2627d744cd7eb472b542f6fe Mon Sep 17 00:00:00 2001 From: kahnjw Date: Fri, 6 Dec 2013 14:22:08 -0800 Subject: [PATCH 22/72] Add documentation to explain what effect these changes have. --- docs/api-guide/throttling.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index cc4692171..ee57383cd 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -35,11 +35,16 @@ The default throttling policy may be set globally, using the `DEFAULT_THROTTLE_C 'DEFAULT_THROTTLE_RATES': { 'anon': '100/day', 'user': '1000/day' - } + }, + 'NUM_PROXIES': 2, } The rate descriptions used in `DEFAULT_THROTTLE_RATES` may include `second`, `minute`, `hour` or `day` as the throttle period. +By default Django REST Framework will try to use the `HTTP_X_FORWARDED_FOR` header to uniquely identify client machines for throttling. If HTTP_X_FORWARDED_FOR is not present `REMOTE_ADDR` header value will be used. + +To help Django REST Framework identify unique clients the number of application proxies can be set using `NUM_PROXIES`. This setting will allow the throttle to correctly identify unique requests whenthere are multiple application side proxies in front of the server. `NUM_PROXIES` should be set to an integer. It is important to understand that if you configure `NUM_PROXIES > 0` all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. + You can also set the throttling policy on a per-view or per-viewset basis, using the `APIView` class based views. From 196c5952e4f610054e832aef36cb2383b8c129c0 Mon Sep 17 00:00:00 2001 From: kahnjw Date: Fri, 6 Dec 2013 14:24:16 -0800 Subject: [PATCH 23/72] Fix typo --- docs/api-guide/throttling.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index ee57383cd..69b15a829 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -43,7 +43,7 @@ The rate descriptions used in `DEFAULT_THROTTLE_RATES` may include `second`, `mi By default Django REST Framework will try to use the `HTTP_X_FORWARDED_FOR` header to uniquely identify client machines for throttling. If HTTP_X_FORWARDED_FOR is not present `REMOTE_ADDR` header value will be used. -To help Django REST Framework identify unique clients the number of application proxies can be set using `NUM_PROXIES`. This setting will allow the throttle to correctly identify unique requests whenthere are multiple application side proxies in front of the server. `NUM_PROXIES` should be set to an integer. It is important to understand that if you configure `NUM_PROXIES > 0` all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. +To help Django REST Framework identify unique clients the number of application proxies can be set using `NUM_PROXIES`. This setting will allow the throttle to correctly identify unique requests when there are multiple application side proxies in front of the server. `NUM_PROXIES` should be set to an integer. It is important to understand that if you configure `NUM_PROXIES > 0` all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. You can also set the throttling policy on a per-view or per-viewset basis, using the `APIView` class based views. From 887da7f6c5a9e7b5007f5e4af32a6b93b18c70ea Mon Sep 17 00:00:00 2001 From: kahnjw Date: Fri, 6 Dec 2013 14:30:33 -0800 Subject: [PATCH 24/72] Add missing tick marks --- docs/api-guide/throttling.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index 69b15a829..34418e84d 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -41,7 +41,7 @@ The default throttling policy may be set globally, using the `DEFAULT_THROTTLE_C The rate descriptions used in `DEFAULT_THROTTLE_RATES` may include `second`, `minute`, `hour` or `day` as the throttle period. -By default Django REST Framework will try to use the `HTTP_X_FORWARDED_FOR` header to uniquely identify client machines for throttling. If HTTP_X_FORWARDED_FOR is not present `REMOTE_ADDR` header value will be used. +By default Django REST Framework will try to use the `HTTP_X_FORWARDED_FOR` header to uniquely identify client machines for throttling. If `HTTP_X_FORWARDED_FOR` is not present `REMOTE_ADDR` header value will be used. To help Django REST Framework identify unique clients the number of application proxies can be set using `NUM_PROXIES`. This setting will allow the throttle to correctly identify unique requests when there are multiple application side proxies in front of the server. `NUM_PROXIES` should be set to an integer. It is important to understand that if you configure `NUM_PROXIES > 0` all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. From 23db6c98495d7b3c18a3069c6cb770d5cbc18ee1 Mon Sep 17 00:00:00 2001 From: kahnjw Date: Fri, 6 Dec 2013 14:52:39 -0800 Subject: [PATCH 25/72] PEP8 Compliance --- rest_framework/tests/test_throttling.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rest_framework/tests/test_throttling.py b/rest_framework/tests/test_throttling.py index 031276968..8c5eefe9c 100644 --- a/rest_framework/tests/test_throttling.py +++ b/rest_framework/tests/test_throttling.py @@ -277,6 +277,7 @@ class ScopedRateThrottleTests(TestCase): response = self.unscoped_view(request) self.assertEqual(200, response.status_code) + class XffTestingBase(TestCase): def setUp(self): From 83da4949c099fcf7e7636c98b9052b502e1bf74b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 13 Dec 2013 00:02:18 +0000 Subject: [PATCH 26/72] Allow NUM_PROXIES=0 and include more docs --- docs/api-guide/settings.md | 6 ++++++ docs/api-guide/throttling.md | 18 ++++++++++++------ rest_framework/throttling.py | 8 ++++++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/api-guide/settings.md b/docs/api-guide/settings.md index 13f96f9a3..d8c878ff8 100644 --- a/docs/api-guide/settings.md +++ b/docs/api-guide/settings.md @@ -359,5 +359,11 @@ The name of a parameter in the URL conf that may be used to provide a format suf Default: `'format'` +#### NUM_PROXIES + +An integer of 0 or more, that may be used to specify the number of application proxies that the API runs behind. This allows throttling to more accurately identify client IP addresses. If set to `None` then less strict IP matching will be used by the throttle classes. + +Default: `None` + [cite]: http://www.python.org/dev/peps/pep-0020/ [strftime]: http://docs.python.org/2/library/time.html#time.strftime diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index 34418e84d..b2a5bb194 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -35,16 +35,11 @@ The default throttling policy may be set globally, using the `DEFAULT_THROTTLE_C 'DEFAULT_THROTTLE_RATES': { 'anon': '100/day', 'user': '1000/day' - }, - 'NUM_PROXIES': 2, + } } The rate descriptions used in `DEFAULT_THROTTLE_RATES` may include `second`, `minute`, `hour` or `day` as the throttle period. -By default Django REST Framework will try to use the `HTTP_X_FORWARDED_FOR` header to uniquely identify client machines for throttling. If `HTTP_X_FORWARDED_FOR` is not present `REMOTE_ADDR` header value will be used. - -To help Django REST Framework identify unique clients the number of application proxies can be set using `NUM_PROXIES`. This setting will allow the throttle to correctly identify unique requests when there are multiple application side proxies in front of the server. `NUM_PROXIES` should be set to an integer. It is important to understand that if you configure `NUM_PROXIES > 0` all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. - You can also set the throttling policy on a per-view or per-viewset basis, using the `APIView` class based views. @@ -71,6 +66,16 @@ Or, if you're using the `@api_view` decorator with function based views. } return Response(content) +## How clients are identified + +By default the `X-Forwarded-For` HTTP header is used to uniquely identify client machines for throttling. If the `X-Forwarded-For` header is not present, then the value of the `Remote-Addr` header will be used. + +If you need to more strictly identify unique clients, you'll need to configure the number of application proxies that the API runs behind by setting the `NUM_PROXIES` setting. This setting should be an integer of 0 or more, and will allow the throttle to identify the client IP as being the last IP address in the `X-Forwarded-For` header, once any application proxy IP addresses have first been excluded. + +It is important to understand that if you configure the `NUM_PROXIES` setting, then all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. + +Further context on how the `X-Forwarded-For` header works, and identifier a remote client IP can be [found here][identifing-clients]. + ## Setting up the cache The throttle classes provided by REST framework use Django's cache backend. You should make sure that you've set appropriate [cache settings][cache-setting]. The default value of `LocMemCache` backend should be okay for simple setups. See Django's [cache documentation][cache-docs] for more details. @@ -183,5 +188,6 @@ The following is an example of a rate throttle, that will randomly throttle 1 in [cite]: https://dev.twitter.com/docs/error-codes-responses [permissions]: permissions.md +[identifing-clients]: http://oxpedia.org/wiki/index.php?title=AppSuite:Grizzly#Multiple_Proxies_in_front_of_the_cluster [cache-setting]: https://docs.djangoproject.com/en/dev/ref/settings/#caches [cache-docs]: https://docs.djangoproject.com/en/dev/topics/cache/#setting-up-the-cache diff --git a/rest_framework/throttling.py b/rest_framework/throttling.py index 60e46d479..c40f3065b 100644 --- a/rest_framework/throttling.py +++ b/rest_framework/throttling.py @@ -28,8 +28,12 @@ class BaseThrottle(object): remote_addr = request.META.get('REMOTE_ADDR') num_proxies = api_settings.NUM_PROXIES - if xff and num_proxies: - return xff.split(',')[-min(num_proxies, len(xff))].strip() + if num_proxies is not None: + if num_proxies == 0 or xff is None: + return remote_addr + addrs = xff.split(',') + client_addr = addrs[-min(num_proxies, len(xff))] + return client_addr.strip() return xff if xff else remote_addr From ed931b90ae9e72f963673e6e188b1802a5a65360 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 13 Dec 2013 00:11:59 +0000 Subject: [PATCH 27/72] Further docs tweaks --- docs/api-guide/throttling.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index b2a5bb194..536f0ab78 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -68,13 +68,13 @@ Or, if you're using the `@api_view` decorator with function based views. ## How clients are identified -By default the `X-Forwarded-For` HTTP header is used to uniquely identify client machines for throttling. If the `X-Forwarded-For` header is not present, then the value of the `Remote-Addr` header will be used. +The `X-Forwarded-For` and `Remote-Addr` HTTP headers are used to uniquely identify client IP addresses for throttling. If the `X-Forwarded-For` header is present then it will be used, otherwise the value of the `Remote-Addr` header will be used. -If you need to more strictly identify unique clients, you'll need to configure the number of application proxies that the API runs behind by setting the `NUM_PROXIES` setting. This setting should be an integer of 0 or more, and will allow the throttle to identify the client IP as being the last IP address in the `X-Forwarded-For` header, once any application proxy IP addresses have first been excluded. +If you need to strictly identify unique client IP addresses, you'll need to first configure the number of application proxies that the API runs behind by setting the `NUM_PROXIES` setting. This setting should be an integer of zero or more. If set to non-zero then the client IP will be identified as being the last IP address in the `X-Forwarded-For` header, once any application proxy IP addresses have first been excluded. If set to zero, then the `Remote-Addr` header will always be used as the identifying IP address. It is important to understand that if you configure the `NUM_PROXIES` setting, then all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. -Further context on how the `X-Forwarded-For` header works, and identifier a remote client IP can be [found here][identifing-clients]. +Further context on how the `X-Forwarded-For` header works, and identifing a remote client IP can be [found here][identifing-clients]. ## Setting up the cache From a1d7aa8f712b659f9d8302a2d2a098d2538e6c89 Mon Sep 17 00:00:00 2001 From: Paul Melnikow Date: Thu, 2 Jan 2014 17:44:47 -0500 Subject: [PATCH 28/72] Allow viewset to specify lookup value regex for routing This patch allows a viewset to define a pattern for its lookup field, which the router will honor. Without this patch, any characters are allowed in the lookup field, and overriding this behavior requires subclassing router and copying and pasting the implementation of get_lookup_regex. It's possible it would be better to remove this functionality from the routers and simply expose a parameter to get_lookup_regex which allows overriding the lookup_regex. That way the viewset config logic could be in the a subclass, which could invoke the super method directly. I'm using this now for PostgreSQL UUID fields using https://github.com/dcramer/django-uuidfield . Without this patch, that field passes the lookup string to the database driver, which raises a DataError to complain about the invalid UUID. It's possible the field ought to signal this error in a different way, which could obviate the need to specify a pattern. --- docs/api-guide/routers.md | 6 ++++++ rest_framework/routers.py | 20 ++++++++++++++------ rest_framework/tests/test_routers.py | 21 +++++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 846ac9f9d..f3beabdd3 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -83,6 +83,12 @@ This behavior can be modified by setting the `trailing_slash` argument to `False Trailing slashes are conventional in Django, but are not used by default in some other frameworks such as Rails. Which style you choose to use is largely a matter of preference, although some javascript frameworks may expect a particular routing style. +With `trailing_slash` set to True, the router will match lookup values containing any characters except slashes and dots. When set to False, dots are allowed. To restrict the lookup pattern, set the `lookup_field_regex` attribute on the viewset. For example, you can limit the lookup to valid UUIDs: + + class MyModelViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): + lookup_field = 'my_model_id' + lookup_value_regex = '[0-9a-f]{32}' + ## DefaultRouter This router is similar to `SimpleRouter` as above, but additionally includes a default API root view, that returns a response containing hyperlinks to all the list views. It also generates routes for optional `.json` style format suffixes. diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 740d58f0d..8766ecb2f 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -219,13 +219,21 @@ class SimpleRouter(BaseRouter): https://github.com/alanjds/drf-nested-routers """ - if self.trailing_slash: - base_regex = '(?P<{lookup_prefix}{lookup_field}>[^/]+)' - else: - # Don't consume `.json` style suffixes - base_regex = '(?P<{lookup_prefix}{lookup_field}>[^/.]+)' + base_regex = '(?P<{lookup_prefix}{lookup_field}>{lookup_value})' lookup_field = getattr(viewset, 'lookup_field', 'pk') - return base_regex.format(lookup_field=lookup_field, lookup_prefix=lookup_prefix) + try: + lookup_value = viewset.lookup_value_regex + except AttributeError: + if self.trailing_slash: + lookup_value = '[^/]+' + else: + # Don't consume `.json` style suffixes + lookup_value = '[^/.]+' + return base_regex.format( + lookup_prefix=lookup_prefix, + lookup_field=lookup_field, + lookup_value=lookup_value + ) def get_urls(self): """ diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index 1c34648f4..0f6d62c7c 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -121,6 +121,27 @@ class TestCustomLookupFields(TestCase): ) +class TestLookupValueRegex(TestCase): + """ + Ensure the router honors lookup_value_regex when applied + to the viewset. + """ + def setUp(self): + class NoteViewSet(viewsets.ModelViewSet): + queryset = RouterTestModel.objects.all() + lookup_field = 'uuid' + lookup_value_regex = '[0-9a-f]{32}' + + self.router = SimpleRouter() + self.router.register(r'notes', NoteViewSet) + self.urls = self.router.urls + + def test_urls_limited_by_lookup_value_regex(self): + expected = ['^notes/$', '^notes/(?P[0-9a-f]{32})/$'] + for idx in range(len(expected)): + self.assertEqual(expected[idx], self.urls[idx].regex.pattern) + + class TestTrailingSlashIncluded(TestCase): def setUp(self): class NoteViewSet(viewsets.ModelViewSet): From 3cd15fb1713dfc49e1bf1fd48045ca3ae5654e18 Mon Sep 17 00:00:00 2001 From: Paul Melnikow Date: Sat, 4 Jan 2014 16:57:50 -0500 Subject: [PATCH 29/72] Router: Do not automatically adjust lookup_regex when trailing_slash is True BREAKING CHANGE When trailing_slash is set to True, the router no longer will adjust the lookup regex to allow it to include periods. To simulate the old behavior, the programmer should specify `lookup_regex = '[^/]+'` on the viewset. https://github.com/tomchristie/django-rest-framework/pull/1328#issuecomment-31517099 --- docs/api-guide/routers.md | 2 +- rest_framework/routers.py | 7 ++----- rest_framework/tests/test_routers.py | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index f3beabdd3..6b4ae6dbd 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -83,7 +83,7 @@ This behavior can be modified by setting the `trailing_slash` argument to `False Trailing slashes are conventional in Django, but are not used by default in some other frameworks such as Rails. Which style you choose to use is largely a matter of preference, although some javascript frameworks may expect a particular routing style. -With `trailing_slash` set to True, the router will match lookup values containing any characters except slashes and dots. When set to False, dots are allowed. To restrict the lookup pattern, set the `lookup_field_regex` attribute on the viewset. For example, you can limit the lookup to valid UUIDs: +The router will match lookup values containing any characters except slashes and period characters. For a more restrictive (or lenient) lookup pattern, set the `lookup_field_regex` attribute on the viewset. For example, you can limit the lookup to valid UUIDs: class MyModelViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): lookup_field = 'my_model_id' diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 8766ecb2f..df1233fdd 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -224,11 +224,8 @@ class SimpleRouter(BaseRouter): try: lookup_value = viewset.lookup_value_regex except AttributeError: - if self.trailing_slash: - lookup_value = '[^/]+' - else: - # Don't consume `.json` style suffixes - lookup_value = '[^/.]+' + # Don't consume `.json` style suffixes + lookup_value = '[^/.]+' return base_regex.format( lookup_prefix=lookup_prefix, lookup_field=lookup_field, diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index 0f6d62c7c..e41da57f5 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -152,7 +152,7 @@ class TestTrailingSlashIncluded(TestCase): self.urls = self.router.urls def test_urls_have_trailing_slash_by_default(self): - expected = ['^notes/$', '^notes/(?P[^/]+)/$'] + expected = ['^notes/$', '^notes/(?P[^/.]+)/$'] for idx in range(len(expected)): self.assertEqual(expected[idx], self.urls[idx].regex.pattern) From 899381575a6038f550a064261ed5c6ba0655211b Mon Sep 17 00:00:00 2001 From: Paul Melnikow Date: Sat, 4 Jan 2014 17:03:01 -0500 Subject: [PATCH 30/72] Fix a typo --- docs/api-guide/routers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 6b4ae6dbd..249e99a48 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -83,7 +83,7 @@ This behavior can be modified by setting the `trailing_slash` argument to `False Trailing slashes are conventional in Django, but are not used by default in some other frameworks such as Rails. Which style you choose to use is largely a matter of preference, although some javascript frameworks may expect a particular routing style. -The router will match lookup values containing any characters except slashes and period characters. For a more restrictive (or lenient) lookup pattern, set the `lookup_field_regex` attribute on the viewset. For example, you can limit the lookup to valid UUIDs: +The router will match lookup values containing any characters except slashes and period characters. For a more restrictive (or lenient) lookup pattern, set the `lookup_value_regex` attribute on the viewset. For example, you can limit the lookup to valid UUIDs: class MyModelViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): lookup_field = 'my_model_id' From 46f5c62530744017f744cdcfec91774a0566c179 Mon Sep 17 00:00:00 2001 From: Yuri Prezument Date: Sun, 5 Jan 2014 15:16:55 +0200 Subject: [PATCH 31/72] Regression test for #1330 (Coerce None to '') --- rest_framework/tests/test_serializer.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/rest_framework/tests/test_serializer.py b/rest_framework/tests/test_serializer.py index 86f365dee..8c2c09cf8 100644 --- a/rest_framework/tests/test_serializer.py +++ b/rest_framework/tests/test_serializer.py @@ -1124,6 +1124,20 @@ class BlankFieldTests(TestCase): serializer = self.model_serializer_class(data={}) self.assertEqual(serializer.is_valid(), True) + def test_create_model_null_field_save(self): + """ + Regression test for #1330. + + https://github.com/tomchristie/django-rest-framework/pull/1330 + """ + serializer = self.model_serializer_class(data={'title': None}) + self.assertEqual(serializer.is_valid(), True) + + try: + serializer.save() + except Exception: + self.fail('Exception raised on save() after validation passes') + #test for issue #460 class SerializerPickleTests(TestCase): From e88e3c6ae163029f0fe564dd214235ab350dbfc9 Mon Sep 17 00:00:00 2001 From: Yuri Prezument Date: Sun, 5 Jan 2014 15:25:16 +0200 Subject: [PATCH 32/72] Possible fix for #1330 Coerce None to '' in CharField.to_native() --- rest_framework/fields.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 5ee752351..22f0120b7 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -452,7 +452,9 @@ class CharField(WritableField): self.validators.append(validators.MaxLengthValidator(max_length)) def from_native(self, value): - if isinstance(value, six.string_types) or value is None: + if value is None: + return '' + if isinstance(value, six.string_types): return value return smart_text(value) From 6e622d644c9b55b905e24497f0fb818d557fd970 Mon Sep 17 00:00:00 2001 From: Yuri Prezument Date: Sun, 5 Jan 2014 15:58:46 +0200 Subject: [PATCH 33/72] CharField - add allow_null argument --- docs/api-guide/fields.md | 7 ++++--- rest_framework/fields.py | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index e05c03061..83825350e 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -157,23 +157,24 @@ Corresponds to `django.db.models.fields.BooleanField`. ## CharField A text representation, optionally validates the text to be shorter than `max_length` and longer than `min_length`. +If `allow_none` is `False` (default), `None` values will be converted to an empty string. Corresponds to `django.db.models.fields.CharField` or `django.db.models.fields.TextField`. -**Signature:** `CharField(max_length=None, min_length=None)` +**Signature:** `CharField(max_length=None, min_length=None, allow_none=False)` ## URLField Corresponds to `django.db.models.fields.URLField`. Uses Django's `django.core.validators.URLValidator` for validation. -**Signature:** `CharField(max_length=200, min_length=None)` +**Signature:** `CharField(max_length=200, min_length=None, allow_none=False)` ## SlugField Corresponds to `django.db.models.fields.SlugField`. -**Signature:** `CharField(max_length=50, min_length=None)` +**Signature:** `CharField(max_length=50, min_length=None, allow_none=False)` ## ChoiceField diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 22f0120b7..16485b414 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -443,8 +443,9 @@ class CharField(WritableField): type_label = 'string' form_field_class = forms.CharField - def __init__(self, max_length=None, min_length=None, *args, **kwargs): + def __init__(self, max_length=None, min_length=None, allow_none=False, *args, **kwargs): self.max_length, self.min_length = max_length, min_length + self.allow_none = allow_none super(CharField, self).__init__(*args, **kwargs) if min_length is not None: self.validators.append(validators.MinLengthValidator(min_length)) @@ -452,7 +453,7 @@ class CharField(WritableField): self.validators.append(validators.MaxLengthValidator(max_length)) def from_native(self, value): - if value is None: + if value is None and not self.allow_none: return '' if isinstance(value, six.string_types): return value From e1bbe9d514c95aba596cff64292eb0f0bc7d99fa Mon Sep 17 00:00:00 2001 From: Yuri Prezument Date: Mon, 6 Jan 2014 13:56:57 +0200 Subject: [PATCH 34/72] Set `allow_none = True` for CharFields with null=True --- rest_framework/serializers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index fa9353061..0164965cd 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -821,10 +821,15 @@ class ModelSerializer(Serializer): kwargs.update({attribute: getattr(model_field, attribute)}) try: - return self.field_mapping[model_field.__class__](**kwargs) + field_class = self.field_mapping[model_field.__class__] except KeyError: return ModelField(model_field=model_field, **kwargs) + if issubclass(field_class, CharField) and model_field.null: + kwargs['allow_none'] = True + + return field_class(**kwargs) + def get_validation_exclusions(self): """ Return a list of field names to exclude from model validation. From 0fd0454a5c1ddcf8676e23b30dfaee40fa7cb0c8 Mon Sep 17 00:00:00 2001 From: Yuri Prezument Date: Mon, 6 Jan 2014 14:02:00 +0200 Subject: [PATCH 35/72] Test for setting allow_none=True for nullable CharFields --- rest_framework/tests/test_serializer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rest_framework/tests/test_serializer.py b/rest_framework/tests/test_serializer.py index 8c2c09cf8..6d9b85ee0 100644 --- a/rest_framework/tests/test_serializer.py +++ b/rest_framework/tests/test_serializer.py @@ -1504,6 +1504,7 @@ class AttributeMappingOnAutogeneratedFieldsTests(TestCase): image_field = models.ImageField(max_length=1024, blank=True) slug_field = models.SlugField(max_length=1024, blank=True) url_field = models.URLField(max_length=1024, blank=True) + nullable_char_field = models.CharField(max_length=1024, blank=True, null=True) class AMOAFSerializer(serializers.ModelSerializer): class Meta: @@ -1536,6 +1537,10 @@ class AttributeMappingOnAutogeneratedFieldsTests(TestCase): 'url_field': [ ('max_length', 1024), ], + 'nullable_char_field': [ + ('max_length', 1024), + ('allow_none', True), + ], } def field_test(self, field): @@ -1572,6 +1577,9 @@ class AttributeMappingOnAutogeneratedFieldsTests(TestCase): def test_url_field(self): self.field_test('url_field') + def test_nullable_char_field(self): + self.field_test('nullable_char_field') + class DefaultValuesOnAutogeneratedFieldsTests(TestCase): From cd9a4194ea4f4dc0e43a34485cd8a27eba44a39a Mon Sep 17 00:00:00 2001 From: Yuri Prezument Date: Sun, 12 Jan 2014 16:30:26 +0200 Subject: [PATCH 36/72] Check the modelfield's class instead --- rest_framework/serializers.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 0164965cd..cbf73fc30 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -804,6 +804,10 @@ class ModelSerializer(Serializer): issubclass(model_field.__class__, models.PositiveSmallIntegerField): kwargs['min_value'] = 0 + if model_field.null and \ + issubclass(model_field.__class__, (models.CharField, models.TextField)): + kwargs['allow_none'] = True + attribute_dict = { models.CharField: ['max_length'], models.CommaSeparatedIntegerField: ['max_length'], @@ -821,15 +825,10 @@ class ModelSerializer(Serializer): kwargs.update({attribute: getattr(model_field, attribute)}) try: - field_class = self.field_mapping[model_field.__class__] + return self.field_mapping[model_field.__class__](**kwargs) except KeyError: return ModelField(model_field=model_field, **kwargs) - if issubclass(field_class, CharField) and model_field.null: - kwargs['allow_none'] = True - - return field_class(**kwargs) - def get_validation_exclusions(self): """ Return a list of field names to exclude from model validation. From a90796c0f0d9db1a7d9bfaca8fbdfed22435c628 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 13 Jan 2014 09:56:57 +0000 Subject: [PATCH 37/72] Track changes that need noting in 2.4 announcement --- docs/topics/2.4-accouncement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/2.4-accouncement.md b/docs/topics/2.4-accouncement.md index a5425d54e..0cf50ce90 100644 --- a/docs/topics/2.4-accouncement.md +++ b/docs/topics/2.4-accouncement.md @@ -1,4 +1,4 @@ * Writable nested serializers. * List/detail routes. * 1.3 Support dropped, install six for <=1.4.?. -* Note title ordering changed \ No newline at end of file +* `allow_none` for char fields From 2911cd64ad67ba193e3d37322ee71692cb482623 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 13 Jan 2014 15:37:52 +0000 Subject: [PATCH 38/72] Minor tweaks to 'lookup_value_regex' work --- docs/topics/2.4-accouncement.md | 1 + rest_framework/routers.py | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/topics/2.4-accouncement.md b/docs/topics/2.4-accouncement.md index 0cf50ce90..91472b9c4 100644 --- a/docs/topics/2.4-accouncement.md +++ b/docs/topics/2.4-accouncement.md @@ -2,3 +2,4 @@ * List/detail routes. * 1.3 Support dropped, install six for <=1.4.?. * `allow_none` for char fields +* `trailing_slash = True` --> `[^/]`, `trailing_slash = False` --> `[^/.]`, becomes simply `[^/]` and `lookup_value_regex` is added. diff --git a/rest_framework/routers.py b/rest_framework/routers.py index df1233fdd..406ebcf77 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -220,12 +220,10 @@ class SimpleRouter(BaseRouter): https://github.com/alanjds/drf-nested-routers """ base_regex = '(?P<{lookup_prefix}{lookup_field}>{lookup_value})' + # Use `pk` as default field, unset set. Default regex should not + # consume `.json` style suffixes and should break at '/' boundaries. lookup_field = getattr(viewset, 'lookup_field', 'pk') - try: - lookup_value = viewset.lookup_value_regex - except AttributeError: - # Don't consume `.json` style suffixes - lookup_value = '[^/.]+' + lookup_value = getattr(viewset, 'lookup_value_regex', '[^/.]+') return base_regex.format( lookup_prefix=lookup_prefix, lookup_field=lookup_field, From 971578ca345c3d3bae7fd93b87c41d43483b6f05 Mon Sep 17 00:00:00 2001 From: Andreas Pelme Date: Sun, 2 Mar 2014 12:40:30 +0100 Subject: [PATCH 39/72] Support for running the test suite with py.test * Get rid of runtests.py * Moved test code from rest_framework/tests and rest_framework/runtests to tests * Invoke py.test from setup.py * Invoke py.test from Travis * Invoke py.test from tox * Changed setUpClass to be just plain setUp in test_permissions.py * Updated contribution guideline to show how to invoke py.test --- .travis.yml | 3 +- CONTRIBUTING.md | 2 +- conftest.py | 85 +++++++++++++++++++ docs/index.md | 16 +--- docs/topics/contributing.md | 2 +- pytest.ini | 2 + requirements.txt | 2 + rest_framework/runtests/runcoverage.py | 78 ----------------- rest_framework/runtests/runtests.py | 48 ----------- rest_framework/runtests/urls.py | 7 -- rest_framework/tests/tests.py | 16 ---- rest_framework/tests/users/__init__.py | 0 setup.py | 17 +++- .../runtests => tests}/__init__.py | 0 .../tests => tests/accounts}/__init__.py | 0 .../tests => tests}/accounts/models.py | 2 +- .../tests => tests}/accounts/serializers.py | 4 +- .../tests => tests}/description.py | 0 .../accounts => tests/extras}/__init__.py | 0 .../tests => tests}/extras/bad_import.py | 0 {rest_framework/tests => tests}/models.py | 0 .../extras => tests/records}/__init__.py | 0 .../tests => tests}/records/models.py | 0 .../tests => tests}/serializers.py | 2 +- .../runtests => tests}/settings.py | 10 +-- .../tests => tests}/test_authentication.py | 10 +-- .../tests => tests}/test_breadcrumbs.py | 2 +- .../tests => tests}/test_decorators.py | 0 .../tests => tests}/test_description.py | 4 +- .../tests => tests}/test_fields.py | 2 +- {rest_framework/tests => tests}/test_files.py | 0 .../tests => tests}/test_filters.py | 6 +- .../tests => tests}/test_genericrelations.py | 0 .../tests => tests}/test_generics.py | 2 +- .../tests => tests}/test_htmlrenderer.py | 4 +- .../test_hyperlinkedserializers.py | 18 ++-- .../test_multitable_inheritance.py | 2 +- .../tests => tests}/test_negotiation.py | 0 .../tests => tests}/test_nullable_fields.py | 8 +- .../tests => tests}/test_pagination.py | 2 +- .../tests => tests}/test_parsers.py | 0 .../tests => tests}/test_permissions.py | 15 +--- .../tests => tests}/test_relations.py | 4 +- .../test_relations_hyperlink.py | 12 +-- .../tests => tests}/test_relations_nested.py | 0 .../tests => tests}/test_relations_pk.py | 2 +- .../tests => tests}/test_relations_slug.py | 2 +- .../tests => tests}/test_renderers.py | 6 +- .../tests => tests}/test_request.py | 2 +- .../tests => tests}/test_response.py | 10 +-- .../tests => tests}/test_reverse.py | 2 +- .../tests => tests}/test_routers.py | 4 +- .../tests => tests}/test_serializer.py | 6 +- .../test_serializer_bulk_update.py | 0 .../tests => tests}/test_serializer_empty.py | 0 .../tests => tests}/test_serializer_import.py | 2 +- .../tests => tests}/test_serializer_nested.py | 0 .../tests => tests}/test_serializers.py | 2 +- .../tests => tests}/test_settings.py | 4 +- .../tests => tests}/test_status.py | 0 .../tests => tests}/test_templatetags.py | 0 .../tests => tests}/test_testing.py | 2 +- .../tests => tests}/test_throttling.py | 0 .../tests => tests}/test_urlpatterns.py | 0 .../tests => tests}/test_validation.py | 0 {rest_framework/tests => tests}/test_views.py | 0 .../tests => tests}/test_write_only_fields.py | 0 tests/urls.py | 6 ++ .../tests/records => tests/users}/__init__.py | 0 .../tests => tests}/users/models.py | 0 .../tests => tests}/users/serializers.py | 2 +- {rest_framework/tests => tests}/views.py | 4 +- tox.ini | 14 ++- 73 files changed, 206 insertions(+), 251 deletions(-) create mode 100644 conftest.py create mode 100644 pytest.ini delete mode 100755 rest_framework/runtests/runcoverage.py delete mode 100755 rest_framework/runtests/runtests.py delete mode 100644 rest_framework/runtests/urls.py delete mode 100644 rest_framework/tests/tests.py delete mode 100644 rest_framework/tests/users/__init__.py rename {rest_framework/runtests => tests}/__init__.py (100%) rename {rest_framework/tests => tests/accounts}/__init__.py (100%) rename {rest_framework/tests => tests}/accounts/models.py (81%) rename {rest_framework/tests => tests}/accounts/serializers.py (58%) rename {rest_framework/tests => tests}/description.py (100%) rename {rest_framework/tests/accounts => tests/extras}/__init__.py (100%) rename {rest_framework/tests => tests}/extras/bad_import.py (100%) rename {rest_framework/tests => tests}/models.py (100%) rename {rest_framework/tests/extras => tests/records}/__init__.py (100%) rename {rest_framework/tests => tests}/records/models.py (100%) rename {rest_framework/tests => tests}/serializers.py (71%) rename {rest_framework/runtests => tests}/settings.py (96%) rename {rest_framework/tests => tests}/test_authentication.py (99%) rename {rest_framework/tests => tests}/test_breadcrumbs.py (98%) rename {rest_framework/tests => tests}/test_decorators.py (100%) rename {rest_framework/tests => tests}/test_description.py (94%) rename {rest_framework/tests => tests}/test_fields.py (99%) rename {rest_framework/tests => tests}/test_files.py (100%) rename {rest_framework/tests => tests}/test_filters.py (99%) rename {rest_framework/tests => tests}/test_genericrelations.py (100%) rename {rest_framework/tests => tests}/test_generics.py (99%) rename {rest_framework/tests => tests}/test_htmlrenderer.py (97%) rename {rest_framework/tests => tests}/test_hyperlinkedserializers.py (95%) rename {rest_framework/tests => tests}/test_multitable_inheritance.py (97%) rename {rest_framework/tests => tests}/test_negotiation.py (100%) rename {rest_framework/tests => tests}/test_nullable_fields.py (76%) rename {rest_framework/tests => tests}/test_pagination.py (99%) rename {rest_framework/tests => tests}/test_parsers.py (100%) rename {rest_framework/tests => tests}/test_permissions.py (98%) rename {rest_framework/tests => tests}/test_relations.py (97%) rename {rest_framework/tests => tests}/test_relations_hyperlink.py (98%) rename {rest_framework/tests => tests}/test_relations_nested.py (100%) rename {rest_framework/tests => tests}/test_relations_pk.py (99%) rename {rest_framework/tests => tests}/test_relations_slug.py (99%) rename {rest_framework/tests => tests}/test_renderers.py (99%) rename {rest_framework/tests => tests}/test_request.py (99%) rename {rest_framework/tests => tests}/test_response.py (97%) rename {rest_framework/tests => tests}/test_reverse.py (93%) rename {rest_framework/tests => tests}/test_routers.py (98%) rename {rest_framework/tests => tests}/test_serializer.py (99%) rename {rest_framework/tests => tests}/test_serializer_bulk_update.py (100%) rename {rest_framework/tests => tests}/test_serializer_empty.py (100%) rename {rest_framework/tests => tests}/test_serializer_import.py (90%) rename {rest_framework/tests => tests}/test_serializer_nested.py (100%) rename {rest_framework/tests => tests}/test_serializers.py (94%) rename {rest_framework/tests => tests}/test_settings.py (83%) rename {rest_framework/tests => tests}/test_status.py (100%) rename {rest_framework/tests => tests}/test_templatetags.py (100%) rename {rest_framework/tests => tests}/test_testing.py (99%) rename {rest_framework/tests => tests}/test_throttling.py (100%) rename {rest_framework/tests => tests}/test_urlpatterns.py (100%) rename {rest_framework/tests => tests}/test_validation.py (100%) rename {rest_framework/tests => tests}/test_views.py (100%) rename {rest_framework/tests => tests}/test_write_only_fields.py (100%) create mode 100644 tests/urls.py rename {rest_framework/tests/records => tests/users}/__init__.py (100%) rename {rest_framework/tests => tests}/users/models.py (100%) rename {rest_framework/tests => tests}/users/serializers.py (71%) rename {rest_framework/tests => tests}/views.py (59%) diff --git a/.travis.yml b/.travis.yml index 2e6ed46a2..061d4c739 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,7 @@ env: install: - pip install $DJANGO - pip install defusedxml==0.3 + - pip install pytest-django==2.6 - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2==1.5.211; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.2.1; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.4; fi" @@ -24,7 +25,7 @@ install: - export PYTHONPATH=. script: - - python rest_framework/runtests/runtests.py + - py.test matrix: exclude: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7aa6fc40..ff6018b82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ To run the tests, clone the repository, and then: pip install -r optionals.txt # Run the tests - rest_framework/runtests/runtests.py + py.test You can also use the excellent [`tox`][tox] testing tool to run the tests against all supported versions of Python and Django. Install `tox` globally, and then simply run: diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..7cfc77f2a --- /dev/null +++ b/conftest.py @@ -0,0 +1,85 @@ +def pytest_configure(): + from django.conf import settings + + settings.configure( + DEBUG_PROPAGATE_EXCEPTIONS=True, + DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:'}}, + SECRET_KEY='not very secret in tests', + USE_I18N=True, + USE_L10N=True, + STATIC_URL='/static/', + ROOT_URLCONF='tests.urls', + TEMPLATE_LOADERS=( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ), + MIDDLEWARE_CLASSES=( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + ), + INSTALLED_APPS=( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + + 'rest_framework', + 'rest_framework.authtoken', + 'tests', + 'tests.accounts', + 'tests.records', + 'tests.users', + ), + PASSWORD_HASHERS=( + 'django.contrib.auth.hashers.SHA1PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.BCryptPasswordHasher', + 'django.contrib.auth.hashers.MD5PasswordHasher', + 'django.contrib.auth.hashers.CryptPasswordHasher', + ), + ) + + try: + import oauth_provider + import oauth2 + except ImportError: + pass + else: + settings.INSTALLED_APPS += ( + 'oauth_provider', + ) + + try: + import provider + except ImportError: + pass + else: + settings.INSTALLED_APPS += ( + 'provider', + 'provider.oauth2', + ) + + # guardian is optional + try: + import guardian + except ImportError: + pass + else: + settings.ANONYMOUS_USER_ID = -1 + settings.AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', # default + 'guardian.backends.ObjectPermissionBackend', + ) + settings.INSTALLED_APPS += ( + 'guardian', + ) + + # Force Django to load all models + from django.db.models import get_models + get_models() diff --git a/docs/index.md b/docs/index.md index 2a4ad8859..9ad647ac9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -206,19 +206,9 @@ General guides to using REST framework. ## Development -If you want to work on REST framework itself, clone the repository, then... - -Build the docs: - - ./mkdocs.py - -Run the tests: - - ./rest_framework/runtests/runtests.py - -To run the tests against all supported configurations, first install [the tox testing tool][tox] globally, using `pip install tox`, then simply run `tox`: - - tox +See the [Contribution guidelines][contributing] for information on how to clone +the repository, run the test suite and contribute changes back to REST +Framework. ## Support diff --git a/docs/topics/contributing.md b/docs/topics/contributing.md index 5a5d1a80b..09cc00b3c 100644 --- a/docs/topics/contributing.md +++ b/docs/topics/contributing.md @@ -65,7 +65,7 @@ To run the tests, clone the repository, and then: pip install -r optionals.txt # Run the tests - rest_framework/runtests/runtests.py + py.test You can also use the excellent `[tox][tox]` testing tool to run the tests against all supported versions of Python and Django. Install `tox` globally, and then simply run: diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..bbd083ac1 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --tb=short diff --git a/requirements.txt b/requirements.txt index 730c1d07a..360acb14d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ +-e . Django>=1.3 +pytest-django==2.6 diff --git a/rest_framework/runtests/runcoverage.py b/rest_framework/runtests/runcoverage.py deleted file mode 100755 index ce11b213e..000000000 --- a/rest_framework/runtests/runcoverage.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python -""" -Useful tool to run the test suite for rest_framework and generate a coverage report. -""" - -# http://ericholscher.com/blog/2009/jun/29/enable-setuppy-test-your-django-apps/ -# http://www.travisswicegood.com/2010/01/17/django-virtualenv-pip-and-fabric/ -# http://code.djangoproject.com/svn/django/trunk/tests/runtests.py -import os -import sys - -# fix sys path so we don't need to setup PYTHONPATH -sys.path.append(os.path.join(os.path.dirname(__file__), "../..")) -os.environ['DJANGO_SETTINGS_MODULE'] = 'rest_framework.runtests.settings' - -from coverage import coverage - - -def main(): - """Run the tests for rest_framework and generate a coverage report.""" - - cov = coverage() - cov.erase() - cov.start() - - from django.conf import settings - from django.test.utils import get_runner - TestRunner = get_runner(settings) - - if hasattr(TestRunner, 'func_name'): - # Pre 1.2 test runners were just functions, - # and did not support the 'failfast' option. - import warnings - warnings.warn( - 'Function-based test runners are deprecated. Test runners should be classes with a run_tests() method.', - DeprecationWarning - ) - failures = TestRunner(['tests']) - else: - test_runner = TestRunner() - failures = test_runner.run_tests(['tests']) - cov.stop() - - # Discover the list of all modules that we should test coverage for - import rest_framework - - project_dir = os.path.dirname(rest_framework.__file__) - cov_files = [] - - for (path, dirs, files) in os.walk(project_dir): - # Drop tests and runtests directories from the test coverage report - if os.path.basename(path) in ['tests', 'runtests', 'migrations']: - continue - - # Drop the compat and six modules from coverage, since we're not interested in the coverage - # of modules which are specifically for resolving environment dependant imports. - # (Because we'll end up getting different coverage reports for it for each environment) - if 'compat.py' in files: - files.remove('compat.py') - - if 'six.py' in files: - files.remove('six.py') - - # Same applies to template tags module. - # This module has to include branching on Django versions, - # so it's never possible for it to have full coverage. - if 'rest_framework.py' in files: - files.remove('rest_framework.py') - - cov_files.extend([os.path.join(path, file) for file in files if file.endswith('.py')]) - - cov.report(cov_files) - if '--html' in sys.argv: - cov.html_report(cov_files, directory='coverage') - sys.exit(failures) - -if __name__ == '__main__': - main() diff --git a/rest_framework/runtests/runtests.py b/rest_framework/runtests/runtests.py deleted file mode 100755 index da36d23fc..000000000 --- a/rest_framework/runtests/runtests.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python - -# http://ericholscher.com/blog/2009/jun/29/enable-setuppy-test-your-django-apps/ -# http://www.travisswicegood.com/2010/01/17/django-virtualenv-pip-and-fabric/ -# http://code.djangoproject.com/svn/django/trunk/tests/runtests.py -import os -import sys - -# fix sys path so we don't need to setup PYTHONPATH -sys.path.append(os.path.join(os.path.dirname(__file__), "../..")) -os.environ['DJANGO_SETTINGS_MODULE'] = 'rest_framework.runtests.settings' - -import django -from django.conf import settings -from django.test.utils import get_runner - - -def usage(): - return """ - Usage: python runtests.py [UnitTestClass].[method] - - You can pass the Class name of the `UnitTestClass` you want to test. - - Append a method name if you only want to test a specific method of that class. - """ - - -def main(): - TestRunner = get_runner(settings) - - test_runner = TestRunner() - if len(sys.argv) == 2: - test_case = '.' + sys.argv[1] - elif len(sys.argv) == 1: - test_case = '' - else: - print(usage()) - sys.exit(1) - test_module_name = 'rest_framework.tests' - if django.VERSION[0] == 1 and django.VERSION[1] < 6: - test_module_name = 'tests' - - failures = test_runner.run_tests([test_module_name + test_case]) - - sys.exit(failures) - -if __name__ == '__main__': - main() diff --git a/rest_framework/runtests/urls.py b/rest_framework/runtests/urls.py deleted file mode 100644 index ed5baeae6..000000000 --- a/rest_framework/runtests/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Blank URLConf just to keep runtests.py happy. -""" -from rest_framework.compat import patterns - -urlpatterns = patterns('', -) diff --git a/rest_framework/tests/tests.py b/rest_framework/tests/tests.py deleted file mode 100644 index 554ebd1ad..000000000 --- a/rest_framework/tests/tests.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Force import of all modules in this package in order to get the standard test -runner to pick up the tests. Yowzers. -""" -from __future__ import unicode_literals -import os -import django - -modules = [filename.rsplit('.', 1)[0] - for filename in os.listdir(os.path.dirname(__file__)) - if filename.endswith('.py') and not filename.startswith('_')] -__test__ = dict() - -if django.VERSION < (1, 6): - for module in modules: - exec("from rest_framework.tests.%s import *" % module) diff --git a/rest_framework/tests/users/__init__.py b/rest_framework/tests/users/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/setup.py b/setup.py index 78cdb6289..2c56cd758 100755 --- a/setup.py +++ b/setup.py @@ -2,11 +2,26 @@ # -*- coding: utf-8 -*- from setuptools import setup +from setuptools.command.test import test as TestCommand import re import os import sys +# This command has been borrowed from +# https://github.com/getsentry/sentry/blob/master/setup.py +class PyTest(TestCommand): + def finalize_options(self): + TestCommand.finalize_options(self) + self.test_args = ['tests'] + self.test_suite = True + + def run_tests(self): + import pytest + errno = pytest.main(self.test_args) + sys.exit(errno) + + def get_version(package): """ Return package version as listed in `__version__` in `init.py`. @@ -62,7 +77,7 @@ setup( author_email='tom@tomchristie.com', # SEE NOTE BELOW (*) packages=get_packages('rest_framework'), package_data=get_package_data('rest_framework'), - test_suite='rest_framework.runtests.runtests.main', + cmdclass={'test': PyTest}, install_requires=[], classifiers=[ 'Development Status :: 5 - Production/Stable', diff --git a/rest_framework/runtests/__init__.py b/tests/__init__.py similarity index 100% rename from rest_framework/runtests/__init__.py rename to tests/__init__.py diff --git a/rest_framework/tests/__init__.py b/tests/accounts/__init__.py similarity index 100% rename from rest_framework/tests/__init__.py rename to tests/accounts/__init__.py diff --git a/rest_framework/tests/accounts/models.py b/tests/accounts/models.py similarity index 81% rename from rest_framework/tests/accounts/models.py rename to tests/accounts/models.py index 525e601ba..3bf4a0c3c 100644 --- a/rest_framework/tests/accounts/models.py +++ b/tests/accounts/models.py @@ -1,6 +1,6 @@ from django.db import models -from rest_framework.tests.users.models import User +from tests.users.models import User class Account(models.Model): diff --git a/rest_framework/tests/accounts/serializers.py b/tests/accounts/serializers.py similarity index 58% rename from rest_framework/tests/accounts/serializers.py rename to tests/accounts/serializers.py index a27b9ca6f..57a91b92c 100644 --- a/rest_framework/tests/accounts/serializers.py +++ b/tests/accounts/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers -from rest_framework.tests.accounts.models import Account -from rest_framework.tests.users.serializers import UserSerializer +from tests.accounts.models import Account +from tests.users.serializers import UserSerializer class AccountSerializer(serializers.ModelSerializer): diff --git a/rest_framework/tests/description.py b/tests/description.py similarity index 100% rename from rest_framework/tests/description.py rename to tests/description.py diff --git a/rest_framework/tests/accounts/__init__.py b/tests/extras/__init__.py similarity index 100% rename from rest_framework/tests/accounts/__init__.py rename to tests/extras/__init__.py diff --git a/rest_framework/tests/extras/bad_import.py b/tests/extras/bad_import.py similarity index 100% rename from rest_framework/tests/extras/bad_import.py rename to tests/extras/bad_import.py diff --git a/rest_framework/tests/models.py b/tests/models.py similarity index 100% rename from rest_framework/tests/models.py rename to tests/models.py diff --git a/rest_framework/tests/extras/__init__.py b/tests/records/__init__.py similarity index 100% rename from rest_framework/tests/extras/__init__.py rename to tests/records/__init__.py diff --git a/rest_framework/tests/records/models.py b/tests/records/models.py similarity index 100% rename from rest_framework/tests/records/models.py rename to tests/records/models.py diff --git a/rest_framework/tests/serializers.py b/tests/serializers.py similarity index 71% rename from rest_framework/tests/serializers.py rename to tests/serializers.py index cc943c7d0..f2f85b6ea 100644 --- a/rest_framework/tests/serializers.py +++ b/tests/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from rest_framework.tests.models import NullableForeignKeySource +from tests.models import NullableForeignKeySource class NullableFKSourceSerializer(serializers.ModelSerializer): diff --git a/rest_framework/runtests/settings.py b/tests/settings.py similarity index 96% rename from rest_framework/runtests/settings.py rename to tests/settings.py index 3fc0eb2f4..75f7c54b3 100644 --- a/rest_framework/runtests/settings.py +++ b/tests/settings.py @@ -79,7 +79,7 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', ) -ROOT_URLCONF = 'urls' +ROOT_URLCONF = 'tests.urls' TEMPLATE_DIRS = ( # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". @@ -99,10 +99,10 @@ INSTALLED_APPS = ( # 'django.contrib.admindocs', 'rest_framework', 'rest_framework.authtoken', - 'rest_framework.tests', - 'rest_framework.tests.accounts', - 'rest_framework.tests.records', - 'rest_framework.tests.users', + 'tests', + 'tests.accounts', + 'tests.records', + 'tests.users', ) # OAuth is optional and won't work if there is no oauth_provider & oauth2 diff --git a/rest_framework/tests/test_authentication.py b/tests/test_authentication.py similarity index 99% rename from rest_framework/tests/test_authentication.py rename to tests/test_authentication.py index f072b81b7..4ecfef44f 100644 --- a/rest_framework/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -64,7 +64,7 @@ if oauth2_provider is not None: class BasicAuthTests(TestCase): """Basic authentication""" - urls = 'rest_framework.tests.test_authentication' + urls = 'tests.test_authentication' def setUp(self): self.csrf_client = APIClient(enforce_csrf_checks=True) @@ -103,7 +103,7 @@ class BasicAuthTests(TestCase): class SessionAuthTests(TestCase): """User session authentication""" - urls = 'rest_framework.tests.test_authentication' + urls = 'tests.test_authentication' def setUp(self): self.csrf_client = APIClient(enforce_csrf_checks=True) @@ -150,7 +150,7 @@ class SessionAuthTests(TestCase): class TokenAuthTests(TestCase): """Token authentication""" - urls = 'rest_framework.tests.test_authentication' + urls = 'tests.test_authentication' def setUp(self): self.csrf_client = APIClient(enforce_csrf_checks=True) @@ -244,7 +244,7 @@ class IncorrectCredentialsTests(TestCase): class OAuthTests(TestCase): """OAuth 1.0a authentication""" - urls = 'rest_framework.tests.test_authentication' + urls = 'tests.test_authentication' def setUp(self): # these imports are here because oauth is optional and hiding them in try..except block or compat @@ -474,7 +474,7 @@ class OAuthTests(TestCase): class OAuth2Tests(TestCase): """OAuth 2.0 authentication""" - urls = 'rest_framework.tests.test_authentication' + urls = 'tests.test_authentication' def setUp(self): self.csrf_client = APIClient(enforce_csrf_checks=True) diff --git a/rest_framework/tests/test_breadcrumbs.py b/tests/test_breadcrumbs.py similarity index 98% rename from rest_framework/tests/test_breadcrumbs.py rename to tests/test_breadcrumbs.py index 41ddf2cea..78edc6032 100644 --- a/rest_framework/tests/test_breadcrumbs.py +++ b/tests/test_breadcrumbs.py @@ -36,7 +36,7 @@ urlpatterns = patterns('', class BreadcrumbTests(TestCase): """Tests the breadcrumb functionality used by the HTML renderer.""" - urls = 'rest_framework.tests.test_breadcrumbs' + urls = 'tests.test_breadcrumbs' def test_root_breadcrumbs(self): url = '/' diff --git a/rest_framework/tests/test_decorators.py b/tests/test_decorators.py similarity index 100% rename from rest_framework/tests/test_decorators.py rename to tests/test_decorators.py diff --git a/rest_framework/tests/test_description.py b/tests/test_description.py similarity index 94% rename from rest_framework/tests/test_description.py rename to tests/test_description.py index 4c03c1ded..1e481f06c 100644 --- a/rest_framework/tests/test_description.py +++ b/tests/test_description.py @@ -4,8 +4,8 @@ from __future__ import unicode_literals from django.test import TestCase from rest_framework.compat import apply_markdown, smart_text from rest_framework.views import APIView -from rest_framework.tests.description import ViewWithNonASCIICharactersInDocstring -from rest_framework.tests.description import UTF8_TEST_DOCSTRING +from .description import ViewWithNonASCIICharactersInDocstring +from .description import UTF8_TEST_DOCSTRING # We check that docstrings get nicely un-indented. DESCRIPTION = """an example docstring diff --git a/rest_framework/tests/test_fields.py b/tests/test_fields.py similarity index 99% rename from rest_framework/tests/test_fields.py rename to tests/test_fields.py index e127feef9..e65a2fb39 100644 --- a/rest_framework/tests/test_fields.py +++ b/tests/test_fields.py @@ -11,7 +11,7 @@ from django.db import models from django.test import TestCase from django.utils.datastructures import SortedDict from rest_framework import serializers -from rest_framework.tests.models import RESTFrameworkModel +from tests.models import RESTFrameworkModel class TimestampedModel(models.Model): diff --git a/rest_framework/tests/test_files.py b/tests/test_files.py similarity index 100% rename from rest_framework/tests/test_files.py rename to tests/test_files.py diff --git a/rest_framework/tests/test_filters.py b/tests/test_filters.py similarity index 99% rename from rest_framework/tests/test_filters.py rename to tests/test_filters.py index 181881865..d9d8042e2 100644 --- a/rest_framework/tests/test_filters.py +++ b/tests/test_filters.py @@ -8,7 +8,7 @@ from django.utils import unittest from rest_framework import generics, serializers, status, filters from rest_framework.compat import django_filters, patterns, url from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import BasicModel +from tests.models import BasicModel factory = APIRequestFactory() @@ -243,7 +243,7 @@ class IntegrationTestDetailFiltering(CommonFilteringTestCase): """ Integration tests for filtered detail views. """ - urls = 'rest_framework.tests.test_filters' + urls = 'tests.test_filters' def _get_url(self, item): return reverse('detail-view', kwargs=dict(pk=item.pk)) @@ -612,4 +612,4 @@ class SensitiveOrderingFilterTests(TestCase): {'id': 2, username_field: 'userB'}, # PassC {'id': 3, username_field: 'userC'}, # PassA ] - ) \ No newline at end of file + ) diff --git a/rest_framework/tests/test_genericrelations.py b/tests/test_genericrelations.py similarity index 100% rename from rest_framework/tests/test_genericrelations.py rename to tests/test_genericrelations.py diff --git a/rest_framework/tests/test_generics.py b/tests/test_generics.py similarity index 99% rename from rest_framework/tests/test_generics.py rename to tests/test_generics.py index 996bd5b0e..4389994af 100644 --- a/rest_framework/tests/test_generics.py +++ b/tests/test_generics.py @@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404 from django.test import TestCase from rest_framework import generics, renderers, serializers, status from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import BasicModel, Comment, SlugBasedModel +from tests.models import BasicModel, Comment, SlugBasedModel from rest_framework.compat import six factory = APIRequestFactory() diff --git a/rest_framework/tests/test_htmlrenderer.py b/tests/test_htmlrenderer.py similarity index 97% rename from rest_framework/tests/test_htmlrenderer.py rename to tests/test_htmlrenderer.py index 8957a43c7..c748fbdbb 100644 --- a/rest_framework/tests/test_htmlrenderer.py +++ b/tests/test_htmlrenderer.py @@ -42,7 +42,7 @@ urlpatterns = patterns('', class TemplateHTMLRendererTests(TestCase): - urls = 'rest_framework.tests.test_htmlrenderer' + urls = 'tests.test_htmlrenderer' def setUp(self): """ @@ -82,7 +82,7 @@ class TemplateHTMLRendererTests(TestCase): class TemplateHTMLRendererExceptionTests(TestCase): - urls = 'rest_framework.tests.test_htmlrenderer' + urls = 'tests.test_htmlrenderer' def setUp(self): """ diff --git a/rest_framework/tests/test_hyperlinkedserializers.py b/tests/test_hyperlinkedserializers.py similarity index 95% rename from rest_framework/tests/test_hyperlinkedserializers.py rename to tests/test_hyperlinkedserializers.py index 83d460435..eee179cae 100644 --- a/rest_framework/tests/test_hyperlinkedserializers.py +++ b/tests/test_hyperlinkedserializers.py @@ -5,7 +5,7 @@ from rest_framework import generics, status, serializers from rest_framework.compat import patterns, url from rest_framework.settings import api_settings from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import ( +from tests.models import ( Anchor, BasicModel, ManyToManyModel, BlogPost, BlogPostComment, Album, Photo, OptionalRelationModel ) @@ -110,7 +110,7 @@ urlpatterns = patterns('', class TestBasicHyperlinkedView(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -147,7 +147,7 @@ class TestBasicHyperlinkedView(TestCase): class TestManyToManyHyperlinkedView(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -195,7 +195,7 @@ class TestManyToManyHyperlinkedView(TestCase): class TestHyperlinkedIdentityFieldLookup(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -225,7 +225,7 @@ class TestHyperlinkedIdentityFieldLookup(TestCase): class TestCreateWithForeignKeys(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -250,7 +250,7 @@ class TestCreateWithForeignKeys(TestCase): class TestCreateWithForeignKeysAndCustomSlug(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -275,7 +275,7 @@ class TestCreateWithForeignKeysAndCustomSlug(TestCase): class TestOptionalRelationHyperlinkedView(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -335,7 +335,7 @@ class TestOverriddenURLField(TestCase): class TestURLFieldNameBySettings(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): self.saved_url_field_name = api_settings.URL_FIELD_NAME @@ -360,7 +360,7 @@ class TestURLFieldNameBySettings(TestCase): class TestURLFieldNameByOptions(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): class Serializer(serializers.HyperlinkedModelSerializer): diff --git a/rest_framework/tests/test_multitable_inheritance.py b/tests/test_multitable_inheritance.py similarity index 97% rename from rest_framework/tests/test_multitable_inheritance.py rename to tests/test_multitable_inheritance.py index 00c153276..ce1bf3ea3 100644 --- a/rest_framework/tests/test_multitable_inheritance.py +++ b/tests/test_multitable_inheritance.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from django.db import models from django.test import TestCase from rest_framework import serializers -from rest_framework.tests.models import RESTFrameworkModel +from tests.models import RESTFrameworkModel # Models diff --git a/rest_framework/tests/test_negotiation.py b/tests/test_negotiation.py similarity index 100% rename from rest_framework/tests/test_negotiation.py rename to tests/test_negotiation.py diff --git a/rest_framework/tests/test_nullable_fields.py b/tests/test_nullable_fields.py similarity index 76% rename from rest_framework/tests/test_nullable_fields.py rename to tests/test_nullable_fields.py index 6ee55c005..33a9685f3 100644 --- a/rest_framework/tests/test_nullable_fields.py +++ b/tests/test_nullable_fields.py @@ -2,9 +2,9 @@ from django.core.urlresolvers import reverse from rest_framework.compat import patterns, url from rest_framework.test import APITestCase -from rest_framework.tests.models import NullableForeignKeySource -from rest_framework.tests.serializers import NullableFKSourceSerializer -from rest_framework.tests.views import NullableFKSourceDetail +from tests.models import NullableForeignKeySource +from tests.serializers import NullableFKSourceSerializer +from tests.views import NullableFKSourceDetail urlpatterns = patterns( @@ -18,7 +18,7 @@ class NullableForeignKeyTests(APITestCase): DRF should be able to handle nullable foreign keys when a test Client POST/PUT request is made with its own serialized object. """ - urls = 'rest_framework.tests.test_nullable_fields' + urls = 'tests.test_nullable_fields' def test_updating_object_with_null_fk(self): obj = NullableForeignKeySource(name='example', target=None) diff --git a/rest_framework/tests/test_pagination.py b/tests/test_pagination.py similarity index 99% rename from rest_framework/tests/test_pagination.py rename to tests/test_pagination.py index cadb515fa..65fa9dcd1 100644 --- a/rest_framework/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -8,7 +8,7 @@ from django.utils import unittest from rest_framework import generics, status, pagination, filters, serializers from rest_framework.compat import django_filters from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import BasicModel +from tests.models import BasicModel factory = APIRequestFactory() diff --git a/rest_framework/tests/test_parsers.py b/tests/test_parsers.py similarity index 100% rename from rest_framework/tests/test_parsers.py rename to tests/test_parsers.py diff --git a/rest_framework/tests/test_permissions.py b/tests/test_permissions.py similarity index 98% rename from rest_framework/tests/test_permissions.py rename to tests/test_permissions.py index 6e3a63034..a2cb0c362 100644 --- a/rest_framework/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -7,7 +7,7 @@ from rest_framework import generics, status, permissions, authentication, HTTP_H from rest_framework.compat import guardian, get_model_name from rest_framework.filters import DjangoObjectPermissionsFilter from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import BasicModel +from tests.models import BasicModel import base64 factory = APIRequestFactory() @@ -187,8 +187,7 @@ class ObjectPermissionsIntegrationTests(TestCase): """ Integration tests for the object level permissions API. """ - @classmethod - def setUpClass(cls): + def setUp(self): from guardian.shortcuts import assign_perm # create users @@ -215,21 +214,13 @@ class ObjectPermissionsIntegrationTests(TestCase): assign_perm(perm, everyone) everyone.user_set.add(*users.values()) - cls.perms = perms - cls.users = users - - def setUp(self): - from guardian.shortcuts import assign_perm - perms = self.perms - users = self.users - # appropriate object level permissions readers = Group.objects.create(name='readers') writers = Group.objects.create(name='writers') deleters = Group.objects.create(name='deleters') model = BasicPermModel.objects.create(text='foo') - + assign_perm(perms['view'], readers, model) assign_perm(perms['change'], writers, model) assign_perm(perms['delete'], deleters, model) diff --git a/rest_framework/tests/test_relations.py b/tests/test_relations.py similarity index 97% rename from rest_framework/tests/test_relations.py rename to tests/test_relations.py index f52e0e1e5..bfc8d487e 100644 --- a/rest_framework/tests/test_relations.py +++ b/tests/test_relations.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals from django.db import models from django.test import TestCase from rest_framework import serializers -from rest_framework.tests.models import BlogPost +from tests.models import BlogPost class NullModel(models.Model): @@ -105,7 +105,7 @@ class RelatedFieldSourceTests(TestCase): Check that the exception message are correct if the source field doesn't exist. """ - from rest_framework.tests.models import ManyToManySource + from tests.models import ManyToManySource class Meta: model = ManyToManySource attrs = { diff --git a/rest_framework/tests/test_relations_hyperlink.py b/tests/test_relations_hyperlink.py similarity index 98% rename from rest_framework/tests/test_relations_hyperlink.py rename to tests/test_relations_hyperlink.py index 3c4d39af6..98f68d29f 100644 --- a/rest_framework/tests/test_relations_hyperlink.py +++ b/tests/test_relations_hyperlink.py @@ -3,7 +3,7 @@ from django.test import TestCase from rest_framework import serializers from rest_framework.compat import patterns, url from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import ( +from tests.models import ( BlogPost, ManyToManyTarget, ManyToManySource, ForeignKeyTarget, ForeignKeySource, NullableForeignKeySource, OneToOneTarget, NullableOneToOneSource @@ -71,7 +71,7 @@ class NullableOneToOneTargetSerializer(serializers.HyperlinkedModelSerializer): # TODO: Add test that .data cannot be accessed prior to .is_valid class HyperlinkedManyToManyTests(TestCase): - urls = 'rest_framework.tests.test_relations_hyperlink' + urls = 'tests.test_relations_hyperlink' def setUp(self): for idx in range(1, 4): @@ -179,7 +179,7 @@ class HyperlinkedManyToManyTests(TestCase): class HyperlinkedForeignKeyTests(TestCase): - urls = 'rest_framework.tests.test_relations_hyperlink' + urls = 'tests.test_relations_hyperlink' def setUp(self): target = ForeignKeyTarget(name='target-1') @@ -307,7 +307,7 @@ class HyperlinkedForeignKeyTests(TestCase): class HyperlinkedNullableForeignKeyTests(TestCase): - urls = 'rest_framework.tests.test_relations_hyperlink' + urls = 'tests.test_relations_hyperlink' def setUp(self): target = ForeignKeyTarget(name='target-1') @@ -435,7 +435,7 @@ class HyperlinkedNullableForeignKeyTests(TestCase): class HyperlinkedNullableOneToOneTests(TestCase): - urls = 'rest_framework.tests.test_relations_hyperlink' + urls = 'tests.test_relations_hyperlink' def setUp(self): target = OneToOneTarget(name='target-1') @@ -458,7 +458,7 @@ class HyperlinkedNullableOneToOneTests(TestCase): # Regression tests for #694 (`source` attribute on related fields) class HyperlinkedRelatedFieldSourceTests(TestCase): - urls = 'rest_framework.tests.test_relations_hyperlink' + urls = 'tests.test_relations_hyperlink' def test_related_manager_source(self): """ diff --git a/rest_framework/tests/test_relations_nested.py b/tests/test_relations_nested.py similarity index 100% rename from rest_framework/tests/test_relations_nested.py rename to tests/test_relations_nested.py diff --git a/rest_framework/tests/test_relations_pk.py b/tests/test_relations_pk.py similarity index 99% rename from rest_framework/tests/test_relations_pk.py rename to tests/test_relations_pk.py index 3815afdd8..ff59b250a 100644 --- a/rest_framework/tests/test_relations_pk.py +++ b/tests/test_relations_pk.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from django.db import models from django.test import TestCase from rest_framework import serializers -from rest_framework.tests.models import ( +from tests.models import ( BlogPost, ManyToManyTarget, ManyToManySource, ForeignKeyTarget, ForeignKeySource, NullableForeignKeySource, OneToOneTarget, NullableOneToOneSource, ) diff --git a/rest_framework/tests/test_relations_slug.py b/tests/test_relations_slug.py similarity index 99% rename from rest_framework/tests/test_relations_slug.py rename to tests/test_relations_slug.py index 435c821cf..97ebf23a1 100644 --- a/rest_framework/tests/test_relations_slug.py +++ b/tests/test_relations_slug.py @@ -1,6 +1,6 @@ from django.test import TestCase from rest_framework import serializers -from rest_framework.tests.models import NullableForeignKeySource, ForeignKeySource, ForeignKeyTarget +from tests.models import NullableForeignKeySource, ForeignKeySource, ForeignKeyTarget class ForeignKeyTargetSerializer(serializers.ModelSerializer): diff --git a/rest_framework/tests/test_renderers.py b/tests/test_renderers.py similarity index 99% rename from rest_framework/tests/test_renderers.py rename to tests/test_renderers.py index 0f3432c99..b41cff397 100644 --- a/rest_framework/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -152,7 +152,7 @@ class RendererEndToEndTests(TestCase): End-to-end testing of renderers using an RendererMixin on a generic view. """ - urls = 'rest_framework.tests.test_renderers' + urls = 'tests.test_renderers' def test_default_renderer_serializes_content(self): """If the Accept header is not set the default renderer should serialize the response.""" @@ -387,7 +387,7 @@ class JSONPRendererTests(TestCase): Tests specific to the JSONP Renderer """ - urls = 'rest_framework.tests.test_renderers' + urls = 'tests.test_renderers' def test_without_callback_with_json_renderer(self): """ @@ -571,7 +571,7 @@ class CacheRenderTest(TestCase): Tests specific to caching responses """ - urls = 'rest_framework.tests.test_renderers' + urls = 'tests.test_renderers' cache_key = 'just_a_cache_key' diff --git a/rest_framework/tests/test_request.py b/tests/test_request.py similarity index 99% rename from rest_framework/tests/test_request.py rename to tests/test_request.py index c0b50f330..0a9355f0b 100644 --- a/rest_framework/tests/test_request.py +++ b/tests/test_request.py @@ -278,7 +278,7 @@ urlpatterns = patterns('', class TestContentParsingWithAuthentication(TestCase): - urls = 'rest_framework.tests.test_request' + urls = 'tests.test_request' def setUp(self): self.csrf_client = APIClient(enforce_csrf_checks=True) diff --git a/rest_framework/tests/test_response.py b/tests/test_response.py similarity index 97% rename from rest_framework/tests/test_response.py rename to tests/test_response.py index eea3c6418..41c0f49d8 100644 --- a/rest_framework/tests/test_response.py +++ b/tests/test_response.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals from django.test import TestCase -from rest_framework.tests.models import BasicModel, BasicModelSerializer +from tests.models import BasicModel, BasicModelSerializer from rest_framework.compat import patterns, url, include from rest_framework.response import Response from rest_framework.views import APIView @@ -118,7 +118,7 @@ class RendererIntegrationTests(TestCase): End-to-end testing of renderers using an ResponseMixin on a generic view. """ - urls = 'rest_framework.tests.test_response' + urls = 'tests.test_response' def test_default_renderer_serializes_content(self): """If the Accept header is not set the default renderer should serialize the response.""" @@ -198,7 +198,7 @@ class Issue122Tests(TestCase): """ Tests that covers #122. """ - urls = 'rest_framework.tests.test_response' + urls = 'tests.test_response' def test_only_html_renderer(self): """ @@ -218,7 +218,7 @@ class Issue467Tests(TestCase): Tests for #467 """ - urls = 'rest_framework.tests.test_response' + urls = 'tests.test_response' def test_form_has_label_and_help_text(self): resp = self.client.get('/html_new_model') @@ -232,7 +232,7 @@ class Issue807Tests(TestCase): Covers #807 """ - urls = 'rest_framework.tests.test_response' + urls = 'tests.test_response' def test_does_not_append_charset_by_default(self): """ diff --git a/rest_framework/tests/test_reverse.py b/tests/test_reverse.py similarity index 93% rename from rest_framework/tests/test_reverse.py rename to tests/test_reverse.py index 690a30b11..3d14a28f5 100644 --- a/rest_framework/tests/test_reverse.py +++ b/tests/test_reverse.py @@ -19,7 +19,7 @@ class ReverseTests(TestCase): """ Tests for fully qualified URLs when using `reverse`. """ - urls = 'rest_framework.tests.test_reverse' + urls = 'tests.test_reverse' def test_reversed_urls_are_fully_qualified(self): request = factory.get('/view') diff --git a/rest_framework/tests/test_routers.py b/tests/test_routers.py similarity index 98% rename from rest_framework/tests/test_routers.py rename to tests/test_routers.py index e723f7d45..084c0e277 100644 --- a/rest_framework/tests/test_routers.py +++ b/tests/test_routers.py @@ -72,7 +72,7 @@ class TestCustomLookupFields(TestCase): """ Ensure that custom lookup fields are correctly routed. """ - urls = 'rest_framework.tests.test_routers' + urls = 'tests.test_routers' def setUp(self): class NoteSerializer(serializers.HyperlinkedModelSerializer): @@ -91,7 +91,7 @@ class TestCustomLookupFields(TestCase): self.router = SimpleRouter() self.router.register(r'notes', NoteViewSet) - from rest_framework.tests import test_routers + from tests import test_routers urls = getattr(test_routers, 'urlpatterns') urls += patterns('', url(r'^', include(self.router.urls)), diff --git a/rest_framework/tests/test_serializer.py b/tests/test_serializer.py similarity index 99% rename from rest_framework/tests/test_serializer.py rename to tests/test_serializer.py index 6b1e333e4..18484afeb 100644 --- a/rest_framework/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -6,10 +6,10 @@ from django.test import TestCase from django.utils.datastructures import MultiValueDict from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers, fields, relations -from rest_framework.tests.models import (HasPositiveIntegerAsChoice, Album, ActionItem, Anchor, BasicModel, +from tests.models import (HasPositiveIntegerAsChoice, Album, ActionItem, Anchor, BasicModel, BlankFieldModel, BlogPost, BlogPostComment, Book, CallableDefaultValueModel, DefaultValueModel, ManyToManyModel, Person, ReadOnlyManyToManyModel, Photo, RESTFrameworkModel) -from rest_framework.tests.models import BasicModelSerializer +from tests.models import BasicModelSerializer import datetime import pickle @@ -1034,7 +1034,7 @@ class RelatedTraversalTest(TestCase): """ If a component of the dotted.source is None, return None for the field. """ - from rest_framework.tests.models import NullableForeignKeySource + from tests.models import NullableForeignKeySource instance = NullableForeignKeySource.objects.create(name='Source with null FK') class NullableSourceSerializer(serializers.Serializer): diff --git a/rest_framework/tests/test_serializer_bulk_update.py b/tests/test_serializer_bulk_update.py similarity index 100% rename from rest_framework/tests/test_serializer_bulk_update.py rename to tests/test_serializer_bulk_update.py diff --git a/rest_framework/tests/test_serializer_empty.py b/tests/test_serializer_empty.py similarity index 100% rename from rest_framework/tests/test_serializer_empty.py rename to tests/test_serializer_empty.py diff --git a/rest_framework/tests/test_serializer_import.py b/tests/test_serializer_import.py similarity index 90% rename from rest_framework/tests/test_serializer_import.py rename to tests/test_serializer_import.py index 9f30a7ffa..3b8ff4b3c 100644 --- a/rest_framework/tests/test_serializer_import.py +++ b/tests/test_serializer_import.py @@ -1,7 +1,7 @@ from django.test import TestCase from rest_framework import serializers -from rest_framework.tests.accounts.serializers import AccountSerializer +from tests.accounts.serializers import AccountSerializer class ImportingModelSerializerTests(TestCase): diff --git a/rest_framework/tests/test_serializer_nested.py b/tests/test_serializer_nested.py similarity index 100% rename from rest_framework/tests/test_serializer_nested.py rename to tests/test_serializer_nested.py diff --git a/rest_framework/tests/test_serializers.py b/tests/test_serializers.py similarity index 94% rename from rest_framework/tests/test_serializers.py rename to tests/test_serializers.py index 082a400ca..675477833 100644 --- a/rest_framework/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -2,7 +2,7 @@ from django.db import models from django.test import TestCase from rest_framework.serializers import _resolve_model -from rest_framework.tests.models import BasicModel +from tests.models import BasicModel class ResolveModelTests(TestCase): diff --git a/rest_framework/tests/test_settings.py b/tests/test_settings.py similarity index 83% rename from rest_framework/tests/test_settings.py rename to tests/test_settings.py index 857375c21..e29fc34aa 100644 --- a/rest_framework/tests/test_settings.py +++ b/tests/test_settings.py @@ -10,13 +10,13 @@ class TestSettings(TestCase): def test_non_import_errors(self): """Make sure other errors aren't suppressed.""" - settings = APISettings({'DEFAULT_MODEL_SERIALIZER_CLASS': 'rest_framework.tests.extras.bad_import.ModelSerializer'}, DEFAULTS, IMPORT_STRINGS) + settings = APISettings({'DEFAULT_MODEL_SERIALIZER_CLASS': 'tests.extras.bad_import.ModelSerializer'}, DEFAULTS, IMPORT_STRINGS) with self.assertRaises(ValueError): settings.DEFAULT_MODEL_SERIALIZER_CLASS def test_import_error_message_maintained(self): """Make sure real import errors are captured and raised sensibly.""" - settings = APISettings({'DEFAULT_MODEL_SERIALIZER_CLASS': 'rest_framework.tests.extras.not_here.ModelSerializer'}, DEFAULTS, IMPORT_STRINGS) + settings = APISettings({'DEFAULT_MODEL_SERIALIZER_CLASS': 'tests.extras.not_here.ModelSerializer'}, DEFAULTS, IMPORT_STRINGS) with self.assertRaises(ImportError) as cm: settings.DEFAULT_MODEL_SERIALIZER_CLASS self.assertTrue('ImportError' in str(cm.exception)) diff --git a/rest_framework/tests/test_status.py b/tests/test_status.py similarity index 100% rename from rest_framework/tests/test_status.py rename to tests/test_status.py diff --git a/rest_framework/tests/test_templatetags.py b/tests/test_templatetags.py similarity index 100% rename from rest_framework/tests/test_templatetags.py rename to tests/test_templatetags.py diff --git a/rest_framework/tests/test_testing.py b/tests/test_testing.py similarity index 99% rename from rest_framework/tests/test_testing.py rename to tests/test_testing.py index 71bd8b552..8c6086a29 100644 --- a/rest_framework/tests/test_testing.py +++ b/tests/test_testing.py @@ -35,7 +35,7 @@ urlpatterns = patterns('', class TestAPITestClient(TestCase): - urls = 'rest_framework.tests.test_testing' + urls = 'tests.test_testing' def setUp(self): self.client = APIClient() diff --git a/rest_framework/tests/test_throttling.py b/tests/test_throttling.py similarity index 100% rename from rest_framework/tests/test_throttling.py rename to tests/test_throttling.py diff --git a/rest_framework/tests/test_urlpatterns.py b/tests/test_urlpatterns.py similarity index 100% rename from rest_framework/tests/test_urlpatterns.py rename to tests/test_urlpatterns.py diff --git a/rest_framework/tests/test_validation.py b/tests/test_validation.py similarity index 100% rename from rest_framework/tests/test_validation.py rename to tests/test_validation.py diff --git a/rest_framework/tests/test_views.py b/tests/test_views.py similarity index 100% rename from rest_framework/tests/test_views.py rename to tests/test_views.py diff --git a/rest_framework/tests/test_write_only_fields.py b/tests/test_write_only_fields.py similarity index 100% rename from rest_framework/tests/test_write_only_fields.py rename to tests/test_write_only_fields.py diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 000000000..62cad3395 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,6 @@ +""" +Blank URLConf just to keep the test suite happy +""" +from rest_framework.compat import patterns + +urlpatterns = patterns('') diff --git a/rest_framework/tests/records/__init__.py b/tests/users/__init__.py similarity index 100% rename from rest_framework/tests/records/__init__.py rename to tests/users/__init__.py diff --git a/rest_framework/tests/users/models.py b/tests/users/models.py similarity index 100% rename from rest_framework/tests/users/models.py rename to tests/users/models.py diff --git a/rest_framework/tests/users/serializers.py b/tests/users/serializers.py similarity index 71% rename from rest_framework/tests/users/serializers.py rename to tests/users/serializers.py index da4965540..4893ddb34 100644 --- a/rest_framework/tests/users/serializers.py +++ b/tests/users/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from rest_framework.tests.users.models import User +from tests.users.models import User class UserSerializer(serializers.ModelSerializer): diff --git a/rest_framework/tests/views.py b/tests/views.py similarity index 59% rename from rest_framework/tests/views.py rename to tests/views.py index 3917b74a9..55935e924 100644 --- a/rest_framework/tests/views.py +++ b/tests/views.py @@ -1,6 +1,6 @@ from rest_framework import generics -from rest_framework.tests.models import NullableForeignKeySource -from rest_framework.tests.serializers import NullableFKSourceSerializer +from .models import NullableForeignKeySource +from .serializers import NullableFKSourceSerializer class NullableFKSourceDetail(generics.RetrieveUpdateDestroyAPIView): diff --git a/tox.ini b/tox.ini index 77766d20b..2fe39f120 100644 --- a/tox.ini +++ b/tox.ini @@ -3,19 +3,21 @@ downloadcache = {toxworkdir}/cache/ envlist = py3.3-django1.6,py3.2-django1.6,py2.7-django1.6,py2.6-django1.6,py3.3-django1.5,py3.2-django1.5,py2.7-django1.5,py2.6-django1.5,py2.7-django1.4,py2.6-django1.4,py2.7-django1.3,py2.6-django1.3 [testenv] -commands = {envpython} rest_framework/runtests/runtests.py +commands = py.test -q [testenv:py3.3-django1.6] basepython = python3.3 deps = Django==1.6 django-filter==0.6a1 defusedxml==0.3 + pytest-django==2.6 [testenv:py3.2-django1.6] basepython = python3.2 deps = Django==1.6 django-filter==0.6a1 defusedxml==0.3 + pytest-django==2.6 [testenv:py2.7-django1.6] basepython = python2.7 @@ -26,6 +28,7 @@ deps = Django==1.6 oauth2==1.5.211 django-oauth2-provider==0.2.4 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py2.6-django1.6] basepython = python2.6 @@ -36,18 +39,21 @@ deps = Django==1.6 oauth2==1.5.211 django-oauth2-provider==0.2.4 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py3.3-django1.5] basepython = python3.3 deps = django==1.5.5 django-filter==0.6a1 defusedxml==0.3 + pytest-django==2.6 [testenv:py3.2-django1.5] basepython = python3.2 deps = django==1.5.5 django-filter==0.6a1 defusedxml==0.3 + pytest-django==2.6 [testenv:py2.7-django1.5] basepython = python2.7 @@ -58,6 +64,7 @@ deps = django==1.5.5 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py2.6-django1.5] basepython = python2.6 @@ -68,6 +75,7 @@ deps = django==1.5.5 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py2.7-django1.4] basepython = python2.7 @@ -78,6 +86,7 @@ deps = django==1.4.10 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py2.6-django1.4] basepython = python2.6 @@ -88,6 +97,7 @@ deps = django==1.4.10 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py2.7-django1.3] basepython = python2.7 @@ -98,6 +108,7 @@ deps = django==1.3.5 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py2.6-django1.3] basepython = python2.6 @@ -108,3 +119,4 @@ deps = django==1.3.5 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 + pytest-django==2.6 From 6c108c459d8cfeda46b8e045ef750c01dd0ffcaa Mon Sep 17 00:00:00 2001 From: Ian Foote Date: Wed, 16 Apr 2014 12:32:04 +0100 Subject: [PATCH 40/72] Allow customising ChoiceField blank display value --- rest_framework/fields.py | 8 ++++++-- rest_framework/tests/test_fields.py | 9 +++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 946a59545..d9521cd46 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -509,12 +509,16 @@ class ChoiceField(WritableField): 'the available choices.'), } - def __init__(self, choices=(), *args, **kwargs): + def __init__(self, choices=(), blank_display_value=None, *args, **kwargs): self.empty = kwargs.pop('empty', '') super(ChoiceField, self).__init__(*args, **kwargs) self.choices = choices if not self.required: - self.choices = BLANK_CHOICE_DASH + self.choices + if blank_display_value is None: + blank_choice = BLANK_CHOICE_DASH + else: + blank_choice = [('', blank_display_value)] + self.choices = blank_choice + self.choices def _get_choices(self): return self._choices diff --git a/rest_framework/tests/test_fields.py b/rest_framework/tests/test_fields.py index e127feef9..63dff7182 100644 --- a/rest_framework/tests/test_fields.py +++ b/rest_framework/tests/test_fields.py @@ -706,6 +706,15 @@ class ChoiceFieldTests(TestCase): f = serializers.ChoiceField(required=False, choices=SAMPLE_CHOICES) self.assertEqual(f.choices, models.fields.BLANK_CHOICE_DASH + SAMPLE_CHOICES) + def test_blank_choice_display(self): + blank = 'No Preference' + f = serializers.ChoiceField( + required=False, + choices=SAMPLE_CHOICES, + blank_display_value=blank, + ) + self.assertEqual(f.choices, [('', blank)] + SAMPLE_CHOICES) + def test_invalid_choice_model(self): s = ChoiceFieldModelSerializer(data={'choice': 'wrong_value'}) self.assertFalse(s.is_valid()) From f22ed49c648c6dc3e2cd3c1dfbda77c010189e28 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 17 Apr 2014 11:09:02 +0200 Subject: [PATCH 41/72] Upgraded to pytest-django 2.6.1 --- .travis.yml | 2 +- conftest.py | 4 ---- tox.ini | 27 +++++++++++++++------------ 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/.travis.yml b/.travis.yml index 13dc3e289..4f4d0c307 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,7 +16,7 @@ env: install: - pip install $DJANGO - pip install defusedxml==0.3 Pillow==2.3.0 - - pip install pytest-django==2.6 + - pip install pytest-django==2.6.1 - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2==1.5.211; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.2.4; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.4; fi" diff --git a/conftest.py b/conftest.py index 7cfc77f2a..b1691a884 100644 --- a/conftest.py +++ b/conftest.py @@ -79,7 +79,3 @@ def pytest_configure(): settings.INSTALLED_APPS += ( 'guardian', ) - - # Force Django to load all models - from django.db.models import get_models - get_models() diff --git a/tox.ini b/tox.ini index 251a40b73..2f6f16125 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ deps = https://www.djangoproject.com/download/1.7b1/tarball/ django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py3.2-django1.7] basepython = python3.2 @@ -18,6 +19,7 @@ deps = https://www.djangoproject.com/download/1.7b1/tarball/ django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py2.7-django1.7] basepython = python2.7 @@ -29,6 +31,7 @@ deps = https://www.djangoproject.com/download/1.7b1/tarball/ django-oauth2-provider==0.2.4 django-guardian==1.1.1 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py3.3-django1.6] basepython = python3.3 @@ -36,7 +39,7 @@ deps = Django==1.6 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py3.2-django1.6] basepython = python3.2 @@ -44,7 +47,7 @@ deps = Django==1.6 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.7-django1.6] basepython = python2.7 @@ -56,7 +59,7 @@ deps = Django==1.6 django-oauth2-provider==0.2.4 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.6-django1.6] basepython = python2.6 @@ -68,7 +71,7 @@ deps = Django==1.6 django-oauth2-provider==0.2.4 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py3.3-django1.5] basepython = python3.3 @@ -76,7 +79,7 @@ deps = django==1.5.5 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py3.2-django1.5] basepython = python3.2 @@ -84,7 +87,7 @@ deps = django==1.5.5 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.7-django1.5] basepython = python2.7 @@ -96,7 +99,7 @@ deps = django==1.5.5 django-oauth2-provider==0.2.3 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.6-django1.5] basepython = python2.6 @@ -108,7 +111,7 @@ deps = django==1.5.5 django-oauth2-provider==0.2.3 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.7-django1.4] basepython = python2.7 @@ -120,7 +123,7 @@ deps = django==1.4.10 django-oauth2-provider==0.2.3 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.6-django1.4] basepython = python2.6 @@ -132,7 +135,7 @@ deps = django==1.4.10 django-oauth2-provider==0.2.3 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.7-django1.3] basepython = python2.7 @@ -144,7 +147,7 @@ deps = django==1.3.5 django-oauth2-provider==0.2.3 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.6-django1.3] basepython = python2.6 @@ -156,4 +159,4 @@ deps = django==1.3.5 django-oauth2-provider==0.2.3 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 From c5f68fba0638a15fa3c802f1bafc664e890611dc Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 17 Apr 2014 14:30:33 +0200 Subject: [PATCH 42/72] Fixed the issue with django-filters / django 1.7 / pytest --- conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/conftest.py b/conftest.py index b1691a884..7a49845f9 100644 --- a/conftest.py +++ b/conftest.py @@ -79,3 +79,9 @@ def pytest_configure(): settings.INSTALLED_APPS += ( 'guardian', ) + + try: + import django + django.setup() + except AttributeError: + pass From cd93cd195ef83a443e8fe7d745b2947d2636f4ad Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Wed, 30 Apr 2014 22:32:29 +0200 Subject: [PATCH 43/72] Use url functions from Django itself. --- rest_framework/tests/test_authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/tests/test_authentication.py b/rest_framework/tests/test_authentication.py index 092030578..af292bf17 100644 --- a/rest_framework/tests/test_authentication.py +++ b/rest_framework/tests/test_authentication.py @@ -20,7 +20,7 @@ from rest_framework.authentication import ( OAuth2Authentication ) from rest_framework.authtoken.models import Token -from rest_framework.compat import patterns, url, include, six +from rest_framework.compat import six from rest_framework.compat import oauth2_provider, oauth2_provider_scope from rest_framework.compat import oauth, oauth_provider from rest_framework.test import APIRequestFactory, APIClient From 7475fceacc5bc94fde6212937685ef69ae79c751 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 1 May 2014 00:54:20 +0200 Subject: [PATCH 44/72] Added missing field for the tests. --- rest_framework/tests/test_serializer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rest_framework/tests/test_serializer.py b/rest_framework/tests/test_serializer.py index f0bb112d6..31bd10827 100644 --- a/rest_framework/tests/test_serializer.py +++ b/rest_framework/tests/test_serializer.py @@ -30,6 +30,7 @@ if PIL is not None: image_field = models.ImageField(upload_to='test', max_length=1024, blank=True) slug_field = models.SlugField(max_length=1024, blank=True) url_field = models.URLField(max_length=1024, blank=True) + nullable_char_field = models.CharField(max_length=1024, blank=True, null=True) class DVOAFModel(RESTFrameworkModel): positive_integer_field = models.PositiveIntegerField(blank=True) From 38362bb43a19c287319ccfe0538ce5524f09c633 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 1 May 2014 01:24:48 +0200 Subject: [PATCH 45/72] Fixed new default for many --- rest_framework/tests/test_genericrelations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/tests/test_genericrelations.py b/rest_framework/tests/test_genericrelations.py index 46a2d863f..3a8f3c7f1 100644 --- a/rest_framework/tests/test_genericrelations.py +++ b/rest_framework/tests/test_genericrelations.py @@ -84,7 +84,7 @@ class TestGenericRelations(TestCase): exclude = ('content_type', 'object_id') class BookmarkSerializer(serializers.ModelSerializer): - tags = TagSerializer() + tags = TagSerializer(many=True) class Meta: model = Bookmark From c9e6f31166ebccc5c3bf2f27e12a6d6c87f5cf22 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 1 May 2014 01:27:51 +0200 Subject: [PATCH 46/72] Fixed new default for many --- rest_framework/tests/test_serializer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/tests/test_serializer.py b/rest_framework/tests/test_serializer.py index 31bd10827..44ef8a95c 100644 --- a/rest_framework/tests/test_serializer.py +++ b/rest_framework/tests/test_serializer.py @@ -661,7 +661,7 @@ class ModelValidationTests(TestCase): second_serializer = AlbumsSerializer(data={'title': 'a'}) self.assertFalse(second_serializer.is_valid()) self.assertEqual(second_serializer.errors, {'title': ['Album with this Title already exists.'],}) - third_serializer = AlbumsSerializer(data=[{'title': 'b', 'ref': '1'}, {'title': 'c'}]) + third_serializer = AlbumsSerializer(data=[{'title': 'b', 'ref': '1'}, {'title': 'c'}], many=True) self.assertFalse(third_serializer.is_valid()) self.assertEqual(third_serializer.errors, [{'ref': ['Album with this Ref already exists.']}, {}]) From eb89ed02f247d903db1cdd488d69b316323d9f60 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 1 May 2014 08:36:18 +0200 Subject: [PATCH 47/72] Added missing staticfiles app --- conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/conftest.py b/conftest.py index 7a49845f9..fa5184dd8 100644 --- a/conftest.py +++ b/conftest.py @@ -27,6 +27,7 @@ def pytest_configure(): 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', + 'django.contrib.staticfiles', 'rest_framework', 'rest_framework.authtoken', From e5441d845e34f1e1bb2b7464d31aa3df7b02d0fe Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 1 May 2014 08:41:37 +0200 Subject: [PATCH 48/72] Use urls functions from django instead of compat. --- tests/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/urls.py b/tests/urls.py index 62cad3395..41f527dfd 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,6 +1,6 @@ """ Blank URLConf just to keep the test suite happy """ -from rest_framework.compat import patterns +from django.conf.urls import patterns urlpatterns = patterns('') From 15c2c58b43a00ec29af99e0478b70eea57560fce Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 1 May 2014 08:43:49 +0200 Subject: [PATCH 49/72] Updated the 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 d6256b38f..fd5c7029b 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -40,6 +40,7 @@ You can determine your currently installed version using `pip freeze`: ### 2.4.0 +* Use py.test * `@detail_route` and `@list_route` decorators replace `@action` and `@link`. * `six` no longer bundled. For Django <= 1.4.1, install `six` package. * Support customizable view name and description functions, using the `VIEW_NAME_FUNCTION` and `VIEW_DESCRIPTION_FUNCTION` settings. From 4e33ff05d9aabee0a90bfb0ef8ce58a5d274b9a2 Mon Sep 17 00:00:00 2001 From: Lucian Mocanu Date: Sun, 4 May 2014 00:12:08 +0200 Subject: [PATCH 50/72] Automatically set the field name as value for the HTML `id` attribute on the rendered widget. --- rest_framework/fields.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 8cdc55515..e67338499 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -154,7 +154,12 @@ class Field(object): def widget_html(self): if not self.widget: return '' - return self.widget.render(self._name, self._value) + + attrs = {} + if 'id' not in self.widget.attrs: + attrs['id'] = self._name + + return self.widget.render(self._name, self._value, attrs=attrs) def label_tag(self): return '' % (self._name, self.label) From 708c7b3a816c3c2df7847695044ef852dc89e72c Mon Sep 17 00:00:00 2001 From: Lucian Mocanu Date: Tue, 6 May 2014 14:17:51 +0200 Subject: [PATCH 51/72] Added test case to check if the proper attributes are set on html widgets. --- rest_framework/tests/test_fields.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/rest_framework/tests/test_fields.py b/rest_framework/tests/test_fields.py index e127feef9..03f79cf4d 100644 --- a/rest_framework/tests/test_fields.py +++ b/rest_framework/tests/test_fields.py @@ -4,6 +4,7 @@ General serializer field tests. from __future__ import unicode_literals import datetime +import re from decimal import Decimal from uuid import uuid4 from django.core import validators @@ -103,6 +104,16 @@ class BasicFieldTests(TestCase): keys = list(field.to_native(ret).keys()) self.assertEqual(keys, ['c', 'b', 'a', 'z']) + def test_widget_html_attributes(self): + """ + Make sure widget_html() renders the correct attributes + """ + r = re.compile('(\S+)=["\']?((?:.(?!["\']?\s+(?:\S+)=|[>"\']))+.)["\']?') + form = TimeFieldModelSerializer().data + attributes = r.findall(form.fields['clock'].widget_html()) + self.assertIn(('name', 'clock'), attributes) + self.assertIn(('id', 'clock'), attributes) + class DateFieldTest(TestCase): """ From 8ecb778cd23d5d561f2e9f4a3561bb1647257a89 Mon Sep 17 00:00:00 2001 From: Corey Farwell Date: Sun, 11 May 2014 20:29:01 -0700 Subject: [PATCH 52/72] Enable testing on Python 3.4 --- .travis.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index bd6d2539a..0c9b44553 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ python: - "2.7" - "3.2" - "3.3" + - "3.4" env: - DJANGO="https://www.djangoproject.com/download/1.7b2/tarball/" @@ -41,4 +42,7 @@ matrix: env: DJANGO="django==1.4.11" - python: "3.3" env: DJANGO="django==1.3.7" - + - python: "3.4" + env: DJANGO="django==1.4.11" + - python: "3.4" + env: DJANGO="django==1.3.7" From 768f537dcbb5d4f7429a74556559047bfd6f3078 Mon Sep 17 00:00:00 2001 From: Giorgos Logiotatidis Date: Thu, 15 May 2014 15:34:31 +0300 Subject: [PATCH 53/72] Typo fix. --- docs/api-guide/serializers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index 7ee060af4..0044f0701 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -464,7 +464,7 @@ For more specific requirements such as specifying a different lookup for each fi model = Account fields = ('url', 'account_name', 'users', 'created') -## Overiding the URL field behavior +## Overriding the URL field behavior The name of the URL field defaults to 'url'. You can override this globally, by using the `URL_FIELD_NAME` setting. @@ -478,7 +478,7 @@ You can also override this on a per-serializer basis by using the `url_field_nam **Note**: The generic view implementations normally generate a `Location` header in response to successful `POST` requests. Serializers using `url_field_name` option will not have this header automatically included by the view. If you need to do so you will ned to also override the view's `get_success_headers()` method. -You can also overide the URL field's view name and lookup field without overriding the field explicitly, by using the `view_name` and `lookup_field` options, like so: +You can also override the URL field's view name and lookup field without overriding the field explicitly, by using the `view_name` and `lookup_field` options, like so: class AccountSerializer(serializers.HyperlinkedModelSerializer): class Meta: From e5556079fc2559916d62b766dc9776b03dc4256b Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Fri, 16 May 2014 00:50:16 +0200 Subject: [PATCH 54/72] Updated tox with Python 2.4 --- tox.ini | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index e21210058..35a108e5e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,22 @@ [tox] downloadcache = {toxworkdir}/cache/ -envlist = py3.3-django1.7,py3.2-django1.7,py2.7-django1.7,py3.3-django1.6,py3.2-django1.6,py2.7-django1.6,py2.6-django1.6,py3.3-django1.5,py3.2-django1.5,py2.7-django1.5,py2.6-django1.5,py2.7-django1.4,py2.6-django1.4,py2.7-django1.3,py2.6-django1.3 +envlist = + py3.4-django1.7,py3.3-django1.7,py3.2-django1.7,py2.7-django1.7, + py3.4-django1.6,py3.3-django1.6,py3.2-django1.6,py2.7-django1.6,py2.6-django1.6, + py3.4-django1.5,py3.3-django1.5,py3.2-django1.5,py2.7-django1.5,py2.6-django1.5, + py2.7-django1.4,py2.6-django1.4, + py2.7-django1.3,py2.6-django1.3 [testenv] commands = {envpython} rest_framework/runtests/runtests.py +[testenv:py3.4-django1.7] +basepython = python3.4 +deps = https://www.djangoproject.com/download/1.7b2/tarball/ + django-filter==0.7 + defusedxml==0.3 + Pillow==2.3.0 + [testenv:py3.3-django1.7] basepython = python3.3 deps = https://www.djangoproject.com/download/1.7b2/tarball/ @@ -30,6 +42,13 @@ deps = https://www.djangoproject.com/download/1.7b2/tarball/ django-guardian==1.1.1 Pillow==2.3.0 +[testenv:py3.4-django1.6] +basepython = python3.3 +deps = Django==1.6.3 + django-filter==0.7 + defusedxml==0.3 + Pillow==2.3.0 + [testenv:py3.3-django1.6] basepython = python3.3 deps = Django==1.6.3 @@ -66,6 +85,13 @@ deps = Django==1.6.3 django-guardian==1.1.1 Pillow==2.3.0 +[testenv:py3.4-django1.5] +basepython = python3.3 +deps = django==1.5.6 + django-filter==0.7 + defusedxml==0.3 + Pillow==2.3.0 + [testenv:py3.3-django1.5] basepython = python3.3 deps = django==1.5.6 From b370fb40b6bc0fd3f597fb8c2db59f0ca57a7ccd Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Fri, 16 May 2014 01:06:34 +0200 Subject: [PATCH 55/72] Typo in the Python version. --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 35a108e5e..279f79cc4 100644 --- a/tox.ini +++ b/tox.ini @@ -43,7 +43,7 @@ deps = https://www.djangoproject.com/download/1.7b2/tarball/ Pillow==2.3.0 [testenv:py3.4-django1.6] -basepython = python3.3 +basepython = python3.4 deps = Django==1.6.3 django-filter==0.7 defusedxml==0.3 @@ -86,7 +86,7 @@ deps = Django==1.6.3 Pillow==2.3.0 [testenv:py3.4-django1.5] -basepython = python3.3 +basepython = python3.4 deps = django==1.5.6 django-filter==0.7 defusedxml==0.3 From a704d5a206238c65765c1f02eb053e461675dda2 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Fri, 16 May 2014 01:20:40 +0200 Subject: [PATCH 56/72] Fixed tests for python 3.4 --- rest_framework/tests/test_views.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/rest_framework/tests/test_views.py b/rest_framework/tests/test_views.py index 65c7e50ea..77b113ee5 100644 --- a/rest_framework/tests/test_views.py +++ b/rest_framework/tests/test_views.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import sys import copy from django.test import TestCase from rest_framework import status @@ -11,6 +12,11 @@ from rest_framework.views import APIView factory = APIRequestFactory() +if sys.version_info[:2] >= (3, 4): + JSON_ERROR = 'JSON parse error - Expecting value:' +else: + JSON_ERROR = 'JSON parse error - No JSON object could be decoded' + class BasicView(APIView): def get(self, request, *args, **kwargs): @@ -48,7 +54,7 @@ def sanitise_json_error(error_dict): of json. """ ret = copy.copy(error_dict) - chop = len('JSON parse error - No JSON object could be decoded') + chop = len(JSON_ERROR) ret['detail'] = ret['detail'][:chop] return ret @@ -61,7 +67,7 @@ class ClassBasedViewIntegrationTests(TestCase): request = factory.post('/', 'f00bar', content_type='application/json') response = self.view(request) expected = { - 'detail': 'JSON parse error - No JSON object could be decoded' + 'detail': JSON_ERROR } self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(sanitise_json_error(response.data), expected) @@ -76,7 +82,7 @@ class ClassBasedViewIntegrationTests(TestCase): request = factory.post('/', form_data) response = self.view(request) expected = { - 'detail': 'JSON parse error - No JSON object could be decoded' + 'detail': JSON_ERROR } self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(sanitise_json_error(response.data), expected) @@ -90,7 +96,7 @@ class FunctionBasedViewIntegrationTests(TestCase): request = factory.post('/', 'f00bar', content_type='application/json') response = self.view(request) expected = { - 'detail': 'JSON parse error - No JSON object could be decoded' + 'detail': JSON_ERROR } self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(sanitise_json_error(response.data), expected) @@ -105,7 +111,7 @@ class FunctionBasedViewIntegrationTests(TestCase): request = factory.post('/', form_data) response = self.view(request) expected = { - 'detail': 'JSON parse error - No JSON object could be decoded' + 'detail': JSON_ERROR } self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(sanitise_json_error(response.data), expected) From 5c12b0768166376783d62632e562f0c1301ee847 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Fri, 16 May 2014 19:40:02 +0200 Subject: [PATCH 57/72] Added missing import. --- rest_framework/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 2a0d5263e..6dd09f68b 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -21,6 +21,7 @@ from django.core.paginator import Page from django.db import models from django.forms import widgets from django.utils.datastructures import SortedDict +from django.core.exceptions import ObjectDoesNotExist from rest_framework.compat import get_concrete_model, six from rest_framework.settings import api_settings From a2e1024f8b0447a712d1f486172d38cfe56535fe Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Sun, 18 May 2014 09:27:23 +0200 Subject: [PATCH 58/72] Updated Django versions. --- .travis.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0c9b44553..638d14998 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,10 +8,10 @@ python: - "3.4" env: - - DJANGO="https://www.djangoproject.com/download/1.7b2/tarball/" - - DJANGO="django==1.6.3" - - DJANGO="django==1.5.6" - - DJANGO="django==1.4.11" + - DJANGO="https://www.djangoproject.com/download/1.7b4/tarball/" + - DJANGO="django==1.6.5" + - DJANGO="django==1.5.8" + - DJANGO="django==1.4.13" - DJANGO="django==1.3.7" install: @@ -24,7 +24,7 @@ install: - "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install django-filter==0.5.4; fi" - "if [[ ${DJANGO::11} != 'django==1.3' ]]; then pip install django-filter==0.7; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} == '3' ]]; then pip install -e git+https://github.com/linovia/django-guardian.git@feature/django_1_7#egg=django-guardian-1.2.0; fi" - - "if [[ ${DJANGO} == 'https://www.djangoproject.com/download/1.7b2/tarball/' ]]; then pip install -e git+https://github.com/linovia/django-guardian.git@feature/django_1_7#egg=django-guardian-1.2.0; fi" + - "if [[ ${DJANGO} == 'https://www.djangoproject.com/download/1.7b4/tarball/' ]]; then pip install -e git+https://github.com/linovia/django-guardian.git@feature/django_1_7#egg=django-guardian-1.2.0; fi" - export PYTHONPATH=. script: @@ -33,16 +33,16 @@ script: matrix: exclude: - python: "2.6" - env: DJANGO="https://www.djangoproject.com/download/1.7b2/tarball/" + env: DJANGO="https://www.djangoproject.com/download/1.7b4/tarball/" - python: "3.2" - env: DJANGO="django==1.4.11" + env: DJANGO="django==1.4.13" - python: "3.2" env: DJANGO="django==1.3.7" - python: "3.3" - env: DJANGO="django==1.4.11" + env: DJANGO="django==1.4.13" - python: "3.3" env: DJANGO="django==1.3.7" - python: "3.4" - env: DJANGO="django==1.4.11" + env: DJANGO="django==1.4.13" - python: "3.4" env: DJANGO="django==1.3.7" From af1ee3e63175d2b1fd30ab18091bed1019ac5de6 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Sun, 18 May 2014 09:38:46 +0200 Subject: [PATCH 59/72] Fixed a small change in the 1.7 beta url. --- .travis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 638d14998..b2da9e816 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,7 @@ python: - "3.4" env: - - DJANGO="https://www.djangoproject.com/download/1.7b4/tarball/" + - DJANGO="https://www.djangoproject.com/download/1.7.b4/tarball/" - DJANGO="django==1.6.5" - DJANGO="django==1.5.8" - DJANGO="django==1.4.13" @@ -24,7 +24,7 @@ install: - "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install django-filter==0.5.4; fi" - "if [[ ${DJANGO::11} != 'django==1.3' ]]; then pip install django-filter==0.7; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} == '3' ]]; then pip install -e git+https://github.com/linovia/django-guardian.git@feature/django_1_7#egg=django-guardian-1.2.0; fi" - - "if [[ ${DJANGO} == 'https://www.djangoproject.com/download/1.7b4/tarball/' ]]; then pip install -e git+https://github.com/linovia/django-guardian.git@feature/django_1_7#egg=django-guardian-1.2.0; fi" + - "if [[ ${DJANGO} == 'https://www.djangoproject.com/download/1.7.b4/tarball/' ]]; then pip install -e git+https://github.com/linovia/django-guardian.git@feature/django_1_7#egg=django-guardian-1.2.0; fi" - export PYTHONPATH=. script: @@ -33,7 +33,7 @@ script: matrix: exclude: - python: "2.6" - env: DJANGO="https://www.djangoproject.com/download/1.7b4/tarball/" + env: DJANGO="https://www.djangoproject.com/download/1.7.b4/tarball/" - python: "3.2" env: DJANGO="django==1.4.13" - python: "3.2" From a1a3ad763996b9ab5535bc5d442c2d6fab10b7cc Mon Sep 17 00:00:00 2001 From: allenhu Date: Sat, 17 May 2014 06:05:33 +0800 Subject: [PATCH 60/72] fix pep8 --- rest_framework/serializers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 6dd09f68b..87d20cfce 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -33,8 +33,8 @@ from rest_framework.settings import api_settings # This helps keep the separation between model fields, form fields, and # serializer fields more explicit. -from rest_framework.relations import * -from rest_framework.fields import * +from rest_framework.relations import * # NOQA +from rest_framework.fields import * # NOQA def _resolve_model(obj): @@ -345,7 +345,7 @@ class BaseSerializer(WritableField): for field_name, field in self.fields.items(): if field.read_only and obj is None: - continue + continue field.initialize(parent=self, field_name=field_name) key = self.get_field_key(field_name) value = field.field_to_native(obj, field_name) From 1e7b5fd2c04e587e30cf29e15ca3074b8d33b92e Mon Sep 17 00:00:00 2001 From: Ian Foote Date: Tue, 20 May 2014 14:55:00 +0100 Subject: [PATCH 61/72] Document ChoiceField blank_display_value parameter --- docs/api-guide/fields.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index 67fa65d2d..58dbf977e 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -184,7 +184,9 @@ Corresponds to `django.db.models.fields.SlugField`. ## ChoiceField -A field that can accept a value out of a limited set of choices. +A field that can accept a value out of a limited set of choices. Optionally takes a `blank_display_value` parameter that customizes the display value of an empty choice. + +**Signature:** `ChoiceField(choices=(), blank_display_value=None)` ## EmailField From 04c820b8e5e4ae153eacd1cbf19b39286c374e87 Mon Sep 17 00:00:00 2001 From: John Spray Date: Thu, 22 May 2014 15:24:35 +0100 Subject: [PATCH 62/72] fields: allow help_text on SerializerMethodField ...by passing through any extra *args and **kwargs to the parent constructor. Previously one couldn't assign help_text to a SerializerMethodField during construction. --- rest_framework/fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 2da895500..4ac5285e8 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -1027,9 +1027,9 @@ class SerializerMethodField(Field): A field that gets its value by calling a method on the serializer it's attached to. """ - def __init__(self, method_name): + def __init__(self, method_name, *args, **kwargs): self.method_name = method_name - super(SerializerMethodField, self).__init__() + super(SerializerMethodField, self).__init__(*args, **kwargs) def field_to_native(self, obj, field_name): value = getattr(self.parent, self.method_name)(obj) From 807f7a6bb9e36321f3487b5ac31ef5fdc8f4b3fb Mon Sep 17 00:00:00 2001 From: Piper Merriam Date: Thu, 22 May 2014 13:51:20 -0600 Subject: [PATCH 63/72] Fix _resolve_model to work with unicode strings --- rest_framework/serializers.py | 14 +++++++------- rest_framework/tests/test_serializers.py | 5 +++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 87d20cfce..c2b414d7a 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -49,7 +49,7 @@ def _resolve_model(obj): String representations should have the format: 'appname.ModelName' """ - if type(obj) == str and len(obj.split('.')) == 2: + if isinstance(obj, six.string_types) and len(obj.split('.')) == 2: app_name, model_name = obj.split('.') return models.get_model(app_name, model_name) elif inspect.isclass(obj) and issubclass(obj, models.Model): @@ -759,9 +759,9 @@ class ModelSerializer(Serializer): field.read_only = True ret[accessor_name] = field - + # Ensure that 'read_only_fields' is an iterable - assert isinstance(self.opts.read_only_fields, (list, tuple)), '`read_only_fields` must be a list or tuple' + assert isinstance(self.opts.read_only_fields, (list, tuple)), '`read_only_fields` must be a list or tuple' # Add the `read_only` flag to any fields that have been specified # in the `read_only_fields` option @@ -776,10 +776,10 @@ class ModelSerializer(Serializer): "on serializer '%s'." % (field_name, self.__class__.__name__)) ret[field_name].read_only = True - + # Ensure that 'write_only_fields' is an iterable - assert isinstance(self.opts.write_only_fields, (list, tuple)), '`write_only_fields` must be a list or tuple' - + assert isinstance(self.opts.write_only_fields, (list, tuple)), '`write_only_fields` must be a list or tuple' + for field_name in self.opts.write_only_fields: assert field_name not in self.base_fields.keys(), ( "field '%s' on serializer '%s' specified in " @@ -790,7 +790,7 @@ class ModelSerializer(Serializer): "Non-existant field '%s' specified in `write_only_fields` " "on serializer '%s'." % (field_name, self.__class__.__name__)) - ret[field_name].write_only = True + ret[field_name].write_only = True return ret diff --git a/rest_framework/tests/test_serializers.py b/rest_framework/tests/test_serializers.py index 082a400ca..120510ace 100644 --- a/rest_framework/tests/test_serializers.py +++ b/rest_framework/tests/test_serializers.py @@ -3,6 +3,7 @@ from django.test import TestCase from rest_framework.serializers import _resolve_model from rest_framework.tests.models import BasicModel +from rest_framework.compat import six class ResolveModelTests(TestCase): @@ -19,6 +20,10 @@ class ResolveModelTests(TestCase): resolved_model = _resolve_model('tests.BasicModel') self.assertEqual(resolved_model, BasicModel) + def test_resolve_unicode_representation(self): + resolved_model = _resolve_model(six.text_type('tests.BasicModel')) + self.assertEqual(resolved_model, BasicModel) + def test_resolve_non_django_model(self): with self.assertRaises(ValueError): _resolve_model(TestCase) From eab5933070d5df9078a6b88e85ee933cbfa28955 Mon Sep 17 00:00:00 2001 From: khamaileon Date: Mon, 26 May 2014 18:43:50 +0200 Subject: [PATCH 64/72] Add the allow_add_remove parameter to the get_serializer method --- docs/api-guide/generic-views.md | 2 +- rest_framework/generics.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/api-guide/generic-views.md b/docs/api-guide/generic-views.md index 7d06f246c..bb748981e 100755 --- a/docs/api-guide/generic-views.md +++ b/docs/api-guide/generic-views.md @@ -187,7 +187,7 @@ Remember that the `pre_save()` method is not called by `GenericAPIView` itself, You won't typically need to override the following methods, although you might need to call into them if you're writing custom views using `GenericAPIView`. * `get_serializer_context(self)` - Returns a dictionary containing any extra context that should be supplied to the serializer. Defaults to including `'request'`, `'view'` and `'format'` keys. -* `get_serializer(self, instance=None, data=None, files=None, many=False, partial=False)` - Returns a serializer instance. +* `get_serializer(self, instance=None, data=None, files=None, many=False, partial=False, allow_add_remove=False)` - Returns a serializer instance. * `get_pagination_serializer(self, page)` - Returns a serializer instance to use with paginated data. * `paginate_queryset(self, queryset)` - Paginate a queryset if required, either returning a page object, or `None` if pagination is not configured for this view. * `filter_queryset(self, queryset)` - Given a queryset, filter it with whichever filter backends are in use, returning a new queryset. diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 7bac510f7..7fc9db364 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -90,8 +90,8 @@ class GenericAPIView(views.APIView): 'view': self } - def get_serializer(self, instance=None, data=None, - files=None, many=False, partial=False): + def get_serializer(self, instance=None, data=None, files=None, many=False, + partial=False, allow_add_remove=False): """ Return the serializer instance that should be used for validating and deserializing input, and for serializing output. @@ -99,7 +99,9 @@ class GenericAPIView(views.APIView): serializer_class = self.get_serializer_class() context = self.get_serializer_context() return serializer_class(instance, data=data, files=files, - many=many, partial=partial, context=context) + many=many, partial=partial, + allow_add_remove=allow_add_remove, + context=context) def get_pagination_serializer(self, page): """ From a7ff51118f8c8d696219ea7723b283a0ee680457 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 29 May 2014 14:33:16 +0100 Subject: [PATCH 65/72] Note on configuring TokenAuthentication --- docs/api-guide/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 88a7a0119..1cb37d67f 100755 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -119,7 +119,7 @@ Unauthenticated responses that are denied permission will result in an `HTTP 401 This authentication scheme uses a simple token-based HTTP Authentication scheme. Token authentication is appropriate for client-server setups, such as native desktop and mobile clients. -To use the `TokenAuthentication` scheme, include `rest_framework.authtoken` in your `INSTALLED_APPS` setting: +To use the `TokenAuthentication` scheme you'll need to [configure the authentication classes](#setting-the-authentication-scheme) to include `TokenAuthentication`, and additionally include `rest_framework.authtoken` in your `INSTALLED_APPS` setting: INSTALLED_APPS = ( ... From 6cb6bfae1b83c8682fa3c3d208c732c8ea49606e Mon Sep 17 00:00:00 2001 From: Danilo Bargen Date: Fri, 30 May 2014 17:53:26 +0200 Subject: [PATCH 66/72] Always use specified content type in APIRequestFactory If `content_type` is specified in the `APIRequestFactory`, always include it in the request, even if data is empty. --- rest_framework/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/test.py b/rest_framework/test.py index df5a5b3b3..284bcee07 100644 --- a/rest_framework/test.py +++ b/rest_framework/test.py @@ -36,7 +36,7 @@ class APIRequestFactory(DjangoRequestFactory): """ if not data: - return ('', None) + return ('', content_type) assert format is None or content_type is None, ( 'You may not set both `format` and `content_type`.' From 31f63e1e5502d45f414df400679c238346137b10 Mon Sep 17 00:00:00 2001 From: Rodolfo Carvalho Date: Mon, 2 Jun 2014 11:06:03 +0200 Subject: [PATCH 67/72] Fix typo in docs --- docs/api-guide/viewsets.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index 23b16575f..b3085f75c 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -137,7 +137,7 @@ The `@action` and `@link` decorators can additionally take extra arguments that def set_password(self, request, pk=None): ... -The `@action` decorator will route `POST` requests by default, but may also accept other HTTP methods, by using the `method` argument. For example: +The `@action` decorator will route `POST` requests by default, but may also accept other HTTP methods, by using the `methods` argument. For example: @action(methods=['POST', 'DELETE']) def unset_password(self, request, pk=None): From 08c4594145a7219a14fafc87db0b9d61483d70d0 Mon Sep 17 00:00:00 2001 From: khamaileon Date: Thu, 5 Jun 2014 12:49:02 +0200 Subject: [PATCH 68/72] Replace ChoiceField type_label --- rest_framework/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 4ac5285e8..86e8fd9df 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -506,7 +506,7 @@ class SlugField(CharField): class ChoiceField(WritableField): type_name = 'ChoiceField' - type_label = 'multiple choice' + type_label = 'choice' form_field_class = forms.ChoiceField widget = widgets.Select default_error_messages = { From e8ec81f5e985f9cc9f524f77ec23013be918b990 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Sun, 8 Jun 2014 09:03:21 +0200 Subject: [PATCH 69/72] Fixed #1624 (thanks @abraithwaite) --- rest_framework/compat.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index d155f5542..fdf12448a 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -51,6 +51,7 @@ except ImportError: # guardian is optional try: import guardian + import guardian.shortcuts # Fixes #1624 except ImportError: guardian = None From be84f71bc906c926c9955a4cf47630b24461067d Mon Sep 17 00:00:00 2001 From: Greg Barker Date: Tue, 10 Jun 2014 15:20:45 -0700 Subject: [PATCH 70/72] Fix #1614 - Corrected reference to serializers.CharField --- docs/api-guide/serializers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index 0044f0701..cedf1ff7b 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -73,8 +73,8 @@ Sometimes when serializing objects, you may not want to represent everything exa If you need to customize the serialized value of a particular field, you can do this by creating a `transform_` method. For example if you needed to render some markdown from a text field: - description = serializers.TextField() - description_html = serializers.TextField(source='description', read_only=True) + description = serializers.CharField() + description_html = serializers.CharField(source='description', read_only=True) def transform_description_html(self, obj, value): from django.contrib.markup.templatetags.markup import markdown From 1386767013d044d337b8e08dd2f9b0197197cccf Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 12 Jun 2014 11:47:26 +0100 Subject: [PATCH 71/72] Version 2.3.14 --- docs/api-guide/content-negotiation.md | 2 -- docs/topics/release-notes.md | 36 ++++++++++--------- rest_framework/__init__.py | 2 +- rest_framework/templatetags/rest_framework.py | 4 +-- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/api-guide/content-negotiation.md b/docs/api-guide/content-negotiation.md index 94dd59cac..58b2a2ce0 100644 --- a/docs/api-guide/content-negotiation.md +++ b/docs/api-guide/content-negotiation.md @@ -1,5 +1,3 @@ - - # Content negotiation > HTTP has provisions for several mechanisms for "content negotiation" - the process of selecting the best representation for a given response when there are multiple representations available. diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 335497eec..ea4c912c9 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -40,24 +40,28 @@ You can determine your currently installed version using `pip freeze`: ## 2.3.x series -### 2.3.x +### 2.3.14 -**Date**: April 2014 +**Date**: 12th June 2014 -* Fix nested serializers linked through a backward foreign key relation -* Fix bad links for the `BrowsableAPIRenderer` with `YAMLRenderer` -* Add `UnicodeYAMLRenderer` that extends `YAMLRenderer` with unicode -* Fix `parse_header` argument convertion -* Fix mediatype detection under Python3 -* Web browseable API now offers blank option on dropdown when the field is not required -* `APIException` representation improved for logging purposes -* Allow source="*" within nested serializers -* Better support for custom oauth2 provider backends -* Fix field validation if it's optional and has no value -* Add `SEARCH_PARAM` and `ORDERING_PARAM` -* Fix `APIRequestFactory` to support arguments within the url string for GET -* Allow three transport modes for access tokens when accessing a protected resource -* Fix `Request`'s `QueryDict` encoding +* **Security fix**: Escape request path when it is include as part of the login and logout links in the browsable API. +* `help_text` and `verbose_name` automatically set for related fields on `ModelSerializer`. +* Fix nested serializers linked through a backward foreign key relation. +* Fix bad links for the `BrowsableAPIRenderer` with `YAMLRenderer`. +* Add `UnicodeYAMLRenderer` that extends `YAMLRenderer` with unicode. +* Fix `parse_header` argument convertion. +* Fix mediatype detection under Python 3. +* Web browseable API now offers blank option on dropdown when the field is not required. +* `APIException` representation improved for logging purposes. +* Allow source="*" within nested serializers. +* Better support for custom oauth2 provider backends. +* Fix field validation if it's optional and has no value. +* Add `SEARCH_PARAM` and `ORDERING_PARAM`. +* Fix `APIRequestFactory` to support arguments within the url string for GET. +* Allow three transport modes for access tokens when accessing a protected resource. +* Fix `QueryDict` encoding on request objects. +* Ensure throttle keys do not contain spaces, as those are invalid if using `memcached`. +* Support `blank_display_value` on `ChoiceField`. ### 2.3.13 diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index 2d76b55d5..01036cefa 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -8,7 +8,7 @@ ______ _____ _____ _____ __ _ """ __title__ = 'Django REST framework' -__version__ = '2.3.13' +__version__ = '2.3.14' __author__ = 'Tom Christie' __license__ = 'BSD 2-Clause' __copyright__ = 'Copyright 2011-2014 Tom Christie' diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index dff176d62..a155d8d25 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -122,7 +122,7 @@ def optional_login(request): except NoReverseMatch: return '' - snippet = "Log in" % (login_url, request.path) + snippet = "Log in" % (login_url, escape(request.path)) return snippet @@ -136,7 +136,7 @@ def optional_logout(request): except NoReverseMatch: return '' - snippet = "Log out" % (logout_url, request.path) + snippet = "Log out" % (logout_url, escape(request.path)) return snippet From 82659873c9d3e3058b7e7ea63e4c4b190c7fc19c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 12 Jun 2014 11:48:58 +0100 Subject: [PATCH 72/72] Fix accidental docs change --- docs/api-guide/content-negotiation.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/api-guide/content-negotiation.md b/docs/api-guide/content-negotiation.md index 58b2a2ce0..94dd59cac 100644 --- a/docs/api-guide/content-negotiation.md +++ b/docs/api-guide/content-negotiation.md @@ -1,3 +1,5 @@ + + # Content negotiation > HTTP has provisions for several mechanisms for "content negotiation" - the process of selecting the best representation for a given response when there are multiple representations available.