diff options
| -rw-r--r-- | .travis.yml | 19 | ||||
| -rwxr-xr-x | docs/api-guide/authentication.md | 2 | ||||
| -rw-r--r-- | docs/api-guide/fields.md | 4 | ||||
| -rwxr-xr-x | docs/api-guide/generic-views.md | 2 | ||||
| -rw-r--r-- | docs/api-guide/serializers.md | 8 | ||||
| -rw-r--r-- | docs/api-guide/viewsets.md | 2 | ||||
| -rw-r--r-- | docs/topics/release-notes.md | 40 | ||||
| -rw-r--r-- | docs/tutorial/1-serialization.md | 4 | ||||
| -rw-r--r-- | docs/tutorial/4-authentication-and-permissions.md | 4 | ||||
| -rw-r--r-- | docs/tutorial/6-viewsets-and-routers.md | 6 | ||||
| -rw-r--r-- | rest_framework/__init__.py | 2 | ||||
| -rw-r--r-- | rest_framework/authtoken/serializers.py | 11 | ||||
| -rw-r--r-- | rest_framework/compat.py | 1 | ||||
| -rw-r--r-- | rest_framework/fields.py | 23 | ||||
| -rw-r--r-- | rest_framework/generics.py | 8 | ||||
| -rw-r--r-- | rest_framework/serializers.py | 30 | ||||
| -rw-r--r-- | rest_framework/templatetags/rest_framework.py | 4 | ||||
| -rw-r--r-- | rest_framework/test.py | 2 | ||||
| -rw-r--r-- | rest_framework/throttling.py | 6 | ||||
| -rw-r--r-- | tests/models.py | 3 | ||||
| -rw-r--r-- | tests/test_fields.py | 24 | ||||
| -rw-r--r-- | tests/test_generics.py | 74 | ||||
| -rw-r--r-- | tests/test_serializers.py | 5 | ||||
| -rw-r--r-- | tests/test_views.py | 16 | ||||
| -rw-r--r-- | tox.ini | 30 | 
25 files changed, 253 insertions, 77 deletions
| diff --git a/.travis.yml b/.travis.yml index 44597d9e..01f3209e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,12 +5,13 @@ python:    - "2.7"    - "3.2"    - "3.3" +  - "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.7.b4/tarball/" +  - DJANGO="django==1.6.5" +  - DJANGO="django==1.5.8" +  - DJANGO="django==1.4.13"  install:    - pip install $DJANGO @@ -23,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.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: @@ -32,8 +33,10 @@ script:  matrix:    exclude:      - python: "2.6" -      env: DJANGO="https://www.djangoproject.com/download/1.7b2/tarball/" +      env: DJANGO="https://www.djangoproject.com/download/1.7.b4/tarball/"      - python: "3.2" -      env: DJANGO="django==1.4.11" +      env: DJANGO="django==1.4.13"      - python: "3.3" -      env: DJANGO="django==1.4.11" +      env: DJANGO="django==1.4.13" +    - python: "3.4" +      env: DJANGO="django==1.4.13" diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 88a7a011..1cb37d67 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 = (          ... diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index 883734a3..4825d9c8 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -185,7 +185,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 diff --git a/docs/api-guide/generic-views.md b/docs/api-guide/generic-views.md index 7d06f246..bb748981 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/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index 7ee060af..cedf1ff7 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_<fieldname>` 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 @@ -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: diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index 161fd2a9..dc5d01a2 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -146,7 +146,7 @@ The decorators can additionally take extra arguments that will be set for the ro          def set_password(self, request, pk=None):             ... -By default, the decorators will route `GET` requests, but may also accept other HTTP methods, by using the `methods` 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:          @detail_route(methods=['post', 'delete'])          def unset_password(self, request, pk=None): diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index fd5c7029..f6bbb815 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -52,24 +52,28 @@ You can determine your currently installed version using `pip freeze`:  ## 2.3.x series -### 2.3.x - -**Date**: April 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 +### 2.3.14 + +**Date**: 12th June 2014 + +* **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  ## 2.3.x series diff --git a/docs/tutorial/1-serialization.md b/docs/tutorial/1-serialization.md index 979c4a3e..55b19457 100644 --- a/docs/tutorial/1-serialization.md +++ b/docs/tutorial/1-serialization.md @@ -104,7 +104,7 @@ Don't forget to sync the database for the first time.  ## Creating a Serializer class -The first thing we need to get started on our Web API is provide a way of serializing and deserializing the snippet instances into representations such as `json`.  We can do this by declaring serializers that work very similar to Django's forms.  Create a file in the `snippets` directory named `serializers.py` and add the following. +The first thing we need to get started on our Web API is to provide a way of serializing and deserializing the snippet instances into representations such as `json`.  We can do this by declaring serializers that work very similar to Django's forms.  Create a file in the `snippets` directory named `serializers.py` and add the following.      from django.forms import widgets      from rest_framework import serializers @@ -143,7 +143,7 @@ The first thing we need to get started on our Web API is provide a way of serial              # Create new instance              return Snippet(**attrs) -The first part of serializer class defines the fields that get serialized/deserialized.  The `restore_object` method defines how fully fledged instances get created when deserializing data. +The first part of the serializer class defines the fields that get serialized/deserialized.  The `restore_object` method defines how fully fledged instances get created when deserializing data.  Notice that we can also use various attributes that would typically be used on form fields, such as `widget=widgets.Textarea`.  These can be used to control how the serializer should render when displayed as an HTML form.  This is particularly useful for controlling how the browsable API should be displayed, as we'll see later in the tutorial. diff --git a/docs/tutorial/4-authentication-and-permissions.md b/docs/tutorial/4-authentication-and-permissions.md index 432371f3..491df160 100644 --- a/docs/tutorial/4-authentication-and-permissions.md +++ b/docs/tutorial/4-authentication-and-permissions.md @@ -44,11 +44,11 @@ When that's all done we'll need to update our database tables.  Normally we'd create a database migration in order to do that, but for the purposes of this tutorial, let's just delete the database and start again.      rm tmp.db -    python ./manage.py syncdb +    python manage.py syncdb  You might also want to create a few different users, to use for testing the API.  The quickest way to do this will be with the `createsuperuser` command. -    python ./manage.py createsuperuser +    python manage.py createsuperuser  ## Adding endpoints for our User models diff --git a/docs/tutorial/6-viewsets-and-routers.md b/docs/tutorial/6-viewsets-and-routers.md index 3b9fd7d4..b2019520 100644 --- a/docs/tutorial/6-viewsets-and-routers.md +++ b/docs/tutorial/6-viewsets-and-routers.md @@ -21,7 +21,7 @@ First of all let's refactor our `UserList` and `UserDetail` views into a single          queryset = User.objects.all()          serializer_class = UserSerializer -Here we've used `ReadOnlyModelViewSet` class to automatically provide the default 'read-only' operations.  We're still setting the `queryset` and `serializer_class` attributes exactly as we did when we were using regular views, but we no longer need to provide the same information to two separate classes. +Here we've used the `ReadOnlyModelViewSet` class to automatically provide the default 'read-only' operations.  We're still setting the `queryset` and `serializer_class` attributes exactly as we did when we were using regular views, but we no longer need to provide the same information to two separate classes.  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. @@ -85,7 +85,7 @@ In the `urls.py` file we bind our `ViewSet` classes into a set of concrete views  Notice how we're creating multiple views from each `ViewSet` class, by binding the http methods to the required action for each view. -Now that we've bound our resources into concrete views, that we can register the views with the URL conf as usual. +Now that we've bound our resources into concrete views, we can register the views with the URL conf as usual.      urlpatterns = format_suffix_patterns(patterns('snippets.views',          url(r'^$', 'api_root'), @@ -138,7 +138,7 @@ You can review the final [tutorial code][repo] on GitHub, or try out a live exam  ## Onwards and upwards -We've reached the end of our tutorial.  If you want to get more involved in the REST framework project, here's a few places you can start: +We've reached the end of our tutorial.  If you want to get more involved in the REST framework project, here are a few places you can start:  * Contribute on [GitHub][github] by reviewing and submitting issues, and making pull requests.  * Join the [REST framework discussion group][group], and help build the community. diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index 2d76b55d..01036cef 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/authtoken/serializers.py b/rest_framework/authtoken/serializers.py index 60a3740e..99e99ae3 100644 --- a/rest_framework/authtoken/serializers.py +++ b/rest_framework/authtoken/serializers.py @@ -1,4 +1,6 @@  from django.contrib.auth import authenticate +from django.utils.translation import ugettext_lazy as _ +  from rest_framework import serializers @@ -15,10 +17,13 @@ class AuthTokenSerializer(serializers.Serializer):              if user:                  if not user.is_active: -                    raise serializers.ValidationError('User account is disabled.') +                    msg = _('User account is disabled.') +                    raise serializers.ValidationError(msg)                  attrs['user'] = user                  return attrs              else: -                raise serializers.ValidationError('Unable to login with provided credentials.') +                msg = _('Unable to login with provided credentials.') +                raise serializers.ValidationError(msg)          else: -            raise serializers.ValidationError('Must include "username" and "password"') +            msg = _('Must include "username" and "password"') +            raise serializers.ValidationError(msg) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index a013a155..6a5cbbe4 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -47,6 +47,7 @@ except ImportError:  # django-guardian is optional  try:      import guardian +    import guardian.shortcuts  # Fixes #1624  except ImportError:      guardian = None diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 8eaf763d..e925c47e 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -64,7 +64,7 @@ def get_component(obj, attr_name):  def readable_datetime_formats(formats):      format = ', '.join(formats).replace(ISO_8601, -             'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HHMM|-HHMM|Z]') +             'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]')      return humanize_strptime(format) @@ -156,7 +156,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 '<label for="%s">%s:</label>' % (self._name, self.label) @@ -499,7 +504,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 = { @@ -507,12 +512,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 @@ -1016,9 +1025,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) diff --git a/rest_framework/generics.py b/rest_framework/generics.py index c3256844..e38c52b1 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):          """ diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 9709d56d..a15b8f22 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 six  from rest_framework.settings import api_settings @@ -32,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): @@ -48,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): @@ -344,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) @@ -753,9 +754,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 @@ -770,10 +771,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 " @@ -784,7 +785,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 @@ -828,6 +829,15 @@ class ModelSerializer(Serializer):              if model_field.verbose_name is not None:                  kwargs['label'] = model_field.verbose_name +            if not model_field.editable: +                kwargs['read_only'] = True + +            if model_field.verbose_name is not None: +                kwargs['label'] = model_field.verbose_name + +            if model_field.help_text is not None: +                kwargs['help_text'] = model_field.help_text +          return PrimaryKeyRelatedField(**kwargs)      def get_field(self, model_field): diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index 886db63c..911b1b62 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -40,7 +40,7 @@ def optional_login(request):      except NoReverseMatch:          return '' -    snippet = "<a href='%s?next=%s'>Log in</a>" % (login_url, request.path) +    snippet = "<a href='%s?next=%s'>Log in</a>" % (login_url, escape(request.path))      return snippet @@ -54,7 +54,7 @@ def optional_logout(request):      except NoReverseMatch:          return '' -    snippet = "<a href='%s?next=%s'>Log out</a>" % (logout_url, request.path) +    snippet = "<a href='%s?next=%s'>Log out</a>" % (logout_url, escape(request.path))      return snippet diff --git a/rest_framework/test.py b/rest_framework/test.py index df5a5b3b..284bcee0 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`.' diff --git a/rest_framework/throttling.py b/rest_framework/throttling.py index fc24c92e..7e9f9d71 100644 --- a/rest_framework/throttling.py +++ b/rest_framework/throttling.py @@ -173,6 +173,12 @@ 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') +        else: +            ident = ''.join(ident.split()) +          return self.cache_format % {              'scope': self.scope,              'ident': self.get_ident(request) diff --git a/tests/models.py b/tests/models.py index 0256697a..e171d3bd 100644 --- a/tests/models.py +++ b/tests/models.py @@ -151,7 +151,8 @@ class ForeignKeySource(RESTFrameworkModel):  class NullableForeignKeySource(RESTFrameworkModel):      name = models.CharField(max_length=100)      target = models.ForeignKey(ForeignKeyTarget, null=True, blank=True, -                               related_name='nullable_sources') +                               related_name='nullable_sources', +                               verbose_name='Optional target object')  # OneToOne diff --git a/tests/test_fields.py b/tests/test_fields.py index e65a2fb3..73b15641 100644 --- a/tests/test_fields.py +++ b/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):      """ @@ -312,7 +323,7 @@ class DateTimeFieldTest(TestCase):              f.from_native('04:61:59')          except validators.ValidationError as e:              self.assertEqual(e.messages, ["Datetime has wrong format. Use one of these formats instead: " -                                          "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HHMM|-HHMM|Z]"]) +                                          "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]"])          else:              self.fail("ValidationError was not properly raised") @@ -326,7 +337,7 @@ class DateTimeFieldTest(TestCase):              f.from_native('04 -- 31')          except validators.ValidationError as e:              self.assertEqual(e.messages, ["Datetime has wrong format. Use one of these formats instead: " -                                          "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HHMM|-HHMM|Z]"]) +                                          "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]"])          else:              self.fail("ValidationError was not properly raised") @@ -706,6 +717,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()) diff --git a/tests/test_generics.py b/tests/test_generics.py index 4389994a..36832aff 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -5,6 +5,7 @@ from django.test import TestCase  from rest_framework import generics, renderers, serializers, status  from rest_framework.test import APIRequestFactory  from tests.models import BasicModel, Comment, SlugBasedModel +from tests.models import ForeignKeySource, ForeignKeyTarget  from rest_framework.compat import six  factory = APIRequestFactory() @@ -28,6 +29,13 @@ class InstanceView(generics.RetrieveUpdateDestroyAPIView):          return queryset.exclude(text='filtered out') +class FKInstanceView(generics.RetrieveUpdateDestroyAPIView): +    """ +    FK: example description for OPTIONS. +    """ +    model = ForeignKeySource + +  class SlugSerializer(serializers.ModelSerializer):      slug = serializers.Field()  # read only @@ -407,6 +415,72 @@ class TestInstanceView(TestCase):          self.assertFalse(self.objects.filter(id=999).exists()) +class TestFKInstanceView(TestCase): +    def setUp(self): +        """ +        Create 3 BasicModel instances. +        """ +        items = ['foo', 'bar', 'baz'] +        for item in items: +            t = ForeignKeyTarget(name=item) +            t.save() +            ForeignKeySource(name='source_' + item, target=t).save() + +        self.objects = ForeignKeySource.objects +        self.data = [ +            {'id': obj.id, 'name': obj.name} +            for obj in self.objects.all() +        ] +        self.view = FKInstanceView.as_view() + +    def test_options_root_view(self): +        """ +        OPTIONS requests to ListCreateAPIView should return metadata +        """ +        request = factory.options('/999') +        with self.assertNumQueries(1): +            response = self.view(request, pk=999).render() +        expected = { +            'name': 'Fk Instance', +            'description': 'FK: example description for OPTIONS.', +            'renders': [ +                'application/json', +                'text/html' +            ], +            'parses': [ +                'application/json', +                'application/x-www-form-urlencoded', +                'multipart/form-data' +            ], +            'actions': { +                'PUT': { +                    'id': { +                        'type': 'integer', +                        'required': False, +                        'read_only': True, +                        'label': 'ID' +                    }, +                    'name': { +                        'type': 'string', +                        'required': True, +                        'read_only': False, +                        'label': 'name', +                        'max_length': 100 +                    }, +                    'target': { +                        'type': 'field', +                        'required': True, +                        'read_only': False, +                        'label': 'Target', +                        'help_text': 'Target' +                    } +                } +            } +        } +        self.assertEqual(response.status_code, status.HTTP_200_OK) +        self.assertEqual(response.data, expected) + +  class TestOverriddenGetObject(TestCase):      """      Test cases for a RetrieveUpdateDestroyAPIView that does NOT use the diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 67547783..2e276f15 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -3,6 +3,7 @@ from django.test import TestCase  from rest_framework.serializers import _resolve_model  from 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) diff --git a/tests/test_views.py b/tests/test_views.py index 65c7e50e..77b113ee 100644 --- a/tests/test_views.py +++ b/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) @@ -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 +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,  [testenv]  commands = py.test -q +[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 +       pytest-django==2.6.1 +  [testenv:py3.3-django1.7]  basepython = python3.3  deps = https://www.djangoproject.com/download/1.7b2/tarball/ @@ -33,6 +45,14 @@ deps = https://www.djangoproject.com/download/1.7b2/tarball/         Pillow==2.3.0         pytest-django==2.6.1 +[testenv:py3.4-django1.6] +basepython = python3.4 +deps = Django==1.6.3 +       django-filter==0.7 +       defusedxml==0.3 +       Pillow==2.3.0 +       pytest-django==2.6.1 +  [testenv:py3.3-django1.6]  basepython = python3.3  deps = Django==1.6.3 @@ -73,6 +93,14 @@ deps = Django==1.6.3         Pillow==2.3.0         pytest-django==2.6.1 +[testenv:py3.4-django1.5] +basepython = python3.4 +deps = django==1.5.6 +       django-filter==0.7 +       defusedxml==0.3 +       Pillow==2.3.0 +       pytest-django==2.6.1 +  [testenv:py3.3-django1.5]  basepython = python3.3  deps = django==1.5.6 | 
