diff options
40 files changed, 497 insertions, 133 deletions
| diff --git a/.travis.yml b/.travis.yml index ececf3e9..a5b6d7d9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,43 +1,28 @@  language: python -python: -  - "2.6" -  - "2.7" -  - "3.2" -  - "3.3" -  - "3.4" +python: 2.7  env: -  - DJANGO="https://www.djangoproject.com/download/1.7c2/tarball/" -  - DJANGO="django==1.6.5" -  - DJANGO="django==1.5.8" -  - DJANGO="django==1.4.13" +    - TOX_ENV=flake8 +    - TOX_ENV=py3.4-django1.7 +    - TOX_ENV=py3.3-django1.7 +    - TOX_ENV=py3.2-django1.7 +    - TOX_ENV=py2.7-django1.7 +    - TOX_ENV=py3.4-django1.6 +    - TOX_ENV=py3.3-django1.6 +    - TOX_ENV=py3.2-django1.6 +    - TOX_ENV=py2.7-django1.6 +    - TOX_ENV=py2.6-django1.6 +    - TOX_ENV=py3.4-django1.5 +    - TOX_ENV=py3.3-django1.5 +    - TOX_ENV=py3.2-django1.5 +    - TOX_ENV=py2.7-django1.5 +    - TOX_ENV=py2.6-django1.5 +    - TOX_ENV=py2.7-django1.4 +    - TOX_ENV=py2.6-django1.4  install: -  - pip install $DJANGO -  - pip install defusedxml==0.3 -  - pip install Pillow==2.3.0 -  - pip install django-guardian==1.2.3 -  - pip install pytest-django==2.6.1 -  - pip install flake8==2.2.2 -  - "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" -  - "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 [[ ${DJANGO} == 'https://www.djangoproject.com/download/1.7c2/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=. +  - "pip install tox --download-cache $HOME/.pip-cache"  script: -  - ./runtests.py - -matrix: -  exclude: -    - python: "2.6" -      env: DJANGO="https://www.djangoproject.com/download/1.7c2/tarball/" -    - python: "3.2" -      env: DJANGO="django==1.4.13" -    - python: "3.3" -      env: DJANGO="django==1.4.13" -    - python: "3.4" -      env: DJANGO="django==1.4.13" +    - tox -e $TOX_ENV diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff6018b8..a6dd05a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,10 +62,10 @@ To run the tests, clone the repository, and then:      virtualenv env      env/bin/activate      pip install -r requirements.txt -    pip install -r optionals.txt +    pip install -r requirements-test.txt      # Run the tests -    py.test +    ./runtests.py  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/MANIFEST.in b/MANIFEST.in index 15c4d0b0..d407865f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,4 @@  recursive-include rest_framework/static *.js *.css *.png  recursive-include rest_framework/templates *.html +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] @@ -50,7 +50,7 @@ Startup up a new project like so...      pip install django      pip install djangorestframework -    django-admin startproject example . +    django-admin.py startproject example .      ./manage.py syncdb  Now edit the `example/urls.py` module in your project: diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index ec5ab61f..cfeb4334 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -193,7 +193,7 @@ filters using `Manufacturer` name. For example:      class ProductFilter(django_filters.FilterSet):          class Meta:              model = Product -            fields = ['category', 'in_stock', 'manufacturer__name`] +            fields = ['category', 'in_stock', 'manufacturer__name']  This enables us to make queries like: @@ -211,7 +211,7 @@ This is nice, but it exposes the Django's double underscore convention as part o          class Meta:              model = Product -            fields = ['category', 'in_stock', 'manufacturer`] +            fields = ['category', 'in_stock', 'manufacturer']  And now you can execute: diff --git a/docs/topics/2.4-announcement.md b/docs/topics/2.4-announcement.md index 5f90319a..09294b91 100644 --- a/docs/topics/2.4-announcement.md +++ b/docs/topics/2.4-announcement.md @@ -17,7 +17,7 @@ The optional authtoken application now includes support for *both* Django 1.7 sc  ## Deprecation of `.model` view attribute -The `.model` attribute on view classes is an optional shortcut for either or both of `.serializer_class` and `.queryset`. It's usage results in more implicit, less obvious behavior. +The `.model` attribute on view classes is an optional shortcut for either or both of `.serializer_class` and `.queryset`. Its usage results in more implicit, less obvious behavior.  The documentation has previously stated that usage of the more explicit style is prefered, and we're now taking that one step further and deprecating the usage of the `.model` shortcut. @@ -128,7 +128,7 @@ There are also a number of other features and bugfixes as [listed in the release  Smarter [client IP identification for throttling][client-ip-identification], with the addition of the `NUM_PROXIES` setting. -Added the standardized `Retry-After` header to throttled responses, as per [RFC 6585](http://tools.ietf.org/html/rfc6585). This should now be used in preference to the custom `X-Trottle-Wait-Seconds` header which will be fully deprecated in 3.0. +Added the standardized `Retry-After` header to throttled responses, as per [RFC 6585](http://tools.ietf.org/html/rfc6585). This should now be used in preference to the custom `X-Throttle-Wait-Seconds` header which will be fully deprecated in 3.0.  ## Deprecations @@ -163,10 +163,10 @@ The next planned release will be 3.0, featuring an improved and simplified seria  Once again, many thanks to all the generous [backers and sponsors][kickstarter-sponsors] who've helped make this possible!  [lts-releases]: https://docs.djangoproject.com/en/dev/internals/release-process/#long-term-support-lts-releases -[2-4-release-notes]: ./topics/release-notes/#240 +[2-4-release-notes]: release-notes#240  [view-name-and-description-settings]: ../api-guide/settings/#view-names-and-descriptions  [client-ip-identification]: ../api-guide/throttling/#how-clients-are-identified -[2-3-announcement]: ./topics/2.3-announcement +[2-3-announcement]: 2.3-announcement  [github-labels]: https://github.com/tomchristie/django-rest-framework/issues  [github-milestones]: https://github.com/tomchristie/django-rest-framework/milestones -[kickstarter-sponsors]: ./topics/kickstarter-announcement/#sponsors +[kickstarter-sponsors]: kickstarter-announcement#sponsors diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 29a0afcd..d758ae6a 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -40,6 +40,18 @@ You can determine your currently installed version using `pip freeze`:  ## 2.4.x series +### 2.4.2 + +**Date**: 3rd September 2014 + +* Bugfix: Fix broken pagination for 2.4.x series. + +### 2.4.1 + +**Date**: 1st September 2014 + +* Bugfix: Fix broken login template for browsable API. +  ### 2.4.0  **Date**: 29th August 2014 diff --git a/requirements-test.txt b/requirements-test.txt index 411daeba..d6ee5c6f 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -8,6 +8,7 @@ flake8==2.2.2  markdown>=2.1.0  PyYAML>=3.10  defusedxml>=0.3 +django-guardian==1.2.4  django-filter>=0.5.4  django-oauth-plus>=2.2.1  oauth2>=1.5.211 diff --git a/requirements.txt b/requirements.txt index 730c1d07..8a698230 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -Django>=1.3 +Django>=1.4.2 diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index f95bdc22..8d82a4b9 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -8,7 +8,7 @@ ______ _____ _____ _____    __  """  __title__ = 'Django REST framework' -__version__ = '2.4.0' +__version__ = '2.4.2'  __author__ = 'Tom Christie'  __license__ = 'BSD 2-Clause'  __copyright__ = 'Copyright 2011-2014 Tom Christie' diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 5721a869..f3fec05e 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -344,7 +344,7 @@ class OAuth2Authentication(BaseAuthentication):          user = token.user          if not user.is_active: -            msg = 'User inactive or deleted: %s' % user.username +            msg = 'User inactive or deleted: %s' % user.get_username()              raise exceptions.AuthenticationFailed(msg)          return (user, token) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 3ec28908..7496a629 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -51,8 +51,11 @@ def get_attribute(instance, attrs):      for attr in attrs:          try:              instance = getattr(instance, attr) -        except AttributeError: -            return instance[attr] +        except AttributeError as exc: +            try: +                return instance[attr] +            except (KeyError, TypeError): +                raise exc      return instance diff --git a/rest_framework/filters.py b/rest_framework/filters.py index e2080013..c580f935 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -56,7 +56,6 @@ class DjangoFilterBackend(BaseFilterBackend):                  class Meta:                      model = queryset.model                      fields = filter_fields -                    order_by = True              return AutoFilterSet          return None diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 478d32b4..9cf31629 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -43,8 +43,9 @@ class DefaultObjectSerializer(serializers.Field):      as the default.      """ -    def __init__(self, source=None, context=None): -        # Note: Swallow context kwarg - only required for eg. ModelSerializer. +    def __init__(self, source=None, many=None, context=None): +        # Note: Swallow context and many kwargs - only required for +        # eg. ModelSerializer.          super(DefaultObjectSerializer, self).__init__(source=source) @@ -61,6 +62,7 @@ class BasePaginationSerializer(serializers.Serializer):          """          super(BasePaginationSerializer, self).__init__(*args, **kwargs)          results_field = self.results_field +          try:              object_serializer = self.Meta.object_serializer_class          except AttributeError: @@ -70,7 +72,7 @@ class BasePaginationSerializer(serializers.Serializer):              child=object_serializer(),              source='object_list'          ) -        self.fields[results_field].bind(results_field, self, self)  # TODO: Support automatic binding +        self.fields[results_field].bind(results_field, self, self)  class PaginationSerializer(BasePaginationSerializer): diff --git a/rest_framework/parsers.py b/rest_framework/parsers.py index aa4fd3f1..c287908d 100644 --- a/rest_framework/parsers.py +++ b/rest_framework/parsers.py @@ -11,7 +11,7 @@ from django.http import QueryDict  from django.http.multipartparser import MultiPartParser as DjangoMultiPartParser  from django.http.multipartparser import MultiPartParserError, parse_header, ChunkIter  from django.utils import six -from rest_framework.compat import etree, yaml, force_text +from rest_framework.compat import etree, yaml, force_text, urlparse  from rest_framework.exceptions import ParseError  from rest_framework import renderers  import json @@ -290,6 +290,22 @@ class FileUploadParser(BaseParser):          try:              meta = parser_context['request'].META              disposition = parse_header(meta['HTTP_CONTENT_DISPOSITION'].encode('utf-8')) -            return force_text(disposition[1]['filename']) +            filename_parm = disposition[1] +            if 'filename*' in filename_parm: +                return self.get_encoded_filename(filename_parm) +            return force_text(filename_parm['filename'])          except (AttributeError, KeyError):              pass + +    def get_encoded_filename(self, filename_parm): +        """ +        Handle encoded filenames per RFC6266. See also: +        http://tools.ietf.org/html/rfc2231#section-4 +        """ +        encoded_filename = force_text(filename_parm['filename*']) +        try: +            charset, lang, filename = encoded_filename.split('\'', 2) +            filename = urlparse.unquote(filename) +        except (ValueError, LookupError): +            filename = force_text(filename_parm['filename']) +        return filename diff --git a/rest_framework/routers.py b/rest_framework/routers.py index ae56673d..8f1ab6fa 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -284,10 +284,10 @@ class DefaultRouter(SimpleRouter):          class APIRoot(views.APIView):              _ignore_model_permissions = True -            def get(self, request, format=None): +            def get(self, request, *args, **kwargs):                  ret = {}                  for key, url_name in api_root_dict.items(): -                    ret[key] = reverse(url_name, request=request, format=format) +                    ret[key] = reverse(url_name, request=request, format=kwargs.get('format', None))                  return Response(ret)          return APIRoot.as_view() diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 13e57939..8fe999ae 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -317,6 +317,19 @@ class ModelSerializerOptions(object):          self.depth = getattr(meta, 'depth', 0) +def lookup_class(mapping, obj): +    """ +    Takes a dictionary with classes as keys, and an object. +    Traverses the object's inheritance hierarchy in method +    resolution order, and returns the first matching value +    from the dictionary or None. +    """ +    return next( +        (mapping[cls] for cls in inspect.getmro(obj.__class__) if cls in mapping), +        None +    ) + +  class ModelSerializer(Serializer):      field_mapping = {          models.AutoField: IntegerField, @@ -580,13 +593,20 @@ class ModelSerializer(Serializer):          if decimal_places is not None:              kwargs['decimal_places'] = decimal_places +        if isinstance(model_field, models.BooleanField): +            # models.BooleanField has `blank=True`, but *is* actually +            # required *unless* a default is provided. +            # Also note that <1.6 `default=False`, >=1.6 `default=None`. +            kwargs.pop('required', None) +          if validator_kwarg:              kwargs['validators'] = validator_kwarg -        try: -            return self.field_mapping[model_field.__class__](**kwargs) -        except KeyError: -            return ModelField(model_field=model_field, **kwargs) +        cls = lookup_class(self.field_mapping, model_field) +        if cls is None: +            cls = ModelField +            kwargs['model_field'] = model_field +        return cls(**kwargs)  class HyperlinkedModelSerializerOptions(ModelSerializerOptions): diff --git a/rest_framework/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html index cee9724d..a84ccf26 100644 --- a/rest_framework/templates/rest_framework/base.html +++ b/rest_framework/templates/rest_framework/base.html @@ -5,14 +5,14 @@  <html>      <head>          {% block head %} -     +              {% block meta %}                  <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>                  <meta name="robots" content="NONE,NOARCHIVE" />              {% endblock %} -     +              <title>{% block title %}Django REST framework{% endblock %}</title> -     +              {% block style %}                  {% block bootstrap_theme %}                      <link rel="stylesheet" type="text/css" href="{% static "rest_framework/css/bootstrap.min.css" %}"/> @@ -21,10 +21,11 @@                  <link rel="stylesheet" type="text/css" href="{% static "rest_framework/css/prettify.css" %}"/>                  <link rel="stylesheet" type="text/css" href="{% static "rest_framework/css/default.css" %}"/>              {% endblock %} -             +          {% endblock %}      </head> +    {% block body %}      <body class="{% block bodyclass %}{% endblock %} container">          <div class="wrapper"> @@ -43,17 +44,9 @@                              <ul class="nav pull-right">                                  {% block userlinks %}                                      {% if user.is_authenticated %} -                                    <li class="dropdown"> -                                        <a href="#" class="dropdown-toggle" data-toggle="dropdown"> -                                            {{ user }} -                                            <b class="caret"></b> -                                        </a> -                                        <ul class="dropdown-menu"> -                                            <li>{% optional_logout request %}</li> -                                        </ul> -                                    </li> +                                        {% optional_logout request user %}                                      {% else %} -                                        <li>{% optional_login request %}</li> +                                        {% optional_login request %}                                      {% endif %}                                  {% endblock %}                              </ul> @@ -84,7 +77,7 @@                              <div class="btn-group format-selection">                                  <a class="btn btn-primary js-tooltip" href='{{ request.get_full_path }}'                                       rel="nofollow" title="Make a GET request on the {{ name }} resource">GET</a> -         +                                  <button class="btn btn-primary dropdown-toggle js-tooltip" data-toggle="dropdown"                                          title="Specify a format for the GET request">                                      <span class="caret"></span> @@ -143,7 +136,7 @@                  </div>                  {% if display_edit_forms %} -     +                      {% if post_form or raw_data_post_form %}                          <div {% if post_form %}class="tabbable"{% endif %}>                              {% if post_form %} @@ -189,7 +182,7 @@                              </div>                          </div>                      {% endif %} -     +                      {% if put_form or raw_data_put_form or raw_data_patch_form %}                          <div {% if put_form %}class="tabbable"{% endif %}>                              {% if put_form %} @@ -245,7 +238,7 @@                  {% endif %}              </div>              <!-- END Content --> -             +              <footer>                  {% block footer %}                      <p>Sponsored by <a href="http://dabapps.com/">DabApps</a>.</p> @@ -261,4 +254,5 @@              <script src="{% static "rest_framework/js/default.js" %}"></script>          {% endblock %}      </body> +    {% endblock %}  </html> diff --git a/rest_framework/templates/rest_framework/login_base.html b/rest_framework/templates/rest_framework/login_base.html index 43860e53..8ab682ac 100644 --- a/rest_framework/templates/rest_framework/login_base.html +++ b/rest_framework/templates/rest_framework/login_base.html @@ -17,21 +17,44 @@                      <div class="row-fluid">                          <div> -                            <form action="{% url 'rest_framework:login' %}" class=" form-inline" method="post"> +                            <form action="{% url 'rest_framework:login' %}" role="form" method="post">                                  {% csrf_token %} -                                <div id="div_id_username" class="clearfix control-group"> +                                <div id="div_id_username" +                                    class="clearfix control-group {% if form.username.errors %}error{% endif %}">                                      <div class="controls">                                          <Label class="span4">Username:</label> -                                        <input style="height: 25px" type="text" name="username" maxlength="100" autocapitalize="off" autocorrect="off" class="textinput textInput" id="id_username"> +                                        <input style="height: 25px" type="text" name="username" maxlength="100" +                                            autocapitalize="off" +                                            autocorrect="off" class="span12 textinput textInput" +                                            id="id_username" required +                                            {% if form.username.value %}value="{{ form.username.value }}"{% endif %}> +                                        {% if form.username.errors %} +                                            <p class="text-error"> +                                                {{ form.username.errors|striptags }} +                                            </p> +                                        {% endif %}                                      </div>                                  </div> -                                <div id="div_id_password" class="clearfix control-group"> -                                    <div class="controls"> +                                    <div id="div_id_password" +                                        class="clearfix control-group {% if form.password.errors %}error{% endif %}"> +                                        <div class="controls">                                          <Label class="span4">Password:</label> -                                        <input style="height: 25px" type="password" name="password" maxlength="100" autocapitalize="off" autocorrect="off" class="textinput textInput" id="id_password"> +                                        <input style="height: 25px" type="password" name="password" maxlength="100" +                                            autocapitalize="off" autocorrect="off" class="span12 textinput textInput" +                                            id="id_password" required> +                                        {% if form.password.errors %} +                                            <p class="text-error"> +                                                {{ form.password.errors|striptags }} +                                            </p> +                                        {% endif %}                                      </div>                                  </div>                                  <input type="hidden" name="next" value="{{ next }}" /> +                                {% if form.non_field_errors %} +                                    {% for error in form.non_field_errors %} +                                        <div class="well well-small text-error" style="border: none">{{ error }}</div> +                                    {% endfor %} +                                {% endif %}                                  <div class="form-actions-no-box">                                      <input type="submit" name="submit" value="Log in" class="btn btn-primary" id="submit-id-submit">                                  </div> diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index b80a7d77..864d64dd 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -41,22 +41,31 @@ def optional_login(request):      except NoReverseMatch:          return '' -    snippet = "<a href='%s?next=%s'>Log in</a>" % (login_url, escape(request.path)) +    snippet = "<li><a href='{href}?next={next}'>Log in</a></li>".format(href=login_url, next=escape(request.path))      return snippet  @register.simple_tag -def optional_logout(request): +def optional_logout(request, user):      """      Include a logout snippet if REST framework's logout view is in the URLconf.      """      try:          logout_url = reverse('rest_framework:logout')      except NoReverseMatch: -        return '' - -    snippet = "<a href='%s?next=%s'>Log out</a>" % (logout_url, escape(request.path)) -    return snippet +        return '<li class="navbar-text">{user}</li>'.format(user=user) + +    snippet = """<li class="dropdown"> +        <a href="#" class="dropdown-toggle" data-toggle="dropdown"> +            {user} +            <b class="caret"></b> +        </a> +        <ul class="dropdown-menu"> +            <li><a href='{href}?next={next}'>Log out</a></li> +        </ul> +    </li>""" + +    return snippet.format(user=user, href=logout_url, next=escape(request.path))  @register.simple_tag diff --git a/rest_framework/test.py b/rest_framework/test.py index f89a6dcd..9b40353a 100644 --- a/rest_framework/test.py +++ b/rest_framework/test.py @@ -36,7 +36,7 @@ class APIRequestFactory(DjangoRequestFactory):          Encode the data returning a two tuple of (bytes, content_type)          """ -        if not data: +        if data is None:              return ('', content_type)          assert format is None or content_type is None, ( diff --git a/rest_framework/urls.py b/rest_framework/urls.py index 8fa3073e..cfcee534 100644 --- a/rest_framework/urls.py +++ b/rest_framework/urls.py @@ -6,7 +6,7 @@ your API requires authentication:      urlpatterns = patterns('',          ... -        url(r'^auth', include('rest_framework.urls', namespace='rest_framework')) +        url(r'^auth/', include('rest_framework.urls', namespace='rest_framework'))      )  The urls must be namespaced as 'rest_framework', and you should make sure diff --git a/rest_framework/utils/formatting.py b/rest_framework/utils/formatting.py index 6d53aed1..470af51b 100644 --- a/rest_framework/utils/formatting.py +++ b/rest_framework/utils/formatting.py @@ -2,11 +2,12 @@  Utility functions to return a formatted name and description for a given view.  """  from __future__ import unicode_literals +import re  from django.utils.html import escape  from django.utils.safestring import mark_safe -from rest_framework.compat import apply_markdown -import re + +from rest_framework.compat import apply_markdown, force_text  def remove_trailing_string(content, trailing): @@ -28,6 +29,7 @@ def dedent(content):      as it fails to dedent multiline docstrings that include      unindented text on the initial line.      """ +    content = force_text(content)      whitespace_counts = [len(line) - len(line.lstrip(' '))                           for line in content.splitlines()[1:] if line.lstrip()] diff --git a/rest_framework/views.py b/rest_framework/views.py index 079e9285..3b7b1c16 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -108,7 +108,9 @@ class APIView(View):          """          view = super(APIView, cls).as_view(**initkwargs)          view.cls = cls -        return view +        # Note: session based authentication is explicitly CSRF validated, +        # all other authentication is CSRF exempt. +        return csrf_exempt(view)      @property      def allowed_methods(self): @@ -376,9 +378,9 @@ class APIView(View):          response.exception = True          return response -    # Note: session based authentication is explicitly CSRF validated, -    # all other authentication is CSRF exempt. -    @csrf_exempt +    # Note: Views are made CSRF exempt from within `as_view` as to prevent +    # accidental removal of this exemption in cases where `dispatch` needs to +    # be overridden.      def dispatch(self, request, *args, **kwargs):          """          `.dispatch()` is pretty much the same as Django's regular dispatch, diff --git a/tests/browsable_api/__init__.py b/tests/browsable_api/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/tests/browsable_api/__init__.py diff --git a/tests/browsable_api/auth_urls.py b/tests/browsable_api/auth_urls.py new file mode 100644 index 00000000..bce7dcf9 --- /dev/null +++ b/tests/browsable_api/auth_urls.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from django.conf.urls import patterns, url, include + +from .views import MockView + +urlpatterns = patterns( +    '', +    (r'^$', MockView.as_view()), +    url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')), +) diff --git a/tests/browsable_api/no_auth_urls.py b/tests/browsable_api/no_auth_urls.py new file mode 100644 index 00000000..5e3604a6 --- /dev/null +++ b/tests/browsable_api/no_auth_urls.py @@ -0,0 +1,9 @@ +from __future__ import unicode_literals +from django.conf.urls import patterns + +from .views import MockView + +urlpatterns = patterns( +    '', +    (r'^$', MockView.as_view()), +) diff --git a/tests/browsable_api/test_browsable_api.py b/tests/browsable_api/test_browsable_api.py new file mode 100644 index 00000000..5f264783 --- /dev/null +++ b/tests/browsable_api/test_browsable_api.py @@ -0,0 +1,65 @@ +from __future__ import unicode_literals +from django.contrib.auth.models import User +from django.test import TestCase + +from rest_framework.test import APIClient + + +class DropdownWithAuthTests(TestCase): +    """Tests correct dropdown behaviour with Auth views enabled.""" + +    urls = 'tests.browsable_api.auth_urls' + +    def setUp(self): +        self.client = APIClient(enforce_csrf_checks=True) +        self.username = 'john' +        self.email = 'lennon@thebeatles.com' +        self.password = 'password' +        self.user = User.objects.create_user(self.username, self.email, self.password) + +    def tearDown(self): +        self.client.logout() + +    def test_name_shown_when_logged_in(self): +        self.client.login(username=self.username, password=self.password) +        response = self.client.get('/') +        self.assertContains(response, 'john') + +    def test_logout_shown_when_logged_in(self): +        self.client.login(username=self.username, password=self.password) +        response = self.client.get('/') +        self.assertContains(response, '>Log out<') + +    def test_login_shown_when_logged_out(self): +        response = self.client.get('/') +        self.assertContains(response, '>Log in<') + + +class NoDropdownWithoutAuthTests(TestCase): +    """Tests correct dropdown behaviour with Auth views NOT enabled.""" + +    urls = 'tests.browsable_api.no_auth_urls' + +    def setUp(self): +        self.client = APIClient(enforce_csrf_checks=True) +        self.username = 'john' +        self.email = 'lennon@thebeatles.com' +        self.password = 'password' +        self.user = User.objects.create_user(self.username, self.email, self.password) + +    def tearDown(self): +        self.client.logout() + +    def test_name_shown_when_logged_in(self): +        self.client.login(username=self.username, password=self.password) +        response = self.client.get('/') +        self.assertContains(response, 'john') + +    def test_dropdown_not_shown_when_logged_in(self): +        self.client.login(username=self.username, password=self.password) +        response = self.client.get('/') +        self.assertNotContains(response, '<li class="dropdown">') + +    def test_dropdown_not_shown_when_logged_out(self): +        response = self.client.get('/') +        self.assertNotContains(response, '<li class="dropdown">') diff --git a/tests/browsable_api/views.py b/tests/browsable_api/views.py new file mode 100644 index 00000000..000f4e80 --- /dev/null +++ b/tests/browsable_api/views.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals + +from rest_framework.views import APIView +from rest_framework import authentication +from rest_framework import renderers +from rest_framework.response import Response + + +class MockView(APIView): + +    authentication_classes = (authentication.SessionAuthentication,) +    renderer_classes = (renderers.BrowsableAPIRenderer,) + +    def get(self, request): +        return Response({'a': 1, 'b': 2, 'c': 3}) diff --git a/tests/conftest.py b/tests/conftest.py index f3723aea..4b33e19c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ def pytest_configure():          DEBUG_PROPAGATE_EXCEPTIONS=True,          DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3',                                 'NAME': ':memory:'}}, +        SITE_ID=1,          SECRET_KEY='not very secret in tests',          USE_I18N=True,          USE_L10N=True, diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 2b9d73e4..32041f9c 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -57,7 +57,8 @@ urlpatterns = patterns(              authentication_classes=[OAuthAuthentication],              permission_classes=[permissions.TokenHasReadWriteScope]          ) -    ) +    ), +    url(r'^auth/', include('rest_framework.urls', namespace='rest_framework'))  ) @@ -134,6 +135,15 @@ class SessionAuthTests(TestCase):      def tearDown(self):          self.csrf_client.logout() +    def test_login_view_renders_on_get(self): +        """ +        Ensure the login template renders for a basic GET. + +        cf. [#1810](https://github.com/tomchristie/django-rest-framework/pull/1810) +        """ +        response = self.csrf_client.get('/auth/login/') +        self.assertContains(response, '<Label class="span4">Username:</label>') +      def test_post_form_session_auth_failing_csrf(self):          """          Ensure POSTing form over session authentication without CSRF token fails. diff --git a/tests/test_description.py b/tests/test_description.py index 1e481f06..0675d209 100644 --- a/tests/test_description.py +++ b/tests/test_description.py @@ -98,6 +98,30 @@ class TestViewNamesAndDescriptions(TestCase):              pass          self.assertEqual(MockView().get_view_description(), '') +    def test_view_description_can_be_promise(self): +        """ +        Ensure a view may have a docstring that is actually a lazily evaluated +        class that can be converted to a string. + +        See: https://github.com/tomchristie/django-rest-framework/issues/1708 +        """ +        # use a mock object instead of gettext_lazy to ensure that we can't end +        # up with a test case string in our l10n catalog +        class MockLazyStr(object): +            def __init__(self, string): +                self.s = string + +            def __str__(self): +                return self.s + +            def __unicode__(self): +                return self.s + +        class MockView(APIView): +            __doc__ = MockLazyStr("a gettext string") + +        self.assertEqual(MockView().get_view_description(), 'a gettext string') +      def test_markdown(self):          """          Ensure markdown to HTML works as expected. diff --git a/tests/test_fields.py b/tests/test_fields.py index 53c60538..a92fafbc 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1012,6 +1012,26 @@  #         def get_my_test(self, obj):  #             return obj.my_test[0:5] +# class ModelCharField(TestCase): +#     """ +#         Tests for CharField +#     """ +#     def test_none_serializing(self): +#         class CharFieldSerializer(serializers.Serializer): +#             char = serializers.CharField(allow_none=True, required=False) +#         serializer = CharFieldSerializer(data={'char': None}) +#         self.assertTrue(serializer.is_valid()) +#         self.assertIsNone(serializer.object['char']) + + +# class SerializerMethodFieldTest(TestCase): +#     """ +#         Tests for the SerializerMethodField field_to_native() behavior +#     """ +#     class SerializerTest(serializers.Serializer): +#         def get_my_test(self, obj): +#             return obj.my_test[0:5] +  #     class Example():  #         my_test = 'Hey, this is a test !' diff --git a/tests/test_filters.py b/tests/test_filters.py index 6f24b1ab..300e47e4 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -422,19 +422,70 @@ class SearchFilterTests(TestCase):              ) -class OrdringFilterModel(models.Model): +class OrderingFilterModel(models.Model):      title = models.CharField(max_length=20)      text = models.CharField(max_length=100)  class OrderingFilterRelatedModel(models.Model): -    related_object = models.ForeignKey(OrdringFilterModel, +    related_object = models.ForeignKey(OrderingFilterModel,                                         related_name="relateds")  class OrderingFilterSerializer(serializers.ModelSerializer):      class Meta: -        model = OrdringFilterModel +        model = OrderingFilterModel + + +class DjangoFilterOrderingModel(models.Model): +    date = models.DateField() +    text = models.CharField(max_length=10) + +    class Meta: +        ordering = ['-date'] + + +class DjangoFilterOrderingSerializer(serializers.ModelSerializer): +    class Meta: +        model = DjangoFilterOrderingModel + + +class DjangoFilterOrderingTests(TestCase): +    def setUp(self): +        data = [{ +            'date': datetime.date(2012, 10, 8), +            'text': 'abc' +        }, { +            'date': datetime.date(2013, 10, 8), +            'text': 'bcd' +        }, { +            'date': datetime.date(2014, 10, 8), +            'text': 'cde' +        }] + +        for d in data: +            DjangoFilterOrderingModel.objects.create(**d) + +    def test_default_ordering(self): +        class DjangoFilterOrderingView(generics.ListAPIView): +            serializer_class = DjangoFilterOrderingSerializer +            queryset = DjangoFilterOrderingModel.objects.all() +            filter_backends = (filters.DjangoFilterBackend,) +            filter_fields = ['text'] +            ordering = ('-date',) + +        view = DjangoFilterOrderingView.as_view() +        request = factory.get('/') +        response = view(request) + +        self.assertEqual( +            response.data, +            [ +                {'id': 3, 'date': datetime.date(2014, 10, 8), 'text': 'cde'}, +                {'id': 2, 'date': datetime.date(2013, 10, 8), 'text': 'bcd'}, +                {'id': 1, 'date': datetime.date(2012, 10, 8), 'text': 'abc'} +            ] +        )  class OrderingFilterTests(TestCase): @@ -455,11 +506,11 @@ class OrderingFilterTests(TestCase):                  chr(idx + ord('b')) +                  chr(idx + ord('c'))              ) -            OrdringFilterModel(title=title, text=text).save() +            OrderingFilterModel(title=title, text=text).save()      def test_ordering(self):          class OrderingListView(generics.ListAPIView): -            queryset = OrdringFilterModel.objects.all() +            queryset = OrderingFilterModel.objects.all()              serializer_class = OrderingFilterSerializer              filter_backends = (filters.OrderingFilter,)              ordering = ('title',) @@ -479,7 +530,7 @@ class OrderingFilterTests(TestCase):      def test_reverse_ordering(self):          class OrderingListView(generics.ListAPIView): -            queryset = OrdringFilterModel.objects.all() +            queryset = OrderingFilterModel.objects.all()              serializer_class = OrderingFilterSerializer              filter_backends = (filters.OrderingFilter,)              ordering = ('title',) @@ -499,7 +550,7 @@ class OrderingFilterTests(TestCase):      def test_incorrectfield_ordering(self):          class OrderingListView(generics.ListAPIView): -            queryset = OrdringFilterModel.objects.all() +            queryset = OrderingFilterModel.objects.all()              serializer_class = OrderingFilterSerializer              filter_backends = (filters.OrderingFilter,)              ordering = ('title',) @@ -519,7 +570,7 @@ class OrderingFilterTests(TestCase):      def test_default_ordering(self):          class OrderingListView(generics.ListAPIView): -            queryset = OrdringFilterModel.objects.all() +            queryset = OrderingFilterModel.objects.all()              serializer_class = OrderingFilterSerializer              filter_backends = (filters.OrderingFilter,)              ordering = ('title',) @@ -539,7 +590,7 @@ class OrderingFilterTests(TestCase):      def test_default_ordering_using_string(self):          class OrderingListView(generics.ListAPIView): -            queryset = OrdringFilterModel.objects.all() +            queryset = OrderingFilterModel.objects.all()              serializer_class = OrderingFilterSerializer              filter_backends = (filters.OrderingFilter,)              ordering = 'title' @@ -560,7 +611,7 @@ class OrderingFilterTests(TestCase):      def test_ordering_by_aggregate_field(self):          # create some related models to aggregate order by          num_objs = [2, 5, 3] -        for obj, num_relateds in zip(OrdringFilterModel.objects.all(), +        for obj, num_relateds in zip(OrderingFilterModel.objects.all(),                                       num_objs):              for _ in range(num_relateds):                  new_related = OrderingFilterRelatedModel( @@ -573,7 +624,7 @@ class OrderingFilterTests(TestCase):              filter_backends = (filters.OrderingFilter,)              ordering = 'title'              ordering_fields = '__all__' -            queryset = OrdringFilterModel.objects.all().annotate( +            queryset = OrderingFilterModel.objects.all().annotate(                  models.Count("relateds"))          view = OrderingListView.as_view() @@ -591,7 +642,7 @@ class OrderingFilterTests(TestCase):      def test_ordering_with_nonstandard_ordering_param(self):          with temporary_setting('ORDERING_PARAM', 'order', filters):              class OrderingListView(generics.ListAPIView): -                queryset = OrdringFilterModel.objects.all() +                queryset = OrderingFilterModel.objects.all()                  serializer_class = OrderingFilterSerializer                  filter_backends = (filters.OrderingFilter,)                  ordering = ('title',) diff --git a/tests/test_generics.py b/tests/test_generics.py index 1b00c351..17bfca2f 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -1,4 +1,5 @@  from __future__ import unicode_literals +import django  from django.db import models  from django.shortcuts import get_object_or_404  from django.test import TestCase @@ -176,6 +177,9 @@ class TestRootView(TestCase):          self.assertEqual(created.text, 'foobar') +EXPECTED_QUERYS_FOR_PUT = 3 if django.VERSION < (1, 6) else 2 + +  class TestInstanceView(TestCase):      def setUp(self):          """ @@ -219,7 +223,7 @@ class TestInstanceView(TestCase):          """          data = {'text': 'foobar'}          request = factory.put('/1', data, format='json') -        with self.assertNumQueries(3): +        with self.assertNumQueries(EXPECTED_QUERYS_FOR_PUT):              response = self.view(request, pk='1').render()          self.assertEqual(response.status_code, status.HTTP_200_OK)          self.assertEqual(dict(response.data), {'id': 1, 'text': 'foobar'}) @@ -233,7 +237,7 @@ class TestInstanceView(TestCase):          data = {'text': 'foobar'}          request = factory.patch('/1', data, format='json') -        with self.assertNumQueries(3): +        with self.assertNumQueries(EXPECTED_QUERYS_FOR_PUT):              response = self.view(request, pk=1).render()          self.assertEqual(response.status_code, status.HTTP_200_OK)          self.assertEqual(response.data, {'id': 1, 'text': 'foobar'}) @@ -351,7 +355,7 @@ class TestInstanceView(TestCase):          """          data = {'id': 999, 'text': 'foobar'}          request = factory.put('/1', data, format='json') -        with self.assertNumQueries(3): +        with self.assertNumQueries(EXPECTED_QUERYS_FOR_PUT):              response = self.view(request, pk=1).render()          self.assertEqual(response.status_code, status.HTTP_200_OK)          self.assertEqual(response.data, {'id': 1, 'text': 'foobar'}) diff --git a/tests/test_model_field_mappings.py b/tests/test_model_field_mappings.py index dc254da4..57c97cb7 100644 --- a/tests/test_model_field_mappings.py +++ b/tests/test_model_field_mappings.py @@ -15,7 +15,7 @@ from rest_framework import serializers  class RegularFieldsModel(models.Model):      auto_field = models.AutoField(primary_key=True)      big_integer_field = models.BigIntegerField() -    boolean_field = models.BooleanField() +    boolean_field = models.BooleanField(default=False)      char_field = models.CharField(max_length=100)      comma_seperated_integer_field = models.CommaSeparatedIntegerField(max_length=100)      date_field = models.DateField() @@ -60,22 +60,22 @@ TestSerializer():  # Model for testing relational field mapping -class ForeignKeyTarget(models.Model): -    char_field = models.CharField(max_length=100) +class ForeignKeyTargetModel(models.Model): +    name = models.CharField(max_length=100) -class ManyToManyTarget(models.Model): -    char_field = models.CharField(max_length=100) +class ManyToManyTargetModel(models.Model): +    name = models.CharField(max_length=100) -class OneToOneTarget(models.Model): -    char_field = models.CharField(max_length=100) +class OneToOneTargetModel(models.Model): +    name = models.CharField(max_length=100)  class RelationalModel(models.Model): -    foreign_key = models.ForeignKey(ForeignKeyTarget) -    many_to_many = models.ManyToManyField(ManyToManyTarget) -    one_to_one = models.OneToOneField(OneToOneTarget) +    foreign_key = models.ForeignKey(ForeignKeyTargetModel) +    many_to_many = models.ManyToManyField(ManyToManyTargetModel) +    one_to_one = models.OneToOneField(OneToOneTargetModel)  RELATIONAL_FLAT_REPR = """ @@ -105,9 +105,9 @@ TestSerializer():  HYPERLINKED_FLAT_REPR = """  TestSerializer():      url = HyperlinkedIdentityField(view_name='relationalmodel-detail') -    foreign_key = HyperlinkedRelatedField(label='foreign key', queryset=<django.db.models.manager.Manager object>, view_name='foreignkeytarget-detail') -    one_to_one = HyperlinkedRelatedField(label='one to one', queryset=<django.db.models.manager.Manager object>, view_name='onetoonetarget-detail') -    many_to_many = HyperlinkedRelatedField(label='many to many', many=True, queryset=<django.db.models.manager.Manager object>, view_name='manytomanytarget-detail') +    foreign_key = HyperlinkedRelatedField(label='foreign key', queryset=<django.db.models.manager.Manager object>, view_name='foreignkeytargetmodel-detail') +    one_to_one = HyperlinkedRelatedField(label='one to one', queryset=<django.db.models.manager.Manager object>, view_name='onetoonetargetmodel-detail') +    many_to_many = HyperlinkedRelatedField(label='many to many', many=True, queryset=<django.db.models.manager.Manager object>, view_name='manytomanytargetmodel-detail')  """.strip() @@ -127,6 +127,8 @@ TestSerializer():  class TestSerializerMappings(TestCase): +    maxDiff = 10000 +      def test_regular_fields(self):          class TestSerializer(serializers.ModelSerializer):              class Meta: diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 2e56d970..68983ba2 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -428,6 +428,15 @@ class CustomPaginationSerializer(pagination.BasePaginationSerializer):      results_field = 'objects' +class CustomFooSerializer(serializers.Serializer): +    foo = serializers.CharField() + + +class CustomFooPaginationSerializer(pagination.PaginationSerializer): +    class Meta: +        object_serializer_class = CustomFooSerializer + +  class TestCustomPaginationSerializer(TestCase):      def setUp(self):          objects = ['john', 'paul', 'george', 'ringo'] @@ -450,6 +459,16 @@ class TestCustomPaginationSerializer(TestCase):          }          self.assertEqual(serializer.data, expected) +    def test_custom_pagination_serializer_with_custom_object_serializer(self): +        objects = [ +            {'foo': 'bar'}, +            {'foo': 'spam'} +        ] +        paginator = Paginator(objects, 1) +        page = paginator.page(1) +        serializer = CustomFooPaginationSerializer(page) +        serializer.data +  class NonIntegerPage(object): diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 8af90677..3f2672df 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +  from __future__ import unicode_literals  from rest_framework.compat import StringIO  from django import forms @@ -113,3 +115,25 @@ class TestFileUploadParser(TestCase):          parser = FileUploadParser()          filename = parser.get_filename(self.stream, None, self.parser_context)          self.assertEqual(filename, 'file.txt') + +    def test_get_encoded_filename(self): +        parser = FileUploadParser() + +        self.__replace_content_disposition('inline; filename*=utf-8\'\'ÀĥƦ.txt') +        filename = parser.get_filename(self.stream, None, self.parser_context) +        self.assertEqual(filename, 'ÀĥƦ.txt') + +        self.__replace_content_disposition('inline; filename=fallback.txt; filename*=utf-8\'\'ÀĥƦ.txt') +        filename = parser.get_filename(self.stream, None, self.parser_context) +        self.assertEqual(filename, 'ÀĥƦ.txt') + +        self.__replace_content_disposition('inline; filename=fallback.txt; filename*=utf-8\'en-us\'ÀĥƦ.txt') +        filename = parser.get_filename(self.stream, None, self.parser_context) +        self.assertEqual(filename, 'ÀĥƦ.txt') + +        self.__replace_content_disposition('inline; filename=fallback.txt; filename*=utf-8--ÀĥƦ.txt') +        filename = parser.get_filename(self.stream, None, self.parser_context) +        self.assertEqual(filename, 'fallback.txt') + +    def __replace_content_disposition(self, disposition): +        self.parser_context['request'].META['HTTP_CONTENT_DISPOSITION'] = disposition diff --git a/tests/test_validation.py b/tests/test_validation.py index 40005486..c4506e7e 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,5 +1,6 @@  from __future__ import unicode_literals  from django.core.validators import MaxValueValidator +from django.core.exceptions import ValidationError  from django.db import models  from django.test import TestCase  from rest_framework import generics, serializers, status @@ -132,3 +133,42 @@ class TestMaxValueValidatorValidation(TestCase):          response = view(request, pk=obj.pk).render()          self.assertEqual(response.content, b'{"number_value": ["Ensure this value is less than or equal to 100."]}')          self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class TestChoiceFieldChoicesValidate(TestCase): +    CHOICES = [ +        (0, 'Small'), +        (1, 'Medium'), +        (2, 'Large'), +    ] + +    CHOICES_NESTED = [ +        ('Category', ( +            (1, 'First'), +            (2, 'Second'), +            (3, 'Third'), +        )), +        (4, 'Fourth'), +    ] + +    def test_choices(self): +        """ +        Make sure a value for choices works as expected. +        """ +        f = serializers.ChoiceField(choices=self.CHOICES) +        value = self.CHOICES[0][0] +        try: +            f.to_native(value) +        except ValidationError: +            self.fail("Value %s does not validate" % str(value)) + +    # def test_nested_choices(self): +    #     """ +    #     Make sure a nested value for choices works as expected. +    #     """ +    #     f = serializers.ChoiceField(choices=self.CHOICES_NESTED) +    #     value = self.CHOICES_NESTED[0][1][0][0] +    #     try: +    #         f.to_native(value) +    #     except ValidationError: +    #         self.fail("Value %s does not validate" % str(value)) @@ -18,7 +18,7 @@ commands = ./runtests.py --lintonly  [testenv:py3.4-django1.7]  basepython = python3.4 -deps = https://www.djangoproject.com/download/1.7c2/tarball/ +deps = Django==1.7         django-filter==0.7         defusedxml==0.3         Pillow==2.3.0 @@ -26,7 +26,7 @@ deps = https://www.djangoproject.com/download/1.7c2/tarball/  [testenv:py3.3-django1.7]  basepython = python3.3 -deps = https://www.djangoproject.com/download/1.7c2/tarball/ +deps = Django==1.7         django-filter==0.7         defusedxml==0.3         Pillow==2.3.0 @@ -34,7 +34,7 @@ deps = https://www.djangoproject.com/download/1.7c2/tarball/  [testenv:py3.2-django1.7]  basepython = python3.2 -deps = https://www.djangoproject.com/download/1.7c2/tarball/ +deps = Django==1.7         django-filter==0.7         defusedxml==0.3         Pillow==2.3.0 @@ -42,7 +42,7 @@ deps = https://www.djangoproject.com/download/1.7c2/tarball/  [testenv:py2.7-django1.7]  basepython = python2.7 -deps = https://www.djangoproject.com/download/1.7c2/tarball/ +deps = Django==1.7         django-filter==0.7         defusedxml==0.3         # django-oauth-plus==2.2.1 | 
