diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index d7c23d6351..5a830ca53f 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1 +1,2 @@
+github: encode
custom: https://fund.django-rest-framework.org/topics/funding/
diff --git a/.github/ISSUE_TEMPLATE/1-issue.md b/.github/ISSUE_TEMPLATE/1-issue.md
new file mode 100644
index 0000000000..0da1549534
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/1-issue.md
@@ -0,0 +1,10 @@
+---
+name: Issue
+about: Please only raise an issue if you've been advised to do so after discussion. Thanks! 🙏
+---
+
+## Checklist
+
+- [ ] Raised initially as discussion #...
+- [ ] This cannot be dealt with as a third party library. (We prefer new functionality to be [in the form of third party libraries](https://www.django-rest-framework.org/community/third-party-packages/#about-third-party-packages) where possible.)
+- [ ] I have reduced the issue to the simplest possible case.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000000..382fc521aa
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,6 @@
+blank_issues_enabled: false
+contact_links:
+- name: Discussions
+ url: https://github.com/encode/django-rest-framework/discussions
+ about: >
+ The "Discussions" forum is where you want to start. 💖
diff --git a/.gitignore b/.gitignore
index 41768084c5..82e885edee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
*.db
*~
.*
+*.py.bak
+
/site/
/htmlcov/
diff --git a/.travis.yml b/.travis.yml
index 2c2724bf63..57a91e594a 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -10,19 +10,21 @@ matrix:
- { python: "3.6", env: DJANGO=2.2 }
- { python: "3.6", env: DJANGO=3.0 }
- { python: "3.6", env: DJANGO=3.1 }
- - { python: "3.6", env: DJANGO=master }
+ - { python: "3.6", env: DJANGO=3.2 }
- { python: "3.7", env: DJANGO=2.2 }
- { python: "3.7", env: DJANGO=3.0 }
- { python: "3.7", env: DJANGO=3.1 }
- - { python: "3.7", env: DJANGO=master }
+ - { python: "3.7", env: DJANGO=3.2 }
- { python: "3.8", env: DJANGO=3.0 }
- { python: "3.8", env: DJANGO=3.1 }
- - { python: "3.8", env: DJANGO=master }
+ - { python: "3.8", env: DJANGO=3.2 }
+ - { python: "3.8", env: DJANGO=main }
- - { python: "3.9-dev", env: DJANGO=3.1 }
- - { python: "3.9-dev", env: DJANGO=master }
+ - { python: "3.9", env: DJANGO=3.1 }
+ - { python: "3.9", env: DJANGO=3.2 }
+ - { python: "3.9", env: DJANGO=main }
- { python: "3.8", env: TOXENV=base }
- { python: "3.8", env: TOXENV=lint }
@@ -37,7 +39,8 @@ matrix:
- tox # test sdist
allow_failures:
- - env: DJANGO=master
+ - env: DJANGO=main
+ - env: DJANGO=3.2
install:
- pip install tox tox-travis
diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md
deleted file mode 100644
index 566bf95436..0000000000
--- a/ISSUE_TEMPLATE.md
+++ /dev/null
@@ -1,14 +0,0 @@
-## Checklist
-
-- [ ] I have verified that that issue exists against the `master` branch of Django REST framework.
-- [ ] I have searched for similar issues in both open and closed tickets and cannot find a duplicate.
-- [ ] This is not a usage question. (Those should be directed to the [discussion group](https://groups.google.com/forum/#!forum/django-rest-framework) instead.)
-- [ ] This cannot be dealt with as a third party library. (We prefer new functionality to be [in the form of third party libraries](https://www.django-rest-framework.org/community/third-party-packages/#about-third-party-packages) where possible.)
-- [ ] I have reduced the issue to the simplest possible case.
-- [ ] I have included a failing test as a pull request. (If you are unable to do so we can still accept the issue.)
-
-## Steps to reproduce
-
-## Expected behavior
-
-## Actual behavior
diff --git a/README.md b/README.md
index 8af1466f8a..305f923898 100644
--- a/README.md
+++ b/README.md
@@ -113,7 +113,7 @@ router.register(r'users', UserViewSet)
# Additionally, we include login URLs for the browsable API.
urlpatterns = [
path('', include(router.urls)),
- path('api-auth/', include('rest_framework.urls', namespace='rest_framework'))
+ path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
]
```
@@ -131,7 +131,7 @@ REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
'DEFAULT_PERMISSION_CLASSES': [
- 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
+ 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly',
]
}
```
diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md
index 5878040a48..4497f73bd0 100644
--- a/docs/api-guide/authentication.md
+++ b/docs/api-guide/authentication.md
@@ -60,8 +60,8 @@ using the `APIView` class-based views.
def get(self, request, format=None):
content = {
- 'user': unicode(request.user), # `django.contrib.auth.User` instance.
- 'auth': unicode(request.auth), # None
+ 'user': str(request.user), # `django.contrib.auth.User` instance.
+ 'auth': str(request.auth), # None
}
return Response(content)
@@ -72,8 +72,8 @@ Or, if you're using the `@api_view` decorator with function based views.
@permission_classes([IsAuthenticated])
def example_view(request, format=None):
content = {
- 'user': unicode(request.user), # `django.contrib.auth.User` instance.
- 'auth': unicode(request.auth), # None
+ 'user': str(request.user), # `django.contrib.auth.User` instance.
+ 'auth': str(request.auth), # None
}
return Response(content)
@@ -357,7 +357,7 @@ The following third party packages are also available.
## Django OAuth Toolkit
-The [Django OAuth Toolkit][django-oauth-toolkit] package provides OAuth 2.0 support and works with Python 3.4+. The package is maintained by [Evonove][evonove] and uses the excellent [OAuthLib][oauthlib]. The package is well documented, and well supported and is currently our **recommended package for OAuth 2.0 support**.
+The [Django OAuth Toolkit][django-oauth-toolkit] package provides OAuth 2.0 support and works with Python 3.4+. The package is maintained by [jazzband][jazzband] and uses the excellent [OAuthLib][oauthlib]. The package is well documented, and well supported and is currently our **recommended package for OAuth 2.0 support**.
#### Installation & configuration
@@ -432,6 +432,16 @@ There are currently two forks of this project.
[drfpasswordless][drfpasswordless] adds (Medium, Square Cash inspired) passwordless support to Django REST Framework's own TokenAuthentication scheme. Users log in and sign up with a token sent to a contact point like an email address or a mobile number.
+## django-rest-authemail
+
+[django-rest-authemail][django-rest-authemail] provides a RESTful API interface for user signup and authentication. Email addresses are used for authentication, rather than usernames. API endpoints are available for signup, signup email verification, login, logout, password reset, password reset verification, email change, email change verification, password change, and user detail. A fully-functional example project and detailed instructions are included.
+
+## Django-Rest-Durin
+
+[Django-Rest-Durin][django-rest-durin] is built with the idea to have one library that does token auth for multiple Web/CLI/Mobile API clients via one interface but allows different token configuration for each API Client that consumes the API. It provides support for multiple tokens per user via custom models, views, permissions that work with Django-Rest-Framework. The token expiration time can be different per API client and is customizable via the Django Admin Interface.
+
+More information can be found in the [Documentation](https://django-rest-durin.readthedocs.io/en/latest/index.html).
+
[cite]: https://jacobian.org/writing/rest-worst-practices/
[http401]: https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2
[http403]: https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.4
@@ -448,7 +458,7 @@ There are currently two forks of this project.
[djangorestframework-digestauth]: https://github.com/juanriaza/django-rest-framework-digestauth
[oauth-1.0a]: https://oauth.net/core/1.0a/
[django-oauth-toolkit]: https://github.com/evonove/django-oauth-toolkit
-[evonove]: https://github.com/evonove/
+[jazzband]: https://github.com/jazzband/
[oauthlib]: https://github.com/idan/oauthlib
[djangorestframework-simplejwt]: https://github.com/davesque/django-rest-framework-simplejwt
[etoccalino]: https://github.com/etoccalino/
@@ -466,3 +476,5 @@ There are currently two forks of this project.
[django-rest-framework-social-oauth2]: https://github.com/PhilipGarnero/django-rest-framework-social-oauth2
[django-rest-knox]: https://github.com/James1345/django-rest-knox
[drfpasswordless]: https://github.com/aaronn/django-rest-framework-passwordless
+[django-rest-authemail]: https://github.com/celiao/django-rest-authemail
+[django-rest-durin]: https://github.com/eshaan7/django-rest-durin
diff --git a/docs/api-guide/caching.md b/docs/api-guide/caching.md
index 96517b15ee..ab4f82cd2f 100644
--- a/docs/api-guide/caching.md
+++ b/docs/api-guide/caching.md
@@ -13,13 +13,13 @@ provided in Django.
Django provides a [`method_decorator`][decorator] to use
decorators with class based views. This can be used with
-other cache decorators such as [`cache_page`][page] and
-[`vary_on_cookie`][cookie].
+other cache decorators such as [`cache_page`][page],
+[`vary_on_cookie`][cookie] and [`vary_on_headers`][headers].
```python
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
-from django.views.decorators.vary import vary_on_cookie
+from django.views.decorators.vary import vary_on_cookie, vary_on_headers
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -27,8 +27,7 @@ from rest_framework import viewsets
class UserViewSet(viewsets.ViewSet):
-
- # Cache requested url for each user for 2 hours
+ # With cookie: cache requested url for each user for 2 hours
@method_decorator(cache_page(60*60*2))
@method_decorator(vary_on_cookie)
def list(self, request, format=None):
@@ -38,8 +37,18 @@ class UserViewSet(viewsets.ViewSet):
return Response(content)
-class PostView(APIView):
+class ProfileView(APIView):
+ # With auth: cache requested url for each user for 2 hours
+ @method_decorator(cache_page(60*60*2))
+ @method_decorator(vary_on_headers("Authorization",))
+ def get(self, request, format=None):
+ content = {
+ 'user_feed': request.user.get_user_feed()
+ }
+ return Response(content)
+
+class PostView(APIView):
# Cache page for the requested url
@method_decorator(cache_page(60*60*2))
def get(self, request, format=None):
@@ -55,4 +64,5 @@ class PostView(APIView):
[page]: https://docs.djangoproject.com/en/dev/topics/cache/#the-per-view-cache
[cookie]: https://docs.djangoproject.com/en/dev/topics/http/decorators/#django.views.decorators.vary.vary_on_cookie
+[headers]: https://docs.djangoproject.com/en/dev/topics/http/decorators/#django.views.decorators.vary.vary_on_headers
[decorator]: https://docs.djangoproject.com/en/dev/topics/class-based-views/intro/#decorating-the-class
diff --git a/docs/api-guide/exceptions.md b/docs/api-guide/exceptions.md
index d7d73a2f2b..e62a7e4f9d 100644
--- a/docs/api-guide/exceptions.md
+++ b/docs/api-guide/exceptions.md
@@ -38,7 +38,7 @@ Might receive an error response indicating that the `DELETE` method is not allow
Validation errors are handled slightly differently, and will include the field names as the keys in the response. If the validation error was not specific to a particular field then it will use the "non_field_errors" key, or whatever string value has been set for the `NON_FIELD_ERRORS_KEY` setting.
-Any example validation error might look like this:
+An example validation error might look like this:
HTTP/1.1 400 Bad Request
Content-Type: application/json
@@ -222,7 +222,7 @@ By default this exception results in a response with the HTTP status code "429 T
The `ValidationError` exception is slightly different from the other `APIException` classes:
* The `detail` argument is mandatory, not optional.
-* The `detail` argument may be a list or dictionary of error details, and may also be a nested data structure.
+* The `detail` argument may be a list or dictionary of error details, and may also be a nested data structure. By using a dictionary, you can specify field-level errors while performing object-level validation in the `validate()` method of a serializer. For example. `raise serializers.ValidationError({'name': 'Please enter a valid name.'})`
* By convention you should import the serializers module and use a fully qualified `ValidationError` style, in order to differentiate it from Django's built-in validation error. For example. `raise serializers.ValidationError('This field must be an integer value.')`
The `ValidationError` class should be used for serializer and field validation, and by validator classes. It is also raised when calling `serializer.is_valid` with the `raise_exception` keyword argument:
diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md
index 0492af9aa9..04f9939425 100644
--- a/docs/api-guide/fields.md
+++ b/docs/api-guide/fields.md
@@ -583,6 +583,7 @@ The serializer method referred to by the `method_name` argument should accept a
class Meta:
model = User
+ fields = '__all__'
def get_days_since_joined(self, obj):
return (now() - obj.date_joined).days
diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md
index d305ede6ba..478e3bcf95 100644
--- a/docs/api-guide/filtering.md
+++ b/docs/api-guide/filtering.md
@@ -75,7 +75,7 @@ We can override `.get_queryset()` to deal with URLs such as `http://example.com/
by filtering against a `username` query parameter in the URL.
"""
queryset = Purchase.objects.all()
- username = self.request.query_params.get('username', None)
+ username = self.request.query_params.get('username')
if username is not None:
queryset = queryset.filter(purchaser__username=username)
return queryset
diff --git a/docs/api-guide/parsers.md b/docs/api-guide/parsers.md
index e8f03de8bd..dde77c3e0e 100644
--- a/docs/api-guide/parsers.md
+++ b/docs/api-guide/parsers.md
@@ -73,7 +73,7 @@ Or, if you're using the `@api_view` decorator with function based views.
## JSONParser
-Parses `JSON` request content.
+Parses `JSON` request content. `request.data` will be populated with a dictionary of data.
**.media_type**: `application/json`
diff --git a/docs/api-guide/permissions.md b/docs/api-guide/permissions.md
index ade1462572..6912c375c2 100644
--- a/docs/api-guide/permissions.md
+++ b/docs/api-guide/permissions.md
@@ -70,6 +70,8 @@ For performance reasons the generic views will not automatically apply object le
Often when you're using object level permissions you'll also want to [filter the queryset][filtering] appropriately, to ensure that users only have visibility onto instances that they are permitted to view.
+Because the `get_object()` method is not called, object level permissions from the `has_object_permission()` method **are not applied** when creating objects. In order to restrict object creation you need to implement the permission check either in your Serializer class or override the `perform_create()` method of your ViewSet class.
+
## Setting the permission policy
The default permission policy may be set globally, using the `DEFAULT_PERMISSION_CLASSES` setting. For example.
@@ -116,7 +118,7 @@ Or, if you're using the `@api_view` decorator with function based views.
}
return Response(content)
-__Note:__ when you set new permission classes through class attribute or decorators you're telling the view to ignore the default list set over the __settings.py__ file.
+__Note:__ when you set new permission classes via the class attribute or decorators you're telling the view to ignore the default list set in the __settings.py__ file.
Provided they inherit from `rest_framework.permissions.BasePermission`, permissions can be composed using standard Python bitwise operators. For example, `IsAuthenticatedOrReadOnly` could be written:
@@ -169,7 +171,7 @@ This permission is suitable if you want to your API to allow read permissions to
## DjangoModelPermissions
-This permission class ties into Django's standard `django.contrib.auth` [model permissions][contribauth]. This permission must only be applied to views that have a `.queryset` property set. Authorization will only be granted if the user *is authenticated* and has the *relevant model permissions* assigned.
+This permission class ties into Django's standard `django.contrib.auth` [model permissions][contribauth]. This permission must only be applied to views that have a `.queryset` property or `get_queryset()` method. Authorization will only be granted if the user *is authenticated* and has the *relevant model permissions* assigned.
* `POST` requests require the user to have the `add` permission on the model.
* `PUT` and `PATCH` requests require the user to have the `change` permission on the model.
@@ -179,12 +181,6 @@ The default behaviour can also be overridden to support custom model permissions
To use custom model permissions, override `DjangoModelPermissions` and set the `.perms_map` property. Refer to the source code for details.
-#### Using with views that do not include a `queryset` attribute.
-
-If you're using this permission with a view that uses an overridden `get_queryset()` method there may not be a `queryset` attribute on the view. In this case we suggest also marking the view with a sentinel queryset, so that this class can determine the required permissions. For example:
-
- queryset = User.objects.none() # Required for DjangoModelPermissions
-
## DjangoModelPermissionsOrAnonReadOnly
Similar to `DjangoModelPermissions`, but also allows unauthenticated users to have read-only access to the API.
@@ -278,6 +274,30 @@ Note that the generic views will check the appropriate object level permissions,
Also note that the generic views will only check the object-level permissions for views that retrieve a single model instance. If you require object-level filtering of list views, you'll need to filter the queryset separately. See the [filtering documentation][filtering] for more details.
+# Overview of access restriction methods
+
+REST framework offers three different methods to customize access restrictions on a case-by-case basis. These apply in different scenarios and have different effects and limitations.
+
+ * `queryset`/`get_queryset()`: Limits the general visibility of existing objects from the database. The queryset limits which objects will be listed and which objects can be modified or deleted. The `get_queryset()` method can apply different querysets based on the current action.
+ * `permission_classes`/`get_permissions()`: General permission checks based on the current action, request and targeted object. Object level permissions can only be applied to retrieve, modify and deletion actions. Permission checks for list and create will be applied to the entire object type. (In case of list: subject to restrictions in the queryset.)
+ * `serializer_class`/`get_serializer()`: Instance level restrictions that apply to all objects on input and output. The serializer may have access to the request context. The `get_serializer()` method can apply different serializers based on the current action.
+
+The following table lists the access restriction methods and the level of control they offer over which actions.
+
+| | `queryset` | `permission_classes` | `serializer_class` |
+|------------------------------------|------------|----------------------|--------------------|
+| Action: list | global | no | object-level* |
+| Action: create | no | global | object-level |
+| Action: retrieve | global | object-level | object-level |
+| Action: update | global | object-level | object-level |
+| Action: partial_update | global | object-level | object-level |
+| Action: destroy | global | object-level | no |
+| Can reference action in decision | no** | yes | no** |
+| Can reference request in decision | no** | yes | yes |
+
+ \* A Serializer class should not raise PermissionDenied in a list action, or the entire list would not be returned. <br>
+ \** The `get_*()` methods have access to the current view and can return different Serializer or QuerySet instances based on the request or action.
+
---
# Third party packages
diff --git a/docs/api-guide/relations.md b/docs/api-guide/relations.md
index d3d8b30b8c..f444125cff 100644
--- a/docs/api-guide/relations.md
+++ b/docs/api-guide/relations.md
@@ -603,6 +603,6 @@ The [rest-framework-generic-relations][drf-nested-relations] library provides re
[generic-relations]: https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/#id1
[drf-nested-routers]: https://github.com/alanjds/drf-nested-routers
[drf-nested-relations]: https://github.com/Ian-Foote/rest-framework-generic-relations
-[django-intermediary-manytomany]: https://docs.djangoproject.com/en/2.2/topics/db/models/#intermediary-manytomany
+[django-intermediary-manytomany]: https://docs.djangoproject.com/en/stable/topics/db/models/#intermediary-manytomany
[dealing-with-nested-objects]: https://www.django-rest-framework.org/api-guide/serializers/#dealing-with-nested-objects
[to_internal_value]: https://www.django-rest-framework.org/api-guide/serializers/#to_internal_valueself-data
diff --git a/docs/api-guide/renderers.md b/docs/api-guide/renderers.md
index ca3a29b82c..954fb3bb98 100644
--- a/docs/api-guide/renderers.md
+++ b/docs/api-guide/renderers.md
@@ -103,6 +103,16 @@ Unlike other renderers, the data passed to the `Response` does not need to be se
The TemplateHTMLRenderer will create a `RequestContext`, using the `response.data` as the context dict, and determine a template name to use to render the context.
+---
+
+**Note:** When used with a view that makes use of a serializer the `Response` sent for rendering may not be a dictionay and will need to be wrapped in a dict before returning to allow the TemplateHTMLRenderer to render it. For example:
+
+```
+response.data = {'results': response.data}
+```
+
+---
+
The template name is determined by (in order of preference):
1. An explicit `template_name` argument passed to the response.
diff --git a/docs/api-guide/requests.md b/docs/api-guide/requests.md
index 1c336953ca..e877c868df 100644
--- a/docs/api-guide/requests.md
+++ b/docs/api-guide/requests.md
@@ -23,7 +23,7 @@ REST framework's Request objects provide flexible request parsing that allows yo
* It includes all parsed content, including *file and non-file* inputs.
* It supports parsing the content of HTTP methods other than `POST`, meaning that you can access the content of `PUT` and `PATCH` requests.
-* It supports REST framework's flexible request parsing, rather than just supporting form data. For example you can handle incoming JSON data in the same way that you handle incoming form data.
+* It supports REST framework's flexible request parsing, rather than just supporting form data. For example you can handle incoming [JSON data] similarly to how you handle incoming [form data].
For more details see the [parsers documentation].
@@ -136,5 +136,7 @@ Note that due to implementation reasons the `Request` class does not inherit fro
[cite]: https://groups.google.com/d/topic/django-developers/dxI4qVzrBY4/discussion
[parsers documentation]: parsers.md
+[JSON data]: parsers.md#jsonparser
+[form data]: parsers.md#formparser
[authentication documentation]: authentication.md
[browser enhancements documentation]: ../topics/browser-enhancements.md
diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md
index fd5dbb0e67..f05fe7e7e9 100644
--- a/docs/api-guide/serializers.md
+++ b/docs/api-guide/serializers.md
@@ -282,7 +282,7 @@ If a nested representation may optionally accept the `None` value you should pas
content = serializers.CharField(max_length=200)
created = serializers.DateTimeField()
-Similarly if a nested representation should be a list of items, you should pass the `many=True` flag to the nested serialized.
+Similarly if a nested representation should be a list of items, you should pass the `many=True` flag to the nested serializer.
class CommentSerializer(serializers.Serializer):
user = UserSerializer(required=False)
diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md
index 215c735bf4..a3e42cacf9 100644
--- a/docs/api-guide/throttling.md
+++ b/docs/api-guide/throttling.md
@@ -59,7 +59,7 @@ using the `APIView` class-based views.
}
return Response(content)
-Or, if you're using the `@api_view` decorator with function based views.
+If you're using the `@api_view` decorator with function based views you can use the following decorator.
@api_view(['GET'])
@throttle_classes([UserRateThrottle])
@@ -69,6 +69,16 @@ Or, if you're using the `@api_view` decorator with function based views.
}
return Response(content)
+It's also possible to set throttle classes for routes that are created using the `@action` decorator.
+Throttle classes set in this way will override any viewset level class settings.
+
+ @action(detail=True, methods=["post"], throttle_classes=[UserRateThrottle])
+ def example_adhoc_method(request, pk=None):
+ content = {
+ 'status': 'request was permitted'
+ }
+ return Response(content)
+
## How clients are identified
The `X-Forwarded-For` HTTP header and `REMOTE_ADDR` WSGI variable are used to uniquely identify client IP addresses for throttling. If the `X-Forwarded-For` header is present then it will be used, otherwise the value of the `REMOTE_ADDR` variable from the WSGI environment will be used.
diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md
index 22cc3d8aa9..d4ab5a7317 100644
--- a/docs/api-guide/viewsets.md
+++ b/docs/api-guide/viewsets.md
@@ -152,7 +152,7 @@ A more complete example of extra actions:
user = self.get_object()
serializer = PasswordSerializer(data=request.data)
if serializer.is_valid():
- user.set_password(serializer.data['password'])
+ user.set_password(serializer.validated_data['password'])
user.save()
return Response({'status': 'password set'})
else:
@@ -185,7 +185,7 @@ The decorator allows you to override any viewset-level configuration such as `pe
def set_password(self, request, pk=None):
...
-The two new actions will then be available at the urls `^users/{pk}/set_password/$` and `^users/{pk}/unset_password/$`. Use the `url_path` and `url_name` parameters to change the URL segement and the reverse URL name of the action.
+The two new actions will then be available at the urls `^users/{pk}/set_password/$` and `^users/{pk}/unset_password/$`. Use the `url_path` and `url_name` parameters to change the URL segment and the reverse URL name of the action.
To view all extra actions, call the `.get_extra_actions()` method.
diff --git a/docs/community/release-notes.md b/docs/community/release-notes.md
index c981b9ac92..72e6b466b5 100644
--- a/docs/community/release-notes.md
+++ b/docs/community/release-notes.md
@@ -38,6 +38,22 @@ You can determine your currently installed version using `pip show`:
### 3.12.2
+Date: 25th March 2021
+
+* Properly handle ATOMIC_REQUESTS when multiple database configurations are used. [#7739]
+* Bypass `COUNT` query when `LimitOffsetPagination` is configured but pagination params are not included on the request. [#6098]
+* Respect `allow_null=True` on `DecimalField`. [#7718]
+* Allow title cased `"Yes"`/`"No"` values with `BooleanField`. [#7739]
+* Add `PageNumberPagination.get_page_number()` method for overriding behavior. [#7652]
+* Fixed rendering of timedelta values in OpenAPI schemas, when present as default, min, or max fields. [#7641]
+* Render JSONFields with indentation in browsable API forms. [#6243]
+* Remove unnecessary database query in admin Token views. [#7852]
+* Raise validation errors when bools are passed to `PrimaryKeyRelatedField` fields, instead of casting to ints. [#7597]
+* Don't include model properties as automatically generated ordering fields with `OrderingFilter`. [#7609]
+* Use `deque` instead of `list` for tracking throttling `.history`. [#7849]
+
+### 3.12.2
+
Date: 13th October 2020
* Fix issue if `rest_framework.authtoken.models` is imported, but `rest_framework.authtoken` is not in INSTALLED_APPS. [#7571]
@@ -177,6 +193,8 @@ Date: 28th September 2020
* Don't strict disallow redundant `SerializerMethodField` field name arguments.
* Don't render extra actions in browable API if not authenticated.
* Strip null characters from search parameters.
+* Deprecate the `detail_route` decorator in favor of `action`, which accepts a `detail` bool. Use `@action(detail=True)` instead. [gh6687]
+* Deprecate the `list_route` decorator in favor of `action`, which accepts a `detail` bool. Use `@action(detail=False)` instead. [gh6687]
## 3.9.x series
@@ -2270,6 +2288,7 @@ For older release notes, [please see the version 2.x documentation][old-release-
<!-- 3.10.0 -->
[gh6680]: https://github.com/encode/django-rest-framework/issues/6680
[gh6317]: https://github.com/encode/django-rest-framework/issues/6317
+[gh6687]: https://github.com/encode/django-rest-framework/issues/6687
<!-- 3.11.0 -->
[gh6892]: https://github.com/encode/django-rest-framework/issues/6892
diff --git a/docs/community/third-party-packages.md b/docs/community/third-party-packages.md
index d4359890dd..046966594c 100644
--- a/docs/community/third-party-packages.md
+++ b/docs/community/third-party-packages.md
@@ -190,6 +190,7 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque
* [django-rest-auth][django-rest-auth] - Provides a set of REST API endpoints for registration, authentication (including social media authentication), password reset, retrieve and update user details, etc.
* [drf-oidc-auth][drf-oidc-auth] - Implements OpenID Connect token authentication for DRF.
* [drfpasswordless][drfpasswordless] - Adds (Medium, Square Cash inspired) passwordless logins and signups via email and mobile numbers.
+* [django-rest-authemail][django-rest-authemail] - Provides a RESTful API for user signup and authentication using email addresses.
### Permissions
@@ -214,17 +215,19 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque
* [drf-action-serializer][drf-action-serializer] - Serializer providing per-action fields config for use with ViewSets to prevent having to write multiple serializers.
* [djangorestframework-dataclasses][djangorestframework-dataclasses] - Serializer providing automatic field generation for Python dataclasses, like the built-in ModelSerializer does for models.
* [django-restql][django-restql] - Turn your REST API into a GraphQL like API(It allows clients to control which fields will be sent in a response, uses GraphQL like syntax, supports read and write on both flat and nested fields).
+* [graphwrap][graphwrap] - Transform your REST API into a fully compliant GraphQL API with just two lines of code. Leverages [Graphene-Django](https://docs.graphene-python.org/projects/django/en/latest/) to dynamically build, at runtime, a GraphQL ObjectType for each view in your API.
### Serializer fields
* [drf-compound-fields][drf-compound-fields] - Provides "compound" serializer fields, such as lists of simple values.
-* [django-extra-fields][django-extra-fields] - Provides extra serializer fields.
+* [drf-extra-fields][drf-extra-fields] - Provides extra serializer fields.
* [django-versatileimagefield][django-versatileimagefield] - Provides a drop-in replacement for Django's stock `ImageField` that makes it easy to serve images in multiple sizes/renditions from a single field. For DRF-specific implementation docs, [click here][django-versatileimagefield-drf-docs].
### Views
* [django-rest-multiple-models][django-rest-multiple-models] - Provides a generic view (and mixin) for sending multiple serialized models and/or querysets via a single API request.
* [drf-typed-views][drf-typed-views] - Use Python type annotations to validate/deserialize request parameters. Inspired by API Star, Hug and FastAPI.
+* [rest-framework-actions][rest-framework-actions] - Provides control over each action in ViewSets. Serializers per action, method.
### Routers
@@ -276,6 +279,7 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque
* [djangorestframework-features][djangorestframework-features] - Advanced schema generation and more based on named features.
* [django-elasticsearch-dsl-drf][django-elasticsearch-dsl-drf] - Integrate Elasticsearch DSL with Django REST framework. Package provides views, serializers, filter backends, pagination and other handy add-ons.
* [django-api-client][django-api-client] - DRF client that groups the Endpoint response, for use in CBVs and FBV as if you were working with Django's Native Models..
+* [fast-drf] - A model based library for making API development faster and easier.
[cite]: http://www.software-ecosystems.com/Software_Ecosystems/Ecosystems.html
[cookiecutter]: https://github.com/jpadilla/cookiecutter-django-rest-framework
@@ -308,7 +312,7 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque
[djangorestframework-gis]: https://github.com/djangonauts/django-rest-framework-gis
[djangorestframework-hstore]: https://github.com/djangonauts/django-rest-framework-hstore
[drf-compound-fields]: https://github.com/estebistec/drf-compound-fields
-[django-extra-fields]: https://github.com/Hipo/drf-extra-fields
+[drf-extra-fields]: https://github.com/Hipo/drf-extra-fields
[django-rest-multiple-models]: https://github.com/MattBroach/DjangoRestMultipleModels
[drf-nested-routers]: https://github.com/alanjds/drf-nested-routers
[wq.db.rest]: https://wq.io/docs/about-rest
@@ -362,3 +366,7 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque
[django-elasticsearch-dsl-drf]: https://github.com/barseghyanartur/django-elasticsearch-dsl-drf
[django-api-client]: https://github.com/rhenter/django-api-client
[drf-psq]: https://github.com/drf-psq/drf-psq
+[django-rest-authemail]: https://github.com/celiao/django-rest-authemail
+[graphwrap]: https://github.com/PaulGilmartin/graph_wrap
+[rest-framework-actions]: https://github.com/AlexisMunera98/rest-framework-actions
+[fast-drf]: https://github.com/iashraful/fast-drf
diff --git a/docs/index.md b/docs/index.md
index 0273da9f14..0e6bb48f2e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -190,11 +190,6 @@ For support please see the [REST framework discussion group][group], try the `#
For priority support please sign up for a [professional or premium sponsorship plan](https://fund.django-rest-framework.org/topics/funding/).
-For updates on REST framework development, you may also want to follow [the author][twitter] on Twitter.
-
-<a style="padding-top: 10px" href="https://twitter.com/_tomchristie" class="twitter-follow-button" data-show-count="false">Follow @_tomchristie</a>
-<script>!function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0];if(!d.getElementById(id)){js=d.createElement(s);js.id=id;js.src="//platform.twitter.com/widgets.js";fjs.parentNode.insertBefore(js,fjs);}}(document,"script","twitter-wjs");</script>
-
## Security
If you believe you’ve found something in Django REST framework which has security implications, please **do not raise the issue in a public forum**.
diff --git a/docs/topics/api-clients.md b/docs/topics/api-clients.md
index 9b61eaf427..b9f5e3ecd8 100644
--- a/docs/topics/api-clients.md
+++ b/docs/topics/api-clients.md
@@ -453,7 +453,7 @@ For example, using the "Django REST framework JWT" package
function loginUser(username, password) {
let action = ["api-token-auth", "obtain-token"];
- let params = {username: "example", email: "example@example.com"};
+ let params = {username: username, password: password};
client.action(schema, action, params).then(function(result) {
// On success, instantiate an authenticated client.
let auth = window.coreapi.auth.TokenAuthentication({
diff --git a/docs/topics/documenting-your-api.md b/docs/topics/documenting-your-api.md
index cd7e5098fe..5eabeee7bb 100644
--- a/docs/topics/documenting-your-api.md
+++ b/docs/topics/documenting-your-api.md
@@ -202,7 +202,7 @@ You can modify the response behavior to `OPTIONS` requests by overriding the `op
meta = self.metadata_class()
data = meta.determine_metadata(request, self)
data.pop('description')
- return data
+ return Response(data=data, status=status.HTTP_200_OK)
See [the Metadata docs][metadata-docs] for more details.
diff --git a/docs/topics/internationalization.md b/docs/topics/internationalization.md
index 7cfc6e247c..c20cf9e339 100644
--- a/docs/topics/internationalization.md
+++ b/docs/topics/internationalization.md
@@ -103,10 +103,10 @@ You can find more information on how the language preference is determined in th
For API clients the most appropriate of these will typically be to use the `Accept-Language` header; Sessions and cookies will not be available unless using session authentication, and generally better practice to prefer an `Accept-Language` header for API clients rather than using language URL prefixes.
[cite]: https://youtu.be/Wa0VfS2q94Y
-[django-translation]: https://docs.djangoproject.com/en/1.7/topics/i18n/translation
+[django-translation]: https://docs.djangoproject.com/en/stable/topics/i18n/translation
[custom-exception-handler]: ../api-guide/exceptions.md#custom-exception-handling
[transifex-project]: https://www.transifex.com/projects/p/django-rest-framework/
[django-po-source]: https://raw.githubusercontent.com/encode/django-rest-framework/master/rest_framework/locale/en_US/LC_MESSAGES/django.po
-[django-language-preference]: https://docs.djangoproject.com/en/1.7/topics/i18n/translation/#how-django-discovers-language-preference
-[django-locale-paths]: https://docs.djangoproject.com/en/1.7/ref/settings/#std:setting-LOCALE_PATHS
-[django-locale-name]: https://docs.djangoproject.com/en/1.7/topics/i18n/#term-locale-name
+[django-language-preference]: https://docs.djangoproject.com/en/stable/topics/i18n/translation/#how-django-discovers-language-preference
+[django-locale-paths]: https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-LOCALE_PATHS
+[django-locale-name]: https://docs.djangoproject.com/en/stable/topics/i18n/#term-locale-name
diff --git a/docs/tutorial/5-relationships-and-hyperlinked-apis.md b/docs/tutorial/5-relationships-and-hyperlinked-apis.md
index 4cd4e9bbd5..b0f3380859 100644
--- a/docs/tutorial/5-relationships-and-hyperlinked-apis.md
+++ b/docs/tutorial/5-relationships-and-hyperlinked-apis.md
@@ -143,7 +143,7 @@ We can change the default list style to use pagination, by modifying our `tutori
Note that settings in REST framework are all namespaced into a single dictionary setting, named `REST_FRAMEWORK`, which helps keep them well separated from your other project settings.
-We could also customize the pagination style if we needed too, but in this case we'll just stick with the default.
+We could also customize the pagination style if we needed to, but in this case we'll just stick with the default.
## Browsing the API
diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt
index 2b7a18a13f..739555667e 100644
--- a/requirements/requirements-optionals.txt
+++ b/requirements/requirements-optionals.txt
@@ -1,6 +1,7 @@
# Optional packages which may be used with REST framework.
psycopg2-binary>=2.8.5, <2.9
-markdown==3.1.1
+markdown==3.3;python_version>="3.6"
+markdown==3.2.2;python_version=="3.5"
pygments==2.4.2
django-guardian==2.2.0
django-filter>=2.2.0, <2.3
diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt
index ad246e8570..c5198dec54 100644
--- a/requirements/requirements-testing.txt
+++ b/requirements/requirements-testing.txt
@@ -1,4 +1,4 @@
# Pytest for running the tests.
-pytest>=5.4.1,<5.5
-pytest-django>=3.9.0,<3.10
-pytest-cov>=2.7.1
+pytest>=6.1.1,<6.2
+pytest-django>=4.1.0,<4.2
+pytest-cov>=2.10.1
diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py
index 7ff188a5ad..eb5d605b9b 100644
--- a/rest_framework/__init__.py
+++ b/rest_framework/__init__.py
@@ -10,7 +10,7 @@
import django
__title__ = 'Django REST framework'
-__version__ = '3.12.2'
+__version__ = '3.12.3'
__author__ = 'Tom Christie'
__license__ = 'BSD 3-Clause'
__copyright__ = 'Copyright 2011-2019 Encode OSS Ltd'
diff --git a/rest_framework/authtoken/models.py b/rest_framework/authtoken/models.py
index 540049295d..5a143d936c 100644
--- a/rest_framework/authtoken/models.py
+++ b/rest_framework/authtoken/models.py
@@ -46,7 +46,7 @@ class TokenProxy(Token):
"""
@property
def pk(self):
- return self.user.pk
+ return self.user_id
class Meta:
proxy = 'rest_framework.authtoken' in settings.INSTALLED_APPS
diff --git a/rest_framework/exceptions.py b/rest_framework/exceptions.py
index 943dcc88c3..fee8f024f2 100644
--- a/rest_framework/exceptions.py
+++ b/rest_framework/exceptions.py
@@ -20,7 +20,7 @@ def _get_error_details(data, default_code=None):
Descend into a nested data structure, forcing any
lazy translation strings or strings into `ErrorDetail`.
"""
- if isinstance(data, list):
+ if isinstance(data, (list, tuple)):
ret = [
_get_error_details(item, default_code) for item in data
]
@@ -150,7 +150,9 @@ def __init__(self, detail=None, code=None):
# For validation failures, we may collect many errors together,
# so the details should always be coerced to a list if not already.
- if not isinstance(detail, dict) and not isinstance(detail, list):
+ if isinstance(detail, tuple):
+ detail = list(detail)
+ elif not isinstance(detail, dict) and not isinstance(detail, list):
detail = [detail]
self.detail = _get_error_details(detail, code)
diff --git a/rest_framework/fields.py b/rest_framework/fields.py
index fdfba13f26..e4be54751d 100644
--- a/rest_framework/fields.py
+++ b/rest_framework/fields.py
@@ -704,7 +704,7 @@ class BooleanField(Field):
initial = False
TRUE_VALUES = {
't', 'T',
- 'y', 'Y', 'yes', 'YES',
+ 'y', 'Y', 'yes', 'Yes', 'YES',
'true', 'True', 'TRUE',
'on', 'On', 'ON',
'1', 1,
@@ -712,7 +712,7 @@ class BooleanField(Field):
}
FALSE_VALUES = {
'f', 'F',
- 'n', 'N', 'no', 'NO',
+ 'n', 'N', 'no', 'No', 'NO',
'false', 'False', 'FALSE',
'off', 'Off', 'OFF',
'0', 0, 0.0,
@@ -1063,6 +1063,9 @@ def to_internal_value(self, data):
try:
value = decimal.Decimal(data)
except decimal.DecimalException:
+ if data == '' and self.allow_null:
+ return None
+
self.fail('invalid')
if value.is_nan():
@@ -1112,6 +1115,12 @@ def validate_precision(self, value):
def to_representation(self, value):
coerce_to_string = getattr(self, 'coerce_to_string', api_settings.COERCE_DECIMAL_TO_STRING)
+ if value is None:
+ if coerce_to_string:
+ return ''
+ else:
+ return None
+
if not isinstance(value, decimal.Decimal):
value = decimal.Decimal(str(value).strip())
@@ -1755,6 +1764,9 @@ class JSONField(Field):
'invalid': _('Value must be valid JSON.')
}
+ # Workaround for isinstance calls when importing the field isn't possible
+ _is_jsonfield = True
+
def __init__(self, *args, **kwargs):
self.binary = kwargs.pop('binary', False)
self.encoder = kwargs.pop('encoder', None)
diff --git a/rest_framework/filters.py b/rest_framework/filters.py
index 3665775195..1ffd9edc02 100644
--- a/rest_framework/filters.py
+++ b/rest_framework/filters.py
@@ -226,10 +226,20 @@ def get_default_valid_fields(self, queryset, view, context={}):
)
raise ImproperlyConfigured(msg % self.__class__.__name__)
+ model_class = queryset.model
+ model_property_names = [
+ # 'pk' is a property added in Django's Model class, however it is valid for ordering.
+ attr for attr in dir(model_class) if isinstance(getattr(model_class, attr), property) and attr != 'pk'
+ ]
+
return [
(field.source.replace('.', '__') or field_name, field.label)
for field_name, field in serializer_class(context=context).fields.items()
- if not getattr(field, 'write_only', False) and not field.source == '*'
+ if (
+ not getattr(field, 'write_only', False) and
+ not field.source == '*' and
+ field.source not in model_property_names
+ )
]
def get_valid_fields(self, queryset, view, context={}):
diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py
index 60a57c8e4a..91da73de64 100644
--- a/rest_framework/pagination.py
+++ b/rest_framework/pagination.py
@@ -198,9 +198,7 @@ def paginate_queryset(self, queryset, request, view=None):
return None
paginator = self.django_paginator_class(queryset, page_size)
- page_number = request.query_params.get(self.page_query_param, 1)
- if page_number in self.last_page_strings:
- page_number = paginator.num_pages
+ page_number = self.get_page_number(request, paginator)
try:
self.page = paginator.page(page_number)
@@ -217,6 +215,12 @@ def paginate_queryset(self, queryset, request, view=None):
self.request = request
return list(self.page)
+ def get_page_number(self, request, paginator):
+ page_number = request.query_params.get(self.page_query_param, 1)
+ if page_number in self.last_page_strings:
+ page_number = paginator.num_pages
+ return page_number
+
def get_paginated_response(self, data):
return Response(OrderedDict([
('count', self.page.paginator.count),
@@ -376,11 +380,11 @@ class LimitOffsetPagination(BasePagination):
template = 'rest_framework/pagination/numbers.html'
def paginate_queryset(self, queryset, request, view=None):
- self.count = self.get_count(queryset)
self.limit = self.get_limit(request)
if self.limit is None:
return None
+ self.count = self.get_count(queryset)
self.offset = self.get_offset(request)
self.request = request
if self.count > self.limit and self.template is not None:
diff --git a/rest_framework/relations.py b/rest_framework/relations.py
index eaf27e1d96..cbdf233698 100644
--- a/rest_framework/relations.py
+++ b/rest_framework/relations.py
@@ -259,6 +259,8 @@ def to_internal_value(self, data):
data = self.pk_field.to_internal_value(data)
queryset = self.get_queryset()
try:
+ if isinstance(data, bool):
+ raise TypeError
return queryset.get(pk=data)
except ObjectDoesNotExist:
self.fail('does_not_exist', pk_value=data)
diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py
index 3c4be8aeb0..5b7ba8a8c8 100644
--- a/rest_framework/renderers.py
+++ b/rest_framework/renderers.py
@@ -1063,7 +1063,8 @@ def ignore_aliases(self, data):
class JSONOpenAPIRenderer(BaseRenderer):
media_type = 'application/vnd.oai.openapi+json'
charset = None
+ encoder_class = encoders.JSONEncoder
format = 'openapi-json'
def render(self, data, media_type=None, renderer_context=None):
- return json.dumps(data, indent=2).encode('utf-8')
+ return json.dumps(data, cls=self.encoder_class, indent=2).encode('utf-8')
diff --git a/rest_framework/static/rest_framework/css/default.css b/rest_framework/static/rest_framework/css/default.css
index 86fef17737..51ca3ba19e 100644
--- a/rest_framework/static/rest_framework/css/default.css
+++ b/rest_framework/static/rest_framework/css/default.css
@@ -40,7 +40,7 @@ td.nested > table {
margin: 0;
}
-form select, form input, form textarea {
+form select, form input:not([type=checkbox]), form textarea {
width: 90%;
}
diff --git a/rest_framework/templates/rest_framework/filters/search.html b/rest_framework/templates/rest_framework/filters/search.html
index edb28d45d8..065c3889ac 100644
--- a/rest_framework/templates/rest_framework/filters/search.html
+++ b/rest_framework/templates/rest_framework/filters/search.html
@@ -5,7 +5,7 @@ <h2>{% trans "Search" %}</h2>
<div class="input-group">
<input type="text" class="form-control" style="width: 350px" name="{{ param }}" value="{{ term }}">
<span class="input-group-btn">
- <button class="btn btn-default" type="submit"><span class="glyphicon glyphicon-search" aria-hidden="true"></span> Search</button>
+ <button class="btn btn-default" type="submit"><span class="glyphicon glyphicon-search" aria-hidden="true"></span> {% trans "Search" %}</button>
</span>
</div>
</div>
diff --git a/rest_framework/test.py b/rest_framework/test.py
index f2581cacca..8ab0f2de19 100644
--- a/rest_framework/test.py
+++ b/rest_framework/test.py
@@ -79,7 +79,7 @@ def send(self, request, *args, **kwargs):
"""
raw_kwargs = {}
- def start_response(wsgi_status, wsgi_headers):
+ def start_response(wsgi_status, wsgi_headers, exc_info=None):
status, _, reason = wsgi_status.partition(' ')
raw_kwargs['status'] = int(status)
raw_kwargs['reason'] = reason
diff --git a/rest_framework/throttling.py b/rest_framework/throttling.py
index 0ba2ba66b1..1374d44925 100644
--- a/rest_framework/throttling.py
+++ b/rest_framework/throttling.py
@@ -2,6 +2,7 @@
Provides various throttling policies.
"""
import time
+from collections import deque
from django.core.cache import cache as default_cache
from django.core.exceptions import ImproperlyConfigured
@@ -120,7 +121,7 @@ def allow_request(self, request, view):
if self.key is None:
return True
- self.history = self.cache.get(self.key, [])
+ self.history = self.cache.get(self.key, deque())
self.now = self.timer()
# Drop any requests from the history which have now passed the
diff --git a/rest_framework/utils/serializer_helpers.py b/rest_framework/utils/serializer_helpers.py
index b18fbe0df9..4cd2ada314 100644
--- a/rest_framework/utils/serializer_helpers.py
+++ b/rest_framework/utils/serializer_helpers.py
@@ -1,5 +1,5 @@
from collections import OrderedDict
-from collections.abc import MutableMapping
+from collections.abc import Mapping, MutableMapping
from django.utils.encoding import force_str
@@ -87,7 +87,12 @@ def as_form_field(self):
# value will be a JSONString, rather than a JSON primitive.
if not getattr(value, 'is_json_string', False):
try:
- value = json.dumps(self.value, sort_keys=True, indent=4)
+ value = json.dumps(
+ self.value,
+ sort_keys=True,
+ indent=4,
+ separators=(',', ': '),
+ )
except (TypeError, ValueError):
pass
return self.__class__(self._field, value, self.errors, self._prefix)
@@ -101,7 +106,7 @@ class NestedBoundField(BoundField):
"""
def __init__(self, field, value, errors, prefix=''):
- if value is None or value == '':
+ if value is None or value == '' or not isinstance(value, Mapping):
value = {}
super().__init__(field, value, errors, prefix)
@@ -115,6 +120,8 @@ def __getitem__(self, key):
error = self.errors.get(key) if isinstance(self.errors, dict) else None
if hasattr(field, 'fields'):
return NestedBoundField(field, value, error, prefix=self.name + '.')
+ elif getattr(field, '_is_jsonfield', False):
+ return JSONBoundField(field, value, error, prefix=self.name + '.')
return BoundField(field, value, error, prefix=self.name + '.')
def as_form_field(self):
diff --git a/rest_framework/views.py b/rest_framework/views.py
index d1b5e4ed90..5b06220691 100644
--- a/rest_framework/views.py
+++ b/rest_framework/views.py
@@ -3,7 +3,7 @@
"""
from django.conf import settings
from django.core.exceptions import PermissionDenied
-from django.db import connection, models, transaction
+from django.db import connections, models
from django.http import Http404
from django.http.response import HttpResponseBase
from django.utils.cache import cc_delim_re, patch_vary_headers
@@ -63,9 +63,9 @@ def get_view_description(view, html=False):
def set_rollback():
- atomic_requests = connection.settings_dict.get('ATOMIC_REQUESTS', False)
- if atomic_requests and connection.in_atomic_block:
- transaction.set_rollback(True)
+ for db in connections.all():
+ if db.settings_dict['ATOMIC_REQUESTS'] and db.in_atomic_block:
+ db.set_rollback(True)
def exception_handler(exc, context):
diff --git a/tests/conftest.py b/tests/conftest.py
index ac29e4a429..cc32cc6373 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -24,6 +24,10 @@ def pytest_configure(config):
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:'
+ },
+ 'secondary': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': ':memory:'
}
},
SITE_ID=1,
diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py
index 542c377b15..871eb1b302 100644
--- a/tests/schemas/test_openapi.py
+++ b/tests/schemas/test_openapi.py
@@ -11,7 +11,8 @@
from rest_framework.compat import uritemplate
from rest_framework.parsers import JSONParser, MultiPartParser
from rest_framework.renderers import (
- BaseRenderer, BrowsableAPIRenderer, JSONRenderer, OpenAPIRenderer
+ BaseRenderer, BrowsableAPIRenderer, JSONOpenAPIRenderer, JSONRenderer,
+ OpenAPIRenderer
)
from rest_framework.request import Request
from rest_framework.schemas.openapi import AutoSchema, SchemaGenerator
@@ -992,6 +993,19 @@ def test_schema_construction(self):
assert 'openapi' in schema
assert 'paths' in schema
+ def test_schema_rendering_to_json(self):
+ patterns = [
+ path('example/', views.ExampleGenericAPIView.as_view()),
+ ]
+ generator = SchemaGenerator(patterns=patterns)
+
+ request = create_request('/')
+ schema = generator.get_schema(request=request)
+ ret = JSONOpenAPIRenderer().render(schema)
+
+ assert b'"openapi": "' in ret
+ assert b'"default": "0.0"' in ret
+
def test_schema_with_no_paths(self):
patterns = []
generator = SchemaGenerator(patterns=patterns)
diff --git a/tests/schemas/views.py b/tests/schemas/views.py
index 18b3beae4e..f1ed0bd4e3 100644
--- a/tests/schemas/views.py
+++ b/tests/schemas/views.py
@@ -1,4 +1,5 @@
import uuid
+from datetime import timedelta
from django.core.validators import (
DecimalValidator, MaxLengthValidator, MaxValueValidator,
@@ -59,6 +60,7 @@ def get(self, *args, **kwargs):
class ExampleSerializer(serializers.Serializer):
date = serializers.DateField()
datetime = serializers.DateTimeField()
+ duration = serializers.DurationField(default=timedelta())
hstore = serializers.HStoreField()
uuid_field = serializers.UUIDField(default=uuid.uuid4)
diff --git a/tests/test_atomic_requests.py b/tests/test_atomic_requests.py
index 15b41e02f4..beda5cba19 100644
--- a/tests/test_atomic_requests.py
+++ b/tests/test_atomic_requests.py
@@ -130,6 +130,41 @@ def test_api_exception_rollback_transaction(self):
assert BasicModel.objects.count() == 0
+@unittest.skipUnless(
+ connection.features.uses_savepoints,
+ "'atomic' requires transactions and savepoints."
+)
+class MultiDBTransactionAPIExceptionTests(TestCase):
+ databases = '__all__'
+
+ def setUp(self):
+ self.view = APIExceptionView.as_view()
+ connections.databases['default']['ATOMIC_REQUESTS'] = True
+ connections.databases['secondary']['ATOMIC_REQUESTS'] = True
+
+ def tearDown(self):
+ connections.databases['default']['ATOMIC_REQUESTS'] = False
+ connections.databases['secondary']['ATOMIC_REQUESTS'] = False
+
+ def test_api_exception_rollback_transaction(self):
+ """
+ Transaction is rollbacked by our transaction atomic block.
+ """
+ request = factory.post('/')
+ num_queries = 4 if connection.features.can_release_savepoints else 3
+ with self.assertNumQueries(num_queries):
+ # 1 - begin savepoint
+ # 2 - insert
+ # 3 - rollback savepoint
+ # 4 - release savepoint
+ with transaction.atomic(), transaction.atomic(using='secondary'):
+ response = self.view(request)
+ assert transaction.get_rollback()
+ assert transaction.get_rollback(using='secondary')
+ assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
+ assert BasicModel.objects.count() == 0
+
+
@unittest.skipUnless(
connection.features.uses_savepoints,
"'atomic' requires transactions and savepoints."
diff --git a/tests/test_bound_fields.py b/tests/test_bound_fields.py
index dc5ab542ff..eee7d9b852 100644
--- a/tests/test_bound_fields.py
+++ b/tests/test_bound_fields.py
@@ -91,6 +91,10 @@ class ExampleSerializer(serializers.Serializer):
assert rendered_packed == expected_packed
+class CustomJSONField(serializers.JSONField):
+ pass
+
+
class TestNestedBoundField:
def test_nested_empty_bound_field(self):
class Nested(serializers.Serializer):
@@ -117,14 +121,31 @@ def test_as_form_fields(self):
class Nested(serializers.Serializer):
bool_field = serializers.BooleanField()
null_field = serializers.IntegerField(allow_null=True)
+ json_field = serializers.JSONField()
+ custom_json_field = CustomJSONField()
class ExampleSerializer(serializers.Serializer):
nested = Nested()
- serializer = ExampleSerializer(data={'nested': {'bool_field': False, 'null_field': None}})
+ serializer = ExampleSerializer(
+ data={'nested': {
+ 'bool_field': False, 'null_field': None,
+ 'json_field': {'bool_item': True, 'number': 1, 'text_item': 'text'},
+ 'custom_json_field': {'bool_item': True, 'number': 1, 'text_item': 'text'},
+ }})
assert serializer.is_valid()
assert serializer['nested']['bool_field'].as_form_field().value == ''
assert serializer['nested']['null_field'].as_form_field().value == ''
+ assert serializer['nested']['json_field'].as_form_field().value == '''{
+ "bool_item": true,
+ "number": 1,
+ "text_item": "text"
+}'''
+ assert serializer['nested']['custom_json_field'].as_form_field().value == '''{
+ "bool_item": true,
+ "number": 1,
+ "text_item": "text"
+}'''
def test_rendering_nested_fields_with_none_value(self):
from rest_framework.renderers import HTMLFormRenderer
@@ -163,6 +184,33 @@ class ExampleSerializer(serializers.Serializer):
rendered_packed = ''.join(rendered.split())
assert rendered_packed == expected_packed
+ def test_rendering_nested_fields_with_not_mappable_value(self):
+ from rest_framework.renderers import HTMLFormRenderer
+
+ class Nested(serializers.Serializer):
+ text_field = serializers.CharField()
+
+ class ExampleSerializer(serializers.Serializer):
+ nested = Nested()
+
+ serializer = ExampleSerializer(data={'nested': 1})
+ assert not serializer.is_valid()
+ renderer = HTMLFormRenderer()
+ for field in serializer:
+ rendered = renderer.render_field(field, {})
+ expected_packed = (
+ '<fieldset>'
+ '<legend>Nested</legend>'
+ '<divclass="form-group">'
+ '<label>Textfield</label>'
+ '<inputname="nested.text_field"class="form-control"type="text"value="">'
+ '</div>'
+ '</fieldset>'
+ )
+
+ rendered_packed = ''.join(rendered.split())
+ assert rendered_packed == expected_packed
+
class TestJSONBoundField:
def test_as_form_fields(self):
diff --git a/tests/test_description.py b/tests/test_description.py
index ae00fe4a97..9e7e4dc322 100644
--- a/tests/test_description.py
+++ b/tests/test_description.py
@@ -1,192 +1,180 @@
-from django.test import TestCase
-
-from rest_framework.compat import apply_markdown
-from rest_framework.utils.formatting import dedent
-from rest_framework.views import APIView
-
-# We check that docstrings get nicely un-indented.
-DESCRIPTION = """an example docstring
-====================
-
-* list
-* list
-
-another header
---------------
-
- code block
-
-indented
-
-# hash style header #
-
-``` json
-[{
- "alpha": 1,
- "beta: "this is a string"
-}]
-```"""
-
-
-# If markdown is installed we also test it's working
-# (and that our wrapped forces '=' to h2 and '-' to h3)
-MARKED_DOWN_HILITE = """
-<div class="highlight"><pre><span></span><span \
-class="p">[{</span><br /> <span class="nt">"alpha"</span><span\
- class="p">:</span> <span class="mi">1</span><span class="p">,</span><br />\
- <span class="nt">"beta: "</span><span class="err">this</span>\
- <span class="err">is</span> <span class="err">a</span> <span class="err">\
-string"</span><br /><span class="p">}]</span><br /></pre></div>
-
-<p><br /></p>"""
-
-MARKED_DOWN_NOT_HILITE = """
-<p><code>json
-[{
- "alpha": 1,
- "beta: "this is a string"
-}]</code></p>"""
-
-# We support markdown < 2.1 and markdown >= 2.1
-MARKED_DOWN_lt_21 = """<h2>an example docstring</h2>
-<ul>
-<li>list</li>
-<li>list</li>
-</ul>
-<h3>another header</h3>
-<pre><code>code block
-</code></pre>
-<p>indented</p>
-<h2 id="hash_style_header">hash style header</h2>%s"""
-
-MARKED_DOWN_gte_21 = """<h2 id="an-example-docstring">an example docstring</h2>
-<ul>
-<li>list</li>
-<li>list</li>
-</ul>
-<h3 id="another-header">another header</h3>
-<pre><code>code block
-</code></pre>
-<p>indented</p>
-<h2 id="hash-style-header">hash style header</h2>%s"""
-
-
-class TestViewNamesAndDescriptions(TestCase):
- def test_view_name_uses_class_name(self):
- """
- Ensure view names are based on the class name.
- """
- class MockView(APIView):
- pass
- assert MockView().get_view_name() == 'Mock'
-
- def test_view_name_uses_name_attribute(self):
- class MockView(APIView):
- name = 'Foo'
- assert MockView().get_view_name() == 'Foo'
-
- def test_view_name_uses_suffix_attribute(self):
- class MockView(APIView):
- suffix = 'List'
- assert MockView().get_view_name() == 'Mock List'
-
- def test_view_name_preferences_name_over_suffix(self):
- class MockView(APIView):
- name = 'Foo'
- suffix = 'List'
- assert MockView().get_view_name() == 'Foo'
-
- def test_view_description_uses_docstring(self):
- """Ensure view descriptions are based on the docstring."""
- class MockView(APIView):
- """an example docstring
- ====================
-
- * list
- * list
-
- another header
- --------------
-
- code block
-
- indented
-
- # hash style header #
-
- ``` json
- [{
- "alpha": 1,
- "beta: "this is a string"
- }]
- ```"""
-
- assert MockView().get_view_description() == DESCRIPTION
-
- def test_view_description_uses_description_attribute(self):
- class MockView(APIView):
- description = 'Foo'
- assert MockView().get_view_description() == 'Foo'
-
- def test_view_description_allows_empty_description(self):
- class MockView(APIView):
- """Description."""
- description = ''
- assert MockView().get_view_description() == ''
-
- def test_view_description_can_be_empty(self):
- """
- Ensure that if a view has no docstring,
- then it's description is the empty string.
- """
- class MockView(APIView):
- pass
- assert 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/encode/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:
- def __init__(self, string):
- self.s = string
-
- def __str__(self):
- return self.s
-
- class MockView(APIView):
- __doc__ = MockLazyStr("a gettext string")
-
- assert MockView().get_view_description() == 'a gettext string'
-
- def test_markdown(self):
- """
- Ensure markdown to HTML works as expected.
- """
- if apply_markdown:
- md_applied = apply_markdown(DESCRIPTION)
- gte_21_match = (
- md_applied == (
- MARKED_DOWN_gte_21 % MARKED_DOWN_HILITE) or
- md_applied == (
- MARKED_DOWN_gte_21 % MARKED_DOWN_NOT_HILITE))
- lt_21_match = (
- md_applied == (
- MARKED_DOWN_lt_21 % MARKED_DOWN_HILITE) or
- md_applied == (
- MARKED_DOWN_lt_21 % MARKED_DOWN_NOT_HILITE))
- assert gte_21_match or lt_21_match
-
-
-def test_dedent_tabs():
- result = 'first string\n\nsecond string'
- assert dedent(" first string\n\n second string") == result
- assert dedent("first string\n\n second string") == result
- assert dedent("\tfirst string\n\n\tsecond string") == result
- assert dedent("first string\n\n\tsecond string") == result
+import sys
+
+import pytest
+from django.test import TestCase
+
+from rest_framework.compat import apply_markdown
+from rest_framework.utils.formatting import dedent
+from rest_framework.views import APIView
+
+# We check that docstrings get nicely un-indented.
+DESCRIPTION = """an example docstring
+====================
+
+* list
+* list
+
+another header
+--------------
+
+ code block
+
+indented
+
+# hash style header #
+
+``` json
+[{
+ "alpha": 1,
+ "beta: "this is a string"
+}]
+```"""
+
+
+# If markdown is installed we also test it's working
+# (and that our wrapped forces '=' to h2 and '-' to h3)
+MARKDOWN_BASE = """<h2 id="an-example-docstring">an example docstring</h2>
+<ul>
+<li>list</li>
+<li>list</li>
+</ul>
+<h3 id="another-header">another header</h3>
+<pre><code>code block
+</code></pre>
+<p>indented</p>
+<h2 id="hash-style-header">hash style header</h2>%s"""
+
+MARKDOWN_gte_33 = """
+<div class="highlight"><pre><span></span><span class="p">[{</span><br />\
+ <span class="nt">"alpha"</span><span class="p">:</span>\
+ <span class="mi">1</span><span class="p">,</span><br />\
+ <span class="nt">"beta: "</span><span class="err">this\
+</span> <span class="err">is</span> <span class="err">a</span> \
+<span class="err">string"</span><br /><span class="p">}]</span>\
+<br /></pre></div>
+<p><br /></p>"""
+
+MARKDOWN_lt_33 = """
+<div class="highlight"><pre><span></span><span class="p">[{</span><br />\
+ <span class="nt">"alpha"</span><span class="p">:</span>\
+ <span class="mi">1</span><span class="p">,</span><br />\
+ <span class="nt">"beta: "</span><span class="err">this\
+</span> <span class="err">is</span> <span class="err">a</span>\
+ <span class="err">string"</span><br /><span class="p">}]</span>\
+<br /></pre></div>
+
+<p><br /></p>"""
+
+
+class TestViewNamesAndDescriptions(TestCase):
+ def test_view_name_uses_class_name(self):
+ """
+ Ensure view names are based on the class name.
+ """
+ class MockView(APIView):
+ pass
+ assert MockView().get_view_name() == 'Mock'
+
+ def test_view_name_uses_name_attribute(self):
+ class MockView(APIView):
+ name = 'Foo'
+ assert MockView().get_view_name() == 'Foo'
+
+ def test_view_name_uses_suffix_attribute(self):
+ class MockView(APIView):
+ suffix = 'List'
+ assert MockView().get_view_name() == 'Mock List'
+
+ def test_view_name_preferences_name_over_suffix(self):
+ class MockView(APIView):
+ name = 'Foo'
+ suffix = 'List'
+ assert MockView().get_view_name() == 'Foo'
+
+ def test_view_description_uses_docstring(self):
+ """Ensure view descriptions are based on the docstring."""
+ class MockView(APIView):
+ """an example docstring
+ ====================
+
+ * list
+ * list
+
+ another header
+ --------------
+
+ code block
+
+ indented
+
+ # hash style header #
+
+ ``` json
+ [{
+ "alpha": 1,
+ "beta: "this is a string"
+ }]
+ ```"""
+
+ assert MockView().get_view_description() == DESCRIPTION
+
+ def test_view_description_uses_description_attribute(self):
+ class MockView(APIView):
+ description = 'Foo'
+ assert MockView().get_view_description() == 'Foo'
+
+ def test_view_description_allows_empty_description(self):
+ class MockView(APIView):
+ """Description."""
+ description = ''
+ assert MockView().get_view_description() == ''
+
+ def test_view_description_can_be_empty(self):
+ """
+ Ensure that if a view has no docstring,
+ then it's description is the empty string.
+ """
+ class MockView(APIView):
+ pass
+ assert 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/encode/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:
+ def __init__(self, string):
+ self.s = string
+
+ def __str__(self):
+ return self.s
+
+ class MockView(APIView):
+ __doc__ = MockLazyStr("a gettext string")
+
+ assert MockView().get_view_description() == 'a gettext string'
+
+ @pytest.mark.skipif(not apply_markdown, reason="Markdown is not installed")
+ def test_markdown(self):
+ """
+ Ensure markdown to HTML works as expected.
+ """
+ # Markdown 3.3 is only supported on Python 3.6 and higher
+ if sys.version_info >= (3, 6):
+ assert apply_markdown(DESCRIPTION) == MARKDOWN_BASE % MARKDOWN_gte_33
+ else:
+ assert apply_markdown(DESCRIPTION) == MARKDOWN_BASE % MARKDOWN_lt_33
+
+
+def test_dedent_tabs():
+ result = 'first string\n\nsecond string'
+ assert dedent(" first string\n\n second string") == result
+ assert dedent("first string\n\n second string") == result
+ assert dedent("\tfirst string\n\n\tsecond string") == result
+ assert dedent("first string\n\n\tsecond string") == result
diff --git a/tests/test_fields.py b/tests/test_fields.py
index fdd570d8a6..5842553f02 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -1090,6 +1090,9 @@ class TestDecimalField(FieldValues):
'2E+1': Decimal('20'),
}
invalid_inputs = (
+ (None, ["This field may not be null."]),
+ ('', ["A valid number is required."]),
+ (' ', ["A valid number is required."]),
('abc', ["A valid number is required."]),
(Decimal('Nan'), ["A valid number is required."]),
(Decimal('Snan'), ["A valid number is required."]),
@@ -1115,6 +1118,32 @@ class TestDecimalField(FieldValues):
field = serializers.DecimalField(max_digits=3, decimal_places=1)
+class TestAllowNullDecimalField(FieldValues):
+ valid_inputs = {
+ None: None,
+ '': None,
+ ' ': None,
+ }
+ invalid_inputs = {}
+ outputs = {
+ None: '',
+ }
+ field = serializers.DecimalField(max_digits=3, decimal_places=1, allow_null=True)
+
+
+class TestAllowNullNoStringCoercionDecimalField(FieldValues):
+ valid_inputs = {
+ None: None,
+ '': None,
+ ' ': None,
+ }
+ invalid_inputs = {}
+ outputs = {
+ None: None,
+ }
+ field = serializers.DecimalField(max_digits=3, decimal_places=1, allow_null=True, coerce_to_string=False)
+
+
class TestMinMaxDecimalField(FieldValues):
"""
Valid and invalid values for `DecimalField` with min and max limits.
diff --git a/tests/test_filters.py b/tests/test_filters.py
index 567e5f83fc..37ae4c7cf3 100644
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -424,6 +424,10 @@ class OrderingFilterModel(models.Model):
title = models.CharField(max_length=20, verbose_name='verbose title')
text = models.CharField(max_length=100)
+ @property
+ def description(self):
+ return self.title + ": " + self.text
+
class OrderingFilterRelatedModel(models.Model):
related_object = models.ForeignKey(OrderingFilterModel, related_name="relateds", on_delete=models.CASCADE)
@@ -436,6 +440,17 @@ class Meta:
fields = '__all__'
+class OrderingFilterSerializerWithModelProperty(serializers.ModelSerializer):
+ class Meta:
+ model = OrderingFilterModel
+ fields = (
+ "id",
+ "title",
+ "text",
+ "description"
+ )
+
+
class OrderingDottedRelatedSerializer(serializers.ModelSerializer):
related_text = serializers.CharField(source='related_object.text')
related_title = serializers.CharField(source='related_object.title')
@@ -551,6 +566,42 @@ class OrderingListView(generics.ListAPIView):
{'id': 1, 'title': 'zyx', 'text': 'abc'},
]
+ def test_ordering_without_ordering_fields(self):
+ class OrderingListView(generics.ListAPIView):
+ queryset = OrderingFilterModel.objects.all()
+ serializer_class = OrderingFilterSerializerWithModelProperty
+ filter_backends = (filters.OrderingFilter,)
+ ordering = ('title',)
+
+ view = OrderingListView.as_view()
+
+ # Model field ordering works fine.
+ request = factory.get('/', {'ordering': 'text'})
+ response = view(request)
+ assert response.data == [
+ {'id': 1, 'title': 'zyx', 'text': 'abc', 'description': 'zyx: abc'},
+ {'id': 2, 'title': 'yxw', 'text': 'bcd', 'description': 'yxw: bcd'},
+ {'id': 3, 'title': 'xwv', 'text': 'cde', 'description': 'xwv: cde'},
+ ]
+
+ # `incorrectfield` ordering works fine.
+ request = factory.get('/', {'ordering': 'foobar'})
+ response = view(request)
+ assert response.data == [
+ {'id': 3, 'title': 'xwv', 'text': 'cde', 'description': 'xwv: cde'},
+ {'id': 2, 'title': 'yxw', 'text': 'bcd', 'description': 'yxw: bcd'},
+ {'id': 1, 'title': 'zyx', 'text': 'abc', 'description': 'zyx: abc'},
+ ]
+
+ # `description` is a Model property, which should be ignored.
+ request = factory.get('/', {'ordering': 'description'})
+ response = view(request)
+ assert response.data == [
+ {'id': 3, 'title': 'xwv', 'text': 'cde', 'description': 'xwv: cde'},
+ {'id': 2, 'title': 'yxw', 'text': 'bcd', 'description': 'yxw: bcd'},
+ {'id': 1, 'title': 'zyx', 'text': 'abc', 'description': 'zyx: abc'},
+ ]
+
def test_default_ordering(self):
class OrderingListView(generics.ListAPIView):
queryset = OrderingFilterModel.objects.all()
diff --git a/tests/test_relations.py b/tests/test_relations.py
index 92aeecf6c4..bb719a65a9 100644
--- a/tests/test_relations.py
+++ b/tests/test_relations.py
@@ -107,6 +107,12 @@ def test_pk_related_lookup_invalid_type(self):
msg = excinfo.value.detail[0]
assert msg == 'Incorrect type. Expected pk value, received BadType.'
+ def test_pk_related_lookup_bool(self):
+ with pytest.raises(serializers.ValidationError) as excinfo:
+ self.field.to_internal_value(True)
+ msg = excinfo.value.detail[0]
+ assert msg == 'Incorrect type. Expected pk value, received bool.'
+
def test_pk_representation(self):
representation = self.field.to_representation(self.instance)
assert representation == self.instance.pk
diff --git a/tests/test_validation_error.py b/tests/test_validation_error.py
index 562fe37e6b..341c4342a5 100644
--- a/tests/test_validation_error.py
+++ b/tests/test_validation_error.py
@@ -2,6 +2,7 @@
from rest_framework import serializers, status
from rest_framework.decorators import api_view
+from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.test import APIRequestFactory
@@ -99,3 +100,12 @@ def test_function_based_view_exception_handler(self):
response = view(request)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data == self.expected_response_data
+
+
+class TestValidationErrorConvertsTuplesToLists(TestCase):
+ def test_validation_error_details(self):
+ error = ValidationError(detail=('message1', 'message2'))
+ assert isinstance(error.detail, list)
+ assert len(error.detail) == 2
+ assert str(error.detail[0]) == 'message1'
+ assert str(error.detail[1]) == 'message2'
diff --git a/tox.ini b/tox.ini
index df6387d5e1..df16cf947f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,7 +3,8 @@ envlist =
{py35,py36,py37}-django22,
{py36,py37,py38}-django30,
{py36,py37,py38,py39}-django31,
- {py36,py37,py38,py39}-djangomaster,
+ {py36,py37,py38,py39}-django32,
+ {py38,py39}-djangomain,
base,dist,lint,docs,
[travis:env]
@@ -11,7 +12,8 @@ DJANGO =
2.2: django22
3.0: django30
3.1: django31
- master: djangomaster
+ 3.2: django32
+ main: djangomain
[testenv]
commands = python -W error::DeprecationWarning -W error::PendingDeprecationWarning runtests.py --fast --coverage {posargs}
@@ -23,7 +25,8 @@ deps =
django22: Django>=2.2,<3.0
django30: Django>=3.0,<3.1
django31: Django>=3.1,<3.2
- djangomaster: https://github.com/django/django/archive/master.tar.gz
+ django32: Django>=3.2a1,<4.0
+ djangomain: https://github.com/django/django/archive/main.tar.gz
-rrequirements/requirements-testing.txt
-rrequirements/requirements-optionals.txt