diff --git a/.travis.yml b/.travis.yml index 7266df2d5..f1ec689f7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,24 +1,16 @@ language: python cache: pip -dist: xenial +dist: bionic matrix: fast_finish: true include: - - { python: "3.5", env: DJANGO=1.11 } - - { python: "3.5", env: DJANGO=2.0 } - - { python: "3.5", env: DJANGO=2.1 } - { python: "3.5", env: DJANGO=2.2 } - - { python: "3.6", env: DJANGO=1.11 } - - { python: "3.6", env: DJANGO=2.0 } - - { python: "3.6", env: DJANGO=2.1 } - { python: "3.6", env: DJANGO=2.2 } - { python: "3.6", env: DJANGO=3.0 } - { python: "3.6", env: DJANGO=master } - - { python: "3.7", env: DJANGO=2.0 } - - { python: "3.7", env: DJANGO=2.1 } - { python: "3.7", env: DJANGO=2.2 } - { python: "3.7", env: DJANGO=3.0 } - { python: "3.7", env: DJANGO=master } diff --git a/README.md b/README.md index 9591bdc17..95020bb1d 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ There is a live example API for testing purposes, [available here][sandbox]. # Requirements * Python (3.5, 3.6, 3.7, 3.8) -* Django (1.11, 2.0, 2.1, 2.2, 3.0) +* Django (2.2, 3.0) We **highly recommend** and only officially support the latest patch release of each Python and Django series. @@ -89,7 +89,7 @@ Startup up a new project like so... Now edit the `example/urls.py` module in your project: ```python -from django.conf.urls import url, include +from django.urls import path, include from django.contrib.auth.models import User from rest_framework import serializers, viewsets, routers @@ -114,8 +114,8 @@ router.register(r'users', UserViewSet) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. urlpatterns = [ - url(r'^', include(router.urls)), - url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')) + path('', include(router.urls)), + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) ] ``` diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index c4dbe8856..ebb0ab4d6 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -304,7 +304,7 @@ If successfully authenticated, `RemoteUserAuthentication` provides the following Consult your web server's documentation for information about configuring an authentication method, e.g.: * [Apache Authentication How-To](https://httpd.apache.org/docs/2.4/howto/auth.html) -* [NGINX (Restricting Access)](https://www.nginx.com/resources/admin-guide/#restricting_access) +* [NGINX (Restricting Access)](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/) # Custom authentication @@ -410,9 +410,15 @@ HTTP Signature (currently a [IETF draft][http-signature-ietf-draft]) provides a [Djoser][djoser] library provides a set of views to handle basic actions such as registration, login, logout, password reset and account activation. The package works with a custom user model and it uses token based authentication. This is a ready to use REST implementation of Django authentication system. -## django-rest-auth +## django-rest-auth / dj-rest-auth -[Django-rest-auth][django-rest-auth] library provides a set of REST API endpoints for registration, authentication (including social media authentication), password reset, retrieve and update user details, etc. By having these API endpoints, your client apps such as AngularJS, iOS, Android, and others can communicate to your Django backend site independently via REST APIs for user management. +This library provides a set of REST API endpoints for registration, authentication (including social media authentication), password reset, retrieve and update user details, etc. By having these API endpoints, your client apps such as AngularJS, iOS, Android, and others can communicate to your Django backend site independently via REST APIs for user management. + + +There are currently two forks of this project. + +* [Django-rest-auth][django-rest-auth] is the original project, [but is not currently receiving updates](https://github.com/Tivix/django-rest-auth/issues/568). +* [Dj-rest-auth][dj-rest-auth] is a newer fork of the project. ## django-rest-framework-social-oauth2 @@ -456,6 +462,7 @@ HTTP Signature (currently a [IETF draft][http-signature-ietf-draft]) provides a [mac]: https://tools.ietf.org/html/draft-hammer-oauth-v2-mac-token-05 [djoser]: https://github.com/sunscrapers/djoser [django-rest-auth]: https://github.com/Tivix/django-rest-auth +[dj-rest-auth]: https://github.com/jazzband/dj-rest-auth [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 diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index bad57b441..8e3bd9ef5 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -205,6 +205,10 @@ This will allow the client to filter the items in the list by making queries suc You can also perform a related lookup on a ForeignKey or ManyToManyField with the lookup API double-underscore notation: search_fields = ['username', 'email', 'profile__profession'] + +For [JSONField][JSONField] and [HStoreField][HStoreField] fields you can filter based on nested values within the data structure using the same double-underscore notation: + + search_fields = ['data__breed', 'data__owner__other_pets__0__name'] By default, searches will use case-insensitive partial matches. The search parameter may contain multiple search terms, which should be whitespace and/or comma separated. If multiple search terms are used then objects will be returned in the list only if all the provided terms are matched. @@ -360,3 +364,5 @@ The [djangorestframework-word-filter][django-rest-framework-word-search-filter] [django-rest-framework-word-search-filter]: https://github.com/trollknurr/django-rest-framework-word-search-filter [django-url-filter]: https://github.com/miki725/django-url-filter [drf-url-filter]: https://github.com/manjitkumar/drf-url-filters +[HStoreField]: https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/fields/#hstorefield +[JSONField]: https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/fields/#jsonfield \ No newline at end of file diff --git a/docs/api-guide/metadata.md b/docs/api-guide/metadata.md index fdb778626..20708c6e3 100644 --- a/docs/api-guide/metadata.md +++ b/docs/api-guide/metadata.md @@ -71,7 +71,7 @@ If you have specific requirements for creating schema endpoints that are accesse For example, the following additional route could be used on a viewset to provide a linkable schema endpoint. @action(methods=['GET'], detail=False) - def schema(self, request): + def api_schema(self, request): meta = self.metadata_class() data = meta.determine_metadata(request, self) return Response(data) diff --git a/docs/api-guide/permissions.md b/docs/api-guide/permissions.md index 25baa4813..ac2924a83 100644 --- a/docs/api-guide/permissions.md +++ b/docs/api-guide/permissions.md @@ -231,7 +231,7 @@ If you need to test if a request is a read operation or a write operation, you s --- -Custom permissions will raise a `PermissionDenied` exception if the test fails. To change the error message associated with the exception, implement a `message` attribute directly on your custom permission. Otherwise the `default_detail` attribute from `PermissionDenied` will be used. +Custom permissions will raise a `PermissionDenied` exception if the test fails. To change the error message associated with the exception, implement a `message` attribute directly on your custom permission. Otherwise the `default_detail` attribute from `PermissionDenied` will be used. Similarly, to change the code identifier associated with the exception, implement a `code` attribute directly on your custom permission - otherwise the `default_code` attribute from `PermissionDenied` will be used. from rest_framework import permissions diff --git a/docs/api-guide/renderers.md b/docs/api-guide/renderers.md index a3321e860..a508a9ff9 100644 --- a/docs/api-guide/renderers.md +++ b/docs/api-guide/renderers.md @@ -273,7 +273,7 @@ By default this will include the following keys: `view`, `request`, `response`, The following is an example plaintext renderer that will return a response with the `data` parameter as the content of the response. - from django.utils.encoding import smart_unicode + from django.utils.encoding import smart_text from rest_framework import renderers @@ -282,7 +282,7 @@ The following is an example plaintext renderer that will return a response with format = 'txt' def render(self, data, media_type=None, renderer_context=None): - return data.encode(self.charset) + return smart_text(data, encoding=self.charset) ## Setting the character set diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index e33a2a611..3dc3f5628 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -18,19 +18,19 @@ Django REST Framework provides support for automatic generation of ## Generating an OpenAPI Schema -### Install `pyyaml` +### Install dependencies -You'll need to install `pyyaml`, so that you can render your generated schema -into the commonly used YAML-based OpenAPI format. + pip install pyyaml uritemplate - pip install pyyaml +* `pyyaml` is used to generate schema into YAML-based OpenAPI format. +* `uritemplate` is used internally to get parameters in path. ### Generating a static schema with the `generateschema` management command If your schema is static, you can use the `generateschema` management command: ```bash -./manage.py generateschema > openapi-schema.yml +./manage.py generateschema --file openapi-schema.yml ``` Once you've generated a schema in this way you can annotate it with any @@ -122,7 +122,7 @@ on a per-view basis. ### Schema Level Customization -In order to customize the top-level schema sublass +In order to customize the top-level schema subclass `rest_framework.schemas.openapi.SchemaGenerator` and provide it as an argument to the `generateschema` command or `get_schema_view()` helper function. @@ -215,6 +215,180 @@ This also applies to extra actions for `ViewSet`s: If you wish to provide a base `AutoSchema` subclass to be used throughout your project you may adjust `settings.DEFAULT_SCHEMA_CLASS` appropriately. + +### Grouping Operations With Tags + +Tags can be used to group logical operations. Each tag name in the list MUST be unique. + +--- +#### Django REST Framework generates tags automatically with the following logic: + +Tag name will be first element from the path. Also, any `_` in path name will be replaced by a `-`. +Consider below examples. + +Example 1: Consider a user management system. The following table will illustrate the tag generation logic. +Here first element from the paths is: `users`. Hence tag wil be `users` + +Http Method | Path | Tags +-------------------------------------|-------------------|------------- +PUT, PATCH, GET(Retrieve), DELETE | /users/{id}/ | ['users'] +POST, GET(List) | /users/ | ['users'] + +Example 2: Consider a restaurant management system. The System has restaurants. Each restaurant has branches. +Consider REST APIs to deal with a branch of a particular restaurant. +Here first element from the paths is: `restaurants`. Hence tag wil be `restaurants`. + +Http Method | Path | Tags +-------------------------------------|----------------------------------------------------|------------------- +PUT, PATCH, GET(Retrieve), DELETE: | /restaurants/{restaurant_id}/branches/{branch_id} | ['restaurants'] +POST, GET(List): | /restaurants/{restaurant_id}/branches/ | ['restaurants'] + +Example 3: Consider Order items for an e commerce company. + +Http Method | Path | Tags +-------------------------------------|-------------------------|------------- +PUT, PATCH, GET(Retrieve), DELETE | /order_items/{id}/ | ['order-items'] +POST, GET(List) | /order_items/ | ['order-items'] + + +--- +#### Overriding auto generated tags: +You can override auto-generated tags by passing `tags` argument to the constructor of `AutoSchema`. `tags` argument must be a list or tuple of string. +```python +from rest_framework.schemas.openapi import AutoSchema +from rest_framework.views import APIView + +class MyView(APIView): + schema = AutoSchema(tags=['tag1', 'tag2']) + ... +``` + +If you need more customization, you can override the `get_tags` method of `AutoSchema` class. Consider the following example: + +```python +from rest_framework.schemas.openapi import AutoSchema +from rest_framework.views import APIView + +class MySchema(AutoSchema): + ... + def get_tags(self, path, method): + if method == 'POST': + tags = ['tag1', 'tag2'] + elif method == 'GET': + tags = ['tag2', 'tag3'] + elif path == '/example/path/': + tags = ['tag3', 'tag4'] + else: + tags = ['tag5', 'tag6', 'tag7'] + + return tags + +class MyView(APIView): + schema = MySchema() + ... +``` + +### OperationId + +The schema generator generates an [operationid][openapi-operationid] for each operation. This `operationId` is deduced from the model name, serializer name or view name. The operationId may looks like "listItems", "retrieveItem", "updateItem", etc.. +The `operationId` is camelCase by convention. + +If you have several views with the same model, the generator may generate duplicate operationId. +In order to work around this, you can override the second part of the operationId: operation name. + +```python +from rest_framework.schemas.openapi import AutoSchema + +class ExampleView(APIView): + """APIView subclass with custom schema introspection.""" + schema = AutoSchema(operation_id_base="Custom") +``` + +The previous example will generate the following operationId: "listCustoms", "retrieveCustom", "updateCustom", "partialUpdateCustom", "destroyCustom". +You need to provide the singular form of he operation name. For the list operation, a "s" will be appended at the end of the operation. + +If you need more configuration over the `operationId` field, you can override the `get_operation_id_base` and `get_operation_id` methods from the `AutoSchema` class: + +```python +class CustomSchema(AutoSchema): + def get_operation_id_base(self, path, method, action): + pass + + def get_operation_id(self, path, method): + pass + +class MyView(APIView): + schema = AutoSchema(component_name="Ulysses") +``` + +### Components + +Since DRF 3.12, Schema uses the [OpenAPI Components][openapi-components]. This method defines components in the schema and [references them][openapi-reference] inside request and response objects. By default, the component's name is deduced from the Serializer's name. + +Using OpenAPI's components provides the following advantages: + +* The schema is more readable and lightweight. +* If you use the schema to generate an SDK (using [openapi-generator][openapi-generator] or [swagger-codegen][swagger-codegen]). The generator can name your SDK's models. + +### Handling component's schema errors + +You may get the following error while generating the schema: +``` +"Serializer" is an invalid class name for schema generation. +Serializer's class name should be unique and explicit. e.g. "ItemSerializer". +``` + +This error occurs when the Serializer name is "Serializer". You should choose a component's name unique across your schema and different than "Serializer". + +You may also get the following warning: +``` +Schema component "ComponentName" has been overriden with a different value. +``` + +This warning occurs when different components have the same name in one schema. Your component name should be unique across your project. This is likely an error that may lead to an invalid schema. + +You have two ways to solve the previous issues: + +* You can rename your serializer with a unique name and another name than "Serializer". +* You can set the `component_name` kwarg parameter of the AutoSchema constructor (see below). +* You can override the `get_component_name` method of the AutoSchema class (see below). + +#### Set a custom component's name for your view + +To override the component's name in your view, you can use the `component_name` parameter of the AutoSchema constructor: + +```python +from rest_framework.schemas.openapi import AutoSchema + +class MyView(APIView): + schema = AutoSchema(component_name="Ulysses") +``` + +#### Override the default implementation + +If you want to have more control and customization about how the schema's components are generated, you can override the `get_component_name` and `get_components` method from the AutoSchema class. + +```python +from rest_framework.schemas.openapi import AutoSchema + +class CustomSchema(AutoSchema): + def get_components(self, path, method): + # Implement your custom implementation + + def get_component_name(self, serializer): + # Implement your custom implementation + +class CustomView(APIView): + """APIView subclass with custom schema introspection.""" + schema = CustomSchema() +``` + [openapi]: https://github.com/OAI/OpenAPI-Specification [openapi-specification-extensions]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#specification-extensions [openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject +[openapi-tags]: https://swagger.io/specification/#tagObject +[openapi-operationid]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#fixed-fields-17 +[openapi-components]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#componentsObject +[openapi-reference]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#referenceObject +[openapi-generator]: https://github.com/OpenAPITools/openapi-generator +[swagger-codegen]: https://github.com/swagger-api/swagger-codegen diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index 4679b1ed1..87d3d4056 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -238,10 +238,12 @@ Serializer classes can also include reusable validators that are applied to the class Meta: # Each room only has one event per day. - validators = UniqueTogetherValidator( - queryset=Event.objects.all(), - fields=['room_number', 'date'] - ) + validators = [ + UniqueTogetherValidator( + queryset=Event.objects.all(), + fields=['room_number', 'date'] + ) + ] For more information see the [validators documentation](validators.md). @@ -333,7 +335,7 @@ Here's an example for an `.update()` method on our previous `UserSerializer` cla def update(self, instance, validated_data): profile_data = validated_data.pop('profile') # Unless the application properly enforces that this field is - # always set, the follow could raise a `DoesNotExist`, which + # always set, the following could raise a `DoesNotExist`, which # would need to be handled. profile = instance.profile @@ -382,8 +384,8 @@ This manager class now more nicely encapsulates that user instances and profile def create(self, validated_data): return User.objects.create( username=validated_data['username'], - email=validated_data['email'] - is_premium_member=validated_data['profile']['is_premium_member'] + email=validated_data['email'], + is_premium_member=validated_data['profile']['is_premium_member'], has_support_contract=validated_data['profile']['has_support_contract'] ) diff --git a/docs/api-guide/testing.md b/docs/api-guide/testing.md index dab0e264d..73de68a76 100644 --- a/docs/api-guide/testing.md +++ b/docs/api-guide/testing.md @@ -221,7 +221,7 @@ If you're using `RequestsClient` you'll want to ensure that test setup, and resu ## Headers & Authentication Custom headers and authentication credentials can be provided in the same way -as [when using a standard `requests.Session` instance](http://docs.python-requests.org/en/master/user/advanced/#session-objects). +as [when using a standard `requests.Session` instance][session_objects]. from requests.auth import HTTPBasicAuth @@ -414,3 +414,4 @@ For example, to add support for using `format='html'` in test requests, you migh [requestfactory]: https://docs.djangoproject.com/en/stable/topics/testing/advanced/#django.test.client.RequestFactory [configuration]: #configuration [refresh_from_db_docs]: https://docs.djangoproject.com/en/1.11/ref/models/instances/#django.db.models.Model.refresh_from_db +[session_objects]: https://requests.readthedocs.io/en/master/user/advanced/#session-objects diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index cd765d3e6..d7aa7ad3b 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -317,5 +317,5 @@ To create a base viewset class that provides `create`, `list` and `retrieve` ope By creating your own base `ViewSet` classes, you can provide common behavior that can be reused in multiple viewsets across your API. -[cite]: https://guides.rubyonrails.org/routing.html +[cite]: https://guides.rubyonrails.org/action_controller_overview.html [routers]: routers.md diff --git a/docs/community/release-notes.md b/docs/community/release-notes.md index 4be05d56b..0e634aa1e 100644 --- a/docs/community/release-notes.md +++ b/docs/community/release-notes.md @@ -34,10 +34,32 @@ You can determine your currently installed version using `pip show`: --- +## 3.11.x series + +### 3.11.0 + +**Date**: 12th December 2019 + +* Drop `.set_context` API [in favour of a `requires_context` marker](../3.11-announcement#validator-default-context). +* Changed default widget for TextField with choices to select box. [#6892][gh6892] +* Supported nested writes on non-relational fields, such as JSONField. [#6916][gh6916] +* Include request/response media types in OpenAPI schemas, based on configured parsers/renderers. [#6865][gh6865] +* Include operation descriptions in OpenAPI schemas, based on the docstring on the view. [#6898][gh6898] +* Fix representation of serializers with all optional fields in OpenAPI schemas. [#6941][gh6941], [#6944][gh6944] +* Fix representation of `serializers.HStoreField` in OpenAPI schemas. [#6914][gh6914] +* Fix OpenAPI generation when title or version is not provided. [#6912][gh6912] +* Use `int64` representation for large integers in OpenAPI schemas. [#7018][gh7018] +* Improved error messages if no `.to_representation` implementation is provided on a field subclass. [#6996][gh6996] +* Fix for serializer classes that use multiple inheritance. [#6980][gh6980] +* Fix for reversing Hyperlinked URL fields with percent encoded components in the path. [#7059][gh7059] +* Update bootstrap to 3.4.1. [#6923][gh6923] + ## 3.10.x series ### 3.10.3 +**Date**: 4th September 2019 + * Include API version in OpenAPI schema generation, defaulting to empty string. * Add pagination properties to OpenAPI response schemas. * Add missing "description" property to OpenAPI response schemas. @@ -47,9 +69,7 @@ You can determine your currently installed version using `pip show`: * Use consistent `lowerInitialCamelCase` style in OpenAPI operation IDs. * Fix `minLength`/`maxLength`/`minItems`/`maxItems` properties in OpenAPI schemas. * Only call `FileField.url` once in serialization, for improved performance. -* Fix an edge case where throttling calcualtions could error after a configuration change. - -* TODO +* Fix an edge case where throttling calculations could error after a configuration change. ### 3.10.2 @@ -154,7 +174,7 @@ Be sure to upgrade to Python 3 before upgrading to Django REST Framework 3.10. * Add testing of Python 3.7 support [#6141][gh6141] * Test using Django 2.1 final release. [#6109][gh6109] * Added djangorestframework-datatables to third-party packages [#5931][gh5931] -* Change ISO 8601 date format to exclude year/month [#5936][gh5936] +* Change ISO 8601 date format to exclude year/month-only options [#5936][gh5936] * Update all pypi.python.org URLs to pypi.org [#5942][gh5942] * Ensure that html forms (multipart form data) respect optional fields [#5927][gh5927] * Allow hashing of ErrorDetail. [#5932][gh5932] @@ -2175,3 +2195,18 @@ For older release notes, [please see the version 2.x documentation][old-release- [gh6680]: https://github.com/encode/django-rest-framework/issues/6680 [gh6317]: https://github.com/encode/django-rest-framework/issues/6317 + + +[gh6892]: https://github.com/encode/django-rest-framework/issues/6892 +[gh6916]: https://github.com/encode/django-rest-framework/issues/6916 +[gh6865]: https://github.com/encode/django-rest-framework/issues/6865 +[gh6898]: https://github.com/encode/django-rest-framework/issues/6898 +[gh6941]: https://github.com/encode/django-rest-framework/issues/6941 +[gh6944]: https://github.com/encode/django-rest-framework/issues/6944 +[gh6914]: https://github.com/encode/django-rest-framework/issues/6914 +[gh6912]: https://github.com/encode/django-rest-framework/issues/6912 +[gh7018]: https://github.com/encode/django-rest-framework/issues/7018 +[gh6996]: https://github.com/encode/django-rest-framework/issues/6996 +[gh6980]: https://github.com/encode/django-rest-framework/issues/6980 +[gh7059]: https://github.com/encode/django-rest-framework/issues/7059 +[gh6923]: https://github.com/encode/django-rest-framework/issues/6923 diff --git a/docs/community/third-party-packages.md b/docs/community/third-party-packages.md index baa30fd0c..2033d97ab 100644 --- a/docs/community/third-party-packages.md +++ b/docs/community/third-party-packages.md @@ -223,6 +223,7 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque ### 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. ### Routers @@ -272,6 +273,7 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque * [djangorestframework-mvt][djangorestframework-mvt] - An extension for creating views that serve Postgres data as Map Box Vector Tiles. * [drf-viewset-profiler][drf-viewset-profiler] - Lib to profile all methods from a viewset line by line. * [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. [cite]: http://www.software-ecosystems.com/Software_Ecosystems/Ecosystems.html [cookiecutter]: https://github.com/jpadilla/cookiecutter-django-rest-framework @@ -347,6 +349,7 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque [django-rest-witchcraft]: https://github.com/shosca/django-rest-witchcraft [drf-access-policy]: https://github.com/rsinger86/drf-access-policy [drf-flex-fields]: https://github.com/rsinger86/drf-flex-fields +[drf-typed-views]: https://github.com/rsinger86/drf-typed-views [drf-action-serializer]: https://github.com/gregschmit/drf-action-serializer [djangorestframework-dataclasses]: https://github.com/oxan/djangorestframework-dataclasses [django-restql]: https://github.com/yezyilomo/django-restql @@ -354,3 +357,4 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque [django-rest-framework-guardian]: https://github.com/rpkilby/django-rest-framework-guardian [drf-viewset-profiler]: https://github.com/fvlima/drf-viewset-profiler [djangorestframework-features]: https://github.com/cloudcode-hungary/django-rest-framework-features/ +[django-elasticsearch-dsl-drf]: https://github.com/barseghyanartur/django-elasticsearch-dsl-drf diff --git a/docs/community/tutorials-and-resources.md b/docs/community/tutorials-and-resources.md index 7993f54fb..6fdac6004 100644 --- a/docs/community/tutorials-and-resources.md +++ b/docs/community/tutorials-and-resources.md @@ -11,8 +11,8 @@ There are a wide range of resources available for learning and using Django REST - - + + diff --git a/docs/img/books/dfa-cover.jpg b/docs/img/books/dfa-cover.jpg new file mode 100644 index 000000000..09ed268f2 Binary files /dev/null and b/docs/img/books/dfa-cover.jpg differ diff --git a/docs/img/books/rad-cover.png b/docs/img/books/rad-cover.png deleted file mode 100644 index 75b19df64..000000000 Binary files a/docs/img/books/rad-cover.png and /dev/null differ diff --git a/docs/index.md b/docs/index.md index bccc1fb46..0e5f9dc7e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -86,14 +86,14 @@ continued development by **[signing up for a paid plan][funding]**. REST framework requires the following: * Python (3.5, 3.6, 3.7, 3.8) -* Django (1.11, 2.0, 2.1, 2.2, 3.0) +* Django (2.2, 3.0) We **highly recommend** and only officially support the latest patch release of each Python and Django series. The following packages are optional: -* [coreapi][coreapi] (1.32.0+) - Schema generation support. +* [PyYAML][pyyaml], [uritemplate][uriteemplate] (5.1+, 3.0.0+) - Schema generation support. * [Markdown][markdown] (3.0.0+) - Markdown support for the browsable API. * [Pygments][pygments] (2.4.0+) - Add syntax highlighting to Markdown processing. * [django-filter][django-filter] (1.0.1+) - Filtering support. @@ -148,7 +148,7 @@ Don't forget to make sure you've also added `rest_framework` to your `INSTALLED_ We're ready to create our API now. Here's our project's root `urls.py` module: - from django.conf.urls import url, include + from django.urls import path, include from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets @@ -170,8 +170,8 @@ Here's our project's root `urls.py` module: # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. urlpatterns = [ - url(r'^', include(router.urls)), - url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')) + path('', include(router.urls)), + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) ] You can now open the API in your browser at [http://127.0.0.1:8000/](http://127.0.0.1:8000/), and view your new 'users' API. If you use the login control in the top right corner you'll also be able to add, create and delete users from the system. @@ -237,7 +237,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [redhat]: https://www.redhat.com/ [heroku]: https://www.heroku.com/ [eventbrite]: https://www.eventbrite.co.uk/about/ -[coreapi]: https://pypi.org/project/coreapi/ +[pyyaml]: https://pypi.org/project/PyYAML/ +[uriteemplate]: https://pypi.org/project/uritemplate/ [markdown]: https://pypi.org/project/Markdown/ [pygments]: https://pypi.org/project/Pygments/ [django-filter]: https://pypi.org/project/django-filter/ diff --git a/docs/tutorial/quickstart.md b/docs/tutorial/quickstart.md index 505f7f91d..546144670 100644 --- a/docs/tutorial/quickstart.md +++ b/docs/tutorial/quickstart.md @@ -137,12 +137,12 @@ Finally, we're including default login and logout views for use with the browsab ## Pagination Pagination allows you to control how many objects per page are returned. To enable it add the following lines to `tutorial/settings.py` - + REST_FRAMEWORK = { 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 10 } - + ## Settings Add `'rest_framework'` to `INSTALLED_APPS`. The settings module will be in `tutorial/settings.py` @@ -224,5 +224,5 @@ If you want to get a more in depth understanding of how REST framework fits toge [image]: ../img/quickstart.png [tutorial]: 1-serialization.md -[guide]: ../#api-guide +[guide]: ../api-guide/requests.md [httpie]: https://github.com/jakubroztocil/httpie#installation diff --git a/docs_theme/404.html b/docs_theme/404.html index a89c0a418..bbb6b70ff 100644 --- a/docs_theme/404.html +++ b/docs_theme/404.html @@ -4,6 +4,6 @@

404

Page not found

-

Try the homepage, or search the documentation.

+

Try the homepage, or search the documentation.

{% endblock %} diff --git a/docs_theme/css/default.css b/docs_theme/css/default.css index e9d7f23bf..992bc60a4 100644 --- a/docs_theme/css/default.css +++ b/docs_theme/css/default.css @@ -6,7 +6,7 @@ pre { .dropdown .dropdown-menu { display: none; - overflow-y: scroll; + overflow-y: auto; } .dropdown.open .dropdown-menu { diff --git a/docs_theme/main.html b/docs_theme/main.html index 21e9171a2..b4e894781 100644 --- a/docs_theme/main.html +++ b/docs_theme/main.html @@ -5,22 +5,18 @@ {% if page.title %}{{ page.title }} - {% endif %}{{ config.site_name }} - + - - - - + + + + - - - - - - + + + + {% for path in config.extra_javascript %} diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 482deac66..d9efde1d0 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,6 +1,6 @@ # PEP8 code linting, which we run on all commits. -flake8==3.7.8 -flake8-tidy-imports==3.0.0 +flake8==3.7.9 +flake8-tidy-imports==4.1.0 pycodestyle==2.5.0 # Sort and lint imports diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 73158043e..e969ff471 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,2 +1,2 @@ # MkDocs to build our documentation. -mkdocs==1.0.4 +mkdocs==1.1 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 14957a531..2b7a18a13 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,8 +1,8 @@ # Optional packages which may be used with REST framework. -psycopg2-binary>=2.8.2, <2.9 +psycopg2-binary>=2.8.5, <2.9 markdown==3.1.1 pygments==2.4.2 -django-guardian==2.1.0 +django-guardian==2.2.0 django-filter>=2.2.0, <2.3 coreapi==2.3.1 coreschema==0.0.4 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 48de9e768..091622fbe 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1,8 +1,8 @@ # Wheel for PyPI installs. -wheel==0.30.0 +wheel==0.34.2 # Twine for secured PyPI uploads. -twine==1.11.0 +twine==3.1.1 # Transifex client for managing translation resources. -transifex-client==0.11 +transifex-client==0.13.9 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 83ec9ab9e..ad246e857 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,4 +1,4 @@ # Pytest for running the tests. -pytest>=5.0,<5.1 -pytest-django>=3.5.1,<3.6 +pytest>=5.4.1,<5.5 +pytest-django>=3.9.0,<3.10 pytest-cov>=2.7.1 diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index b6f3f65ce..8f2bc4466 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -25,9 +25,9 @@ ISO_8601 = 'iso-8601' default_app_config = 'rest_framework.apps.RestFrameworkConfig' -class RemovedInDRF312Warning(DeprecationWarning): +class RemovedInDRF313Warning(DeprecationWarning): pass -class RemovedInDRF313Warning(PendingDeprecationWarning): +class RemovedInDRF314Warning(PendingDeprecationWarning): pass diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 1e30728d3..a2ba53480 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -74,7 +74,11 @@ class BasicAuthentication(BaseAuthentication): raise exceptions.AuthenticationFailed(msg) try: - auth_parts = base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).partition(':') + try: + auth_decoded = base64.b64decode(auth[1]).decode('utf-8') + except UnicodeDecodeError: + auth_decoded = base64.b64decode(auth[1]).decode('latin-1') + auth_parts = auth_decoded.partition(':') except (TypeError, UnicodeDecodeError, binascii.Error): msg = _('Invalid basic header. Credentials not correctly base64 encoded.') raise exceptions.AuthenticationFailed(msg) diff --git a/rest_framework/authtoken/admin.py b/rest_framework/authtoken/admin.py index 1a507249b..f0cf646f4 100644 --- a/rest_framework/authtoken/admin.py +++ b/rest_framework/authtoken/admin.py @@ -3,10 +3,8 @@ from django.contrib import admin from rest_framework.authtoken.models import Token +@admin.register(Token) class TokenAdmin(admin.ModelAdmin): list_display = ('key', 'user', 'created') fields = ('user',) ordering = ('-created',) - - -admin.site.register(Token, TokenAdmin) diff --git a/rest_framework/authtoken/serializers.py b/rest_framework/authtoken/serializers.py index bb552f3e5..63e64d668 100644 --- a/rest_framework/authtoken/serializers.py +++ b/rest_framework/authtoken/serializers.py @@ -5,11 +5,19 @@ from rest_framework import serializers class AuthTokenSerializer(serializers.Serializer): - username = serializers.CharField(label=_("Username")) + username = serializers.CharField( + label=_("Username"), + write_only=True + ) password = serializers.CharField( label=_("Password"), style={'input_type': 'password'}, - trim_whitespace=False + trim_whitespace=False, + write_only=True + ) + token = serializers.CharField( + label=_("Token"), + read_only=True ) def validate(self, attrs): diff --git a/rest_framework/authtoken/views.py b/rest_framework/authtoken/views.py index a8c751d51..50f9acbd9 100644 --- a/rest_framework/authtoken/views.py +++ b/rest_framework/authtoken/views.py @@ -4,6 +4,7 @@ from rest_framework.authtoken.serializers import AuthTokenSerializer from rest_framework.compat import coreapi, coreschema from rest_framework.response import Response from rest_framework.schemas import ManualSchema +from rest_framework.schemas import coreapi as coreapi_schema from rest_framework.views import APIView @@ -13,7 +14,8 @@ class ObtainAuthToken(APIView): parser_classes = (parsers.FormParser, parsers.MultiPartParser, parsers.JSONParser,) renderer_classes = (renderers.JSONRenderer,) serializer_class = AuthTokenSerializer - if coreapi is not None and coreschema is not None: + + if coreapi_schema.is_enabled(): schema = ManualSchema( fields=[ coreapi.Field( @@ -38,9 +40,19 @@ class ObtainAuthToken(APIView): encoding="application/json", ) + def get_serializer_context(self): + return { + 'request': self.request, + 'format': self.format_kwarg, + 'view': self + } + + def get_serializer(self, *args, **kwargs): + kwargs['context'] = self.get_serializer_context() + return self.serializer_class(*args, **kwargs) + def post(self, request, *args, **kwargs): - serializer = self.serializer_class(data=request.data, - context={'request': request}) + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.validated_data['user'] token, created = Token.objects.get_or_create(user=user) diff --git a/rest_framework/checks.py b/rest_framework/checks.py index c1e626018..d5d77bc59 100644 --- a/rest_framework/checks.py +++ b/rest_framework/checks.py @@ -9,7 +9,7 @@ def pagination_system_check(app_configs, **kwargs): if api_settings.PAGE_SIZE and not api_settings.DEFAULT_PAGINATION_CLASS: errors.append( Warning( - "You have specified a default PAGE_SIZE pagination rest_framework setting," + "You have specified a default PAGE_SIZE pagination rest_framework setting, " "without specifying also a DEFAULT_PAGINATION_CLASS.", hint="The default for DEFAULT_PAGINATION_CLASS is None. " "In previous versions this was PageNumberPagination. " diff --git a/rest_framework/filters.py b/rest_framework/filters.py index c15723ec3..366577519 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -8,7 +8,6 @@ from functools import reduce from django.core.exceptions import ImproperlyConfigured from django.db import models from django.db.models.constants import LOOKUP_SEP -from django.db.models.sql.constants import ORDER_PATTERN from django.template import loader from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ @@ -86,7 +85,7 @@ class SearchFilter(BaseFilterBackend): search_field = search_field[1:] # Annotated fields do not need to be distinct if isinstance(queryset, models.QuerySet) and search_field in queryset.query.annotations: - return False + continue parts = search_field.split(LOOKUP_SEP) for part in parts: field = opts.get_field(part) @@ -97,6 +96,9 @@ class SearchFilter(BaseFilterBackend): if any(path.m2m for path in path_info): # This field is a m2m relation so we know we need to call distinct return True + else: + # This field has a custom __ query transform but is not a relational field. + break return False def filter_queryset(self, request, queryset, view): @@ -256,7 +258,13 @@ class OrderingFilter(BaseFilterBackend): def remove_invalid_fields(self, queryset, fields, view, request): valid_fields = [item[0] for item in self.get_valid_fields(queryset, view, {'request': request})] - return [term for term in fields if term.lstrip('-') in valid_fields and ORDER_PATTERN.match(term)] + + def term_valid(term): + if term.startswith("-"): + term = term[1:] + return term in valid_fields + + return [term for term in fields if term_valid(term)] def filter_queryset(self, request, queryset, view): ordering = self.get_ordering(request, queryset, view) diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 998d33dd1..8fbcaf7f1 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -108,7 +108,7 @@ class GenericAPIView(views.APIView): serializer_class = self.get_serializer_class() kwargs.setdefault('context', self.get_serializer_context()) - + return serializer_class(*args, **kwargs) def get_serializer_class(self): diff --git a/rest_framework/management/commands/generateschema.py b/rest_framework/management/commands/generateschema.py index a7763492c..024306b65 100644 --- a/rest_framework/management/commands/generateschema.py +++ b/rest_framework/management/commands/generateschema.py @@ -25,6 +25,7 @@ class Command(BaseCommand): parser.add_argument('--format', dest="format", choices=['openapi', 'openapi-json'], default='openapi', type=str) parser.add_argument('--urlconf', dest="urlconf", default=None, type=str) parser.add_argument('--generator_class', dest="generator_class", default=None, type=str) + parser.add_argument('--file', dest="file", default=None, type=str) def handle(self, *args, **options): if options['generator_class']: @@ -40,7 +41,12 @@ class Command(BaseCommand): schema = generator.get_schema(request=None, public=True) renderer = self.get_renderer(options['format']) output = renderer.render(schema, renderer_context={}) - self.stdout.write(output.decode()) + + if options['file']: + with open(options['file'], 'wb') as f: + f.write(output) + else: + self.stdout.write(output.decode()) def get_renderer(self, format): if self.get_mode() == COREAPI_MODE: diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 1a1ba2f65..a73f2e526 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -236,10 +236,16 @@ class PageNumberPagination(BasePagination): 'next': { 'type': 'string', 'nullable': True, + 'format': 'uri', + 'example': 'http://api.example.org/accounts/?{page_query_param}=4'.format( + page_query_param=self.page_query_param) }, 'previous': { 'type': 'string', 'nullable': True, + 'format': 'uri', + 'example': 'http://api.example.org/accounts/?{page_query_param}=2'.format( + page_query_param=self.page_query_param) }, 'results': schema, }, @@ -403,10 +409,16 @@ class LimitOffsetPagination(BasePagination): 'next': { 'type': 'string', 'nullable': True, + 'format': 'uri', + 'example': 'http://api.example.org/accounts/?{offset_param}=400&{limit_param}=100'.format( + offset_param=self.offset_query_param, limit_param=self.limit_query_param), }, 'previous': { 'type': 'string', 'nullable': True, + 'format': 'uri', + 'example': 'http://api.example.org/accounts/?{offset_param}=200&{limit_param}=100'.format( + offset_param=self.offset_query_param, limit_param=self.limit_query_param), }, 'results': schema, }, diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 29ac90ea8..a96fa6e65 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -1053,7 +1053,11 @@ class OpenAPIRenderer(BaseRenderer): assert yaml, 'Using OpenAPIRenderer, but `pyyaml` is not installed.' def render(self, data, media_type=None, renderer_context=None): - return yaml.dump(data, default_flow_style=False, sort_keys=False).encode('utf-8') + # disable yaml advanced feature 'alias' for clean, portable, and readable output + class Dumper(yaml.Dumper): + def ignore_aliases(self, data): + return True + return yaml.dump(data, default_flow_style=False, sort_keys=False, Dumper=Dumper).encode('utf-8') class JSONOpenAPIRenderer(BaseRenderer): diff --git a/rest_framework/request.py b/rest_framework/request.py index ec4b749c2..43cdf5e9b 100644 --- a/rest_framework/request.py +++ b/rest_framework/request.py @@ -179,6 +179,13 @@ class Request: forced_auth = ForcedAuthentication(force_user, force_token) self.authenticators = (forced_auth,) + def __repr__(self): + return '<%s.%s: %s %r>' % ( + self.__class__.__module__, + self.__class__.__name__, + self.method, + self.get_full_path()) + def _default_negotiator(self): return api_settings.DEFAULT_CONTENT_NEGOTIATION_CLASS() diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 351174aac..9b3082822 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -1,4 +1,7 @@ +import re import warnings +from collections import OrderedDict +from decimal import Decimal from operator import attrgetter from urllib.parse import urljoin @@ -9,9 +12,12 @@ from django.core.validators import ( from django.db import models from django.utils.encoding import force_str -from rest_framework import exceptions, renderers, serializers +from rest_framework import ( + RemovedInDRF314Warning, exceptions, renderers, serializers +) from rest_framework.compat import uritemplate from rest_framework.fields import _UnvalidatedField, empty +from rest_framework.settings import api_settings from .generators import BaseSchemaGenerator from .inspectors import ViewInspector @@ -32,14 +38,40 @@ class SchemaGenerator(BaseSchemaGenerator): return info + def check_duplicate_operation_id(self, paths): + ids = {} + for route in paths: + for method in paths[route]: + if 'operationId' not in paths[route][method]: + continue + operation_id = paths[route][method]['operationId'] + if operation_id in ids: + warnings.warn( + 'You have a duplicated operationId in your OpenAPI schema: {operation_id}\n' + '\tRoute: {route1}, Method: {method1}\n' + '\tRoute: {route2}, Method: {method2}\n' + '\tAn operationId has to be unique accros your schema. Your schema may not work in other tools.' + .format( + route1=ids[operation_id]['route'], + method1=ids[operation_id]['method'], + route2=route, + method2=method, + operation_id=operation_id + ) + ) + ids[operation_id] = { + 'route': route, + 'method': method + } + def get_schema(self, request=None, public=False): """ Generate a OpenAPI schema. """ self._initialise_endpoints() + components_schemas = {} # Iterate endpoints generating per method path operations. - # TODO: …and reference components. paths = {} _, view_endpoints = self._get_paths_and_endpoints(None if public else request) for path, method, view in view_endpoints: @@ -47,6 +79,16 @@ class SchemaGenerator(BaseSchemaGenerator): continue operation = view.schema.get_operation(path, method) + components = view.schema.get_components(path, method) + for k in components.keys(): + if k not in components_schemas: + continue + if components_schemas[k] == components[k]: + continue + warnings.warn('Schema component "{}" has been overriden with a different value.'.format(k)) + + components_schemas.update(components) + # Normalise path for any provided mount url. if path.startswith('/'): path = path[1:] @@ -55,6 +97,8 @@ class SchemaGenerator(BaseSchemaGenerator): paths.setdefault(path, {}) paths[path][method.lower()] = operation + self.check_duplicate_operation_id(paths) + # Compile final schema. schema = { 'openapi': '3.0.2', @@ -62,6 +106,11 @@ class SchemaGenerator(BaseSchemaGenerator): 'paths': paths, } + if len(components_schemas) > 0: + schema['components'] = { + 'schemas': components_schemas + } + return schema # View Inspectors @@ -69,56 +118,112 @@ class SchemaGenerator(BaseSchemaGenerator): class AutoSchema(ViewInspector): + def __init__(self, tags=None, operation_id_base=None, component_name=None): + """ + :param operation_id_base: user-defined name in operationId. If empty, it will be deducted from the Model/Serializer/View name. + :param component_name: user-defined component's name. If empty, it will be deducted from the Serializer's class name. + """ + if tags and not all(isinstance(tag, str) for tag in tags): + raise ValueError('tags must be a list or tuple of string.') + self._tags = tags + self.operation_id_base = operation_id_base + self.component_name = component_name + super().__init__() + request_media_types = [] response_media_types = [] method_mapping = { - 'get': 'Retrieve', - 'post': 'Create', - 'put': 'Update', - 'patch': 'PartialUpdate', - 'delete': 'Destroy', + 'get': 'retrieve', + 'post': 'create', + 'put': 'update', + 'patch': 'partialUpdate', + 'delete': 'destroy', } def get_operation(self, path, method): operation = {} - operation['operationId'] = self._get_operation_id(path, method) + operation['operationId'] = self.get_operation_id(path, method) operation['description'] = self.get_description(path, method) parameters = [] - parameters += self._get_path_parameters(path, method) - parameters += self._get_pagination_parameters(path, method) - parameters += self._get_filter_parameters(path, method) + parameters += self.get_path_parameters(path, method) + parameters += self.get_pagination_parameters(path, method) + parameters += self.get_filter_parameters(path, method) operation['parameters'] = parameters - request_body = self._get_request_body(path, method) + request_body = self.get_request_body(path, method) if request_body: operation['requestBody'] = request_body - operation['responses'] = self._get_responses(path, method) + operation['responses'] = self.get_responses(path, method) + operation['tags'] = self.get_tags(path, method) return operation - def _get_operation_id(self, path, method): + def get_component_name(self, serializer): """ - Compute an operation ID from the model, serializer or view name. + Compute the component's name from the serializer. + Raise an exception if the serializer's class name is "Serializer" (case-insensitive). """ - method_name = getattr(self.view, 'action', method.lower()) - if is_list_view(path, method, self.view): - action = 'list' - elif method_name not in self.method_mapping: - action = method_name - else: - action = self.method_mapping[method.lower()] + if self.component_name is not None: + return self.component_name + + # use the serializer's class name as the component name. + component_name = serializer.__class__.__name__ + # We remove the "serializer" string from the class name. + pattern = re.compile("serializer", re.IGNORECASE) + component_name = pattern.sub("", component_name) + + if component_name == "": + raise Exception( + '"{}" is an invalid class name for schema generation. ' + 'Serializer\'s class name should be unique and explicit. e.g. "ItemSerializer"' + .format(serializer.__class__.__name__) + ) + + return component_name + + def get_components(self, path, method): + """ + Return components with their properties from the serializer. + """ + + if method.lower() == 'delete': + return {} + + serializer = self.get_serializer(path, method) + + if not isinstance(serializer, serializers.Serializer): + return {} + + component_name = self.get_component_name(serializer) + + content = self.map_serializer(serializer) + return {component_name: content} + + def _to_camel_case(self, snake_str): + components = snake_str.split('_') + # We capitalize the first letter of each component except the first one + # with the 'title' method and join them together. + return components[0] + ''.join(x.title() for x in components[1:]) + + def get_operation_id_base(self, path, method, action): + """ + Compute the base part for operation ID from the model, serializer or view name. + """ + model = getattr(getattr(self.view, 'queryset', None), 'model', None) + + if self.operation_id_base is not None: + name = self.operation_id_base # Try to deduce the ID from the view's model - model = getattr(getattr(self.view, 'queryset', None), 'model', None) - if model is not None: + elif model is not None: name = model.__name__ # Try with the serializer class name - elif self._get_serializer(path, method) is not None: - name = self._get_serializer(path, method).__class__.__name__ + elif self.get_serializer(path, method) is not None: + name = self.get_serializer(path, method).__class__.__name__ if name.endswith('Serializer'): name = name[:-10] @@ -138,9 +243,25 @@ class AutoSchema(ViewInspector): if action == 'list' and not name.endswith('s'): # listThings instead of listThing name += 's' + return name + + def get_operation_id(self, path, method): + """ + Compute an operation ID from the view type and get_operation_id_base method. + """ + method_name = getattr(self.view, 'action', method.lower()) + if is_list_view(path, method, self.view): + action = 'list' + elif method_name not in self.method_mapping: + action = self._to_camel_case(method_name) + else: + action = self.method_mapping[method.lower()] + + name = self.get_operation_id_base(path, method, action) + return action + name - def _get_path_parameters(self, path, method): + def get_path_parameters(self, path, method): """ Return a list of parameters from templated path variables. """ @@ -176,15 +297,15 @@ class AutoSchema(ViewInspector): return parameters - def _get_filter_parameters(self, path, method): - if not self._allows_filters(path, method): + def get_filter_parameters(self, path, method): + if not self.allows_filters(path, method): return [] parameters = [] for filter_backend in self.view.filter_backends: parameters += filter_backend().get_schema_operation_parameters(self.view) return parameters - def _allows_filters(self, path, method): + def allows_filters(self, path, method): """ Determine whether to include filter Fields in schema. @@ -197,28 +318,56 @@ class AutoSchema(ViewInspector): return self.view.action in ["list", "retrieve", "update", "partial_update", "destroy"] return method.lower() in ["get", "put", "patch", "delete"] - def _get_pagination_parameters(self, path, method): + def get_pagination_parameters(self, path, method): view = self.view if not is_list_view(path, method, view): return [] - paginator = self._get_paginator() + paginator = self.get_paginator() if not paginator: return [] return paginator.get_schema_operation_parameters(view) - def _map_field(self, field): + def map_choicefield(self, field): + choices = list(OrderedDict.fromkeys(field.choices)) # preserve order and remove duplicates + if all(isinstance(choice, bool) for choice in choices): + type = 'boolean' + elif all(isinstance(choice, int) for choice in choices): + type = 'integer' + elif all(isinstance(choice, (int, float, Decimal)) for choice in choices): # `number` includes `integer` + # Ref: https://tools.ietf.org/html/draft-wright-json-schema-validation-00#section-5.21 + type = 'number' + elif all(isinstance(choice, str) for choice in choices): + type = 'string' + else: + type = None + + mapping = { + # The value of `enum` keyword MUST be an array and SHOULD be unique. + # Ref: https://tools.ietf.org/html/draft-wright-json-schema-validation-00#section-5.20 + 'enum': choices + } + + # If We figured out `type` then and only then we should set it. It must be a string. + # Ref: https://swagger.io/docs/specification/data-models/data-types/#mixed-type + # It is optional but it can not be null. + # Ref: https://tools.ietf.org/html/draft-wright-json-schema-validation-00#section-5.21 + if type: + mapping['type'] = type + return mapping + + def map_field(self, field): # Nested Serializers, `many` or not. if isinstance(field, serializers.ListSerializer): return { 'type': 'array', - 'items': self._map_serializer(field.child) + 'items': self.map_serializer(field.child) } if isinstance(field, serializers.Serializer): - data = self._map_serializer(field) + data = self.map_serializer(field) data['type'] = 'object' return data @@ -226,7 +375,7 @@ class AutoSchema(ViewInspector): if isinstance(field, serializers.ManyRelatedField): return { 'type': 'array', - 'items': self._map_field(field.child_relation) + 'items': self.map_field(field.child_relation) } if isinstance(field, serializers.PrimaryKeyRelatedField): model = getattr(field.queryset, 'model', None) @@ -242,15 +391,11 @@ class AutoSchema(ViewInspector): if isinstance(field, serializers.MultipleChoiceField): return { 'type': 'array', - 'items': { - 'enum': list(field.choices) - }, + 'items': self.map_choicefield(field) } if isinstance(field, serializers.ChoiceField): - return { - 'enum': list(field.choices), - } + return self.map_choicefield(field) # ListField. if isinstance(field, serializers.ListField): @@ -259,7 +404,7 @@ class AutoSchema(ViewInspector): 'items': {}, } if not isinstance(field.child, _UnvalidatedField): - mapping['items'] = self._map_field(field.child) + mapping['items'] = self.map_field(field.child) return mapping # DateField and DateTimeField type is string @@ -304,11 +449,17 @@ class AutoSchema(ViewInspector): content['format'] = field.protocol return content - # DecimalField has multipleOf based on decimal_places if isinstance(field, serializers.DecimalField): - content = { - 'type': 'number' - } + if getattr(field, 'coerce_to_string', api_settings.COERCE_DECIMAL_TO_STRING): + content = { + 'type': 'string', + 'format': 'decimal', + } + else: + content = { + 'type': 'number' + } + if field.decimal_places: content['multipleOf'] = float('.' + (field.decimal_places - 1) * '0' + '1') if field.max_whole_digits: @@ -319,7 +470,7 @@ class AutoSchema(ViewInspector): if isinstance(field, serializers.FloatField): content = { - 'type': 'number' + 'type': 'number', } self._map_min_max(field, content) return content @@ -355,12 +506,8 @@ class AutoSchema(ViewInspector): if field.min_value: content['minimum'] = field.min_value - def _map_serializer(self, serializer): + def map_serializer(self, serializer): # Assuming we have a valid serializer instance. - # TODO: - # - field is Nested or List serializer. - # - Handle read_only/write_only for request/response differences. - # - could do this with readOnly/writeOnly and then filter dict. required = [] properties = {} @@ -371,22 +518,23 @@ class AutoSchema(ViewInspector): if field.required: required.append(field.field_name) - schema = self._map_field(field) + schema = self.map_field(field) if field.read_only: schema['readOnly'] = True if field.write_only: schema['writeOnly'] = True if field.allow_null: schema['nullable'] = True - if field.default and field.default != empty and not callable(field.default): + if field.default is not None and field.default != empty and not callable(field.default): schema['default'] = field.default if field.help_text: schema['description'] = str(field.help_text) - self._map_field_validators(field, schema) + self.map_field_validators(field, schema) properties[field.field_name] = schema result = { + 'type': 'object', 'properties': properties } if required: @@ -394,7 +542,7 @@ class AutoSchema(ViewInspector): return result - def _map_field_validators(self, field, schema): + def map_field_validators(self, field, schema): """ map field validators """ @@ -421,7 +569,8 @@ class AutoSchema(ViewInspector): schema['maximum'] = v.limit_value elif isinstance(v, MinValueValidator): schema['minimum'] = v.limit_value - elif isinstance(v, DecimalValidator): + elif isinstance(v, DecimalValidator) and \ + not getattr(field, 'coerce_to_string', api_settings.COERCE_DECIMAL_TO_STRING): if v.decimal_places: schema['multipleOf'] = float('.' + (v.decimal_places - 1) * '0' + '1') if v.max_digits: @@ -431,7 +580,7 @@ class AutoSchema(ViewInspector): schema['maximum'] = int(digits * '9') + 1 schema['minimum'] = -schema['maximum'] - def _get_paginator(self): + def get_paginator(self): pagination_class = getattr(self.view, 'pagination_class', None) if pagination_class: return pagination_class() @@ -449,7 +598,7 @@ class AutoSchema(ViewInspector): media_types.append(renderer.media_type) return media_types - def _get_serializer(self, path, method): + def get_serializer(self, path, method): view = self.view if not hasattr(view, 'get_serializer'): @@ -464,35 +613,30 @@ class AutoSchema(ViewInspector): .format(view.__class__.__name__, method, path)) return None - def _get_request_body(self, path, method): + def _get_reference(self, serializer): + return {'$ref': '#/components/schemas/{}'.format(self.get_component_name(serializer))} + + def get_request_body(self, path, method): if method not in ('PUT', 'PATCH', 'POST'): return {} self.request_media_types = self.map_parsers(path, method) - serializer = self._get_serializer(path, method) + serializer = self.get_serializer(path, method) if not isinstance(serializer, serializers.Serializer): - return {} - - content = self._map_serializer(serializer) - # No required fields for PATCH - if method == 'PATCH': - content.pop('required', None) - # No read_only fields for request. - for name, schema in content['properties'].copy().items(): - if 'readOnly' in schema: - del content['properties'][name] + item_schema = {} + else: + item_schema = self._get_reference(serializer) return { 'content': { - ct: {'schema': content} + ct: {'schema': item_schema} for ct in self.request_media_types } } - def _get_responses(self, path, method): - # TODO: Handle multiple codes and pagination classes. + def get_responses(self, path, method): if method == 'DELETE': return { '204': { @@ -502,31 +646,26 @@ class AutoSchema(ViewInspector): self.response_media_types = self.map_renderers(path, method) - item_schema = {} - serializer = self._get_serializer(path, method) + serializer = self.get_serializer(path, method) - if isinstance(serializer, serializers.Serializer): - item_schema = self._map_serializer(serializer) - # No write_only fields for response. - for name, schema in item_schema['properties'].copy().items(): - if 'writeOnly' in schema: - del item_schema['properties'][name] - if 'required' in item_schema: - item_schema['required'] = [f for f in item_schema['required'] if f != name] + if not isinstance(serializer, serializers.Serializer): + item_schema = {} + else: + item_schema = self._get_reference(serializer) if is_list_view(path, method, self.view): response_schema = { 'type': 'array', 'items': item_schema, } - paginator = self._get_paginator() + paginator = self.get_paginator() if paginator: response_schema = paginator.get_paginated_response_schema(response_schema) else: response_schema = item_schema - + status_code = '201' if method == 'POST' else '200' return { - '200': { + status_code: { 'content': { ct: {'schema': response_schema} for ct in self.response_media_types @@ -537,3 +676,112 @@ class AutoSchema(ViewInspector): 'description': "" } } + + def get_tags(self, path, method): + # If user have specified tags, use them. + if self._tags: + return self._tags + + # First element of a specific path could be valid tag. This is a fallback solution. + # PUT, PATCH, GET(Retrieve), DELETE: /user_profile/{id}/ tags = [user-profile] + # POST, GET(List): /user_profile/ tags = [user-profile] + if path.startswith('/'): + path = path[1:] + + return [path.split('/')[0].replace('_', '-')] + + def _get_path_parameters(self, path, method): + warnings.warn( + "Method `_get_path_parameters()` has been renamed to `get_path_parameters()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_path_parameters(path, method) + + def _get_filter_parameters(self, path, method): + warnings.warn( + "Method `_get_filter_parameters()` has been renamed to `get_filter_parameters()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_filter_parameters(path, method) + + def _get_responses(self, path, method): + warnings.warn( + "Method `_get_responses()` has been renamed to `get_responses()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_responses(path, method) + + def _get_request_body(self, path, method): + warnings.warn( + "Method `_get_request_body()` has been renamed to `get_request_body()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_request_body(path, method) + + def _get_serializer(self, path, method): + warnings.warn( + "Method `_get_serializer()` has been renamed to `get_serializer()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_serializer(path, method) + + def _get_paginator(self): + warnings.warn( + "Method `_get_paginator()` has been renamed to `get_paginator()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_paginator() + + def _map_field_validators(self, field, schema): + warnings.warn( + "Method `_map_field_validators()` has been renamed to `map_field_validators()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.map_field_validators(field, schema) + + def _map_serializer(self, serializer): + warnings.warn( + "Method `_map_serializer()` has been renamed to `map_serializer()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.map_serializer(serializer) + + def _map_field(self, field): + warnings.warn( + "Method `_map_field()` has been renamed to `map_field()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.map_field(field) + + def _map_choicefield(self, field): + warnings.warn( + "Method `_map_choicefield()` has been renamed to `map_choicefield()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.map_choicefield(field) + + def _get_pagination_parameters(self, path, method): + warnings.warn( + "Method `_get_pagination_parameters()` has been renamed to `get_pagination_parameters()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_pagination_parameters(path, method) + + def _allows_filters(self, path, method): + warnings.warn( + "Method `_allows_filters()` has been renamed to `allows_filters()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.allows_filters(path, method) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 8c2486bea..c1cea1e83 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -13,7 +13,7 @@ response content is handled by parsers and renderers. import copy import inspect import traceback -from collections import OrderedDict +from collections import OrderedDict, defaultdict from collections.abc import Mapping from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured @@ -1508,28 +1508,55 @@ class ModelSerializer(Serializer): # which may map onto a model field. Any dotted field name lookups # cannot map to a field, and must be a traversal, so we're not # including those. - field_names = { - field.source for field in self._writable_fields + field_sources = OrderedDict( + (field.field_name, field.source) for field in self._writable_fields if (field.source != '*') and ('.' not in field.source) - } + ) # Special Case: Add read_only fields with defaults. - field_names |= { - field.source for field in self.fields.values() + field_sources.update(OrderedDict( + (field.field_name, field.source) for field in self.fields.values() if (field.read_only) and (field.default != empty) and (field.source != '*') and ('.' not in field.source) - } + )) + + # Invert so we can find the serializer field names that correspond to + # the model field names in the unique_together sets. This also allows + # us to check that multiple fields don't map to the same source. + source_map = defaultdict(list) + for name, source in field_sources.items(): + source_map[source].append(name) # Note that we make sure to check `unique_together` both on the # base model class, but also on any parent classes. validators = [] for parent_class in model_class_inheritance_tree: for unique_together in parent_class._meta.unique_together: - if field_names.issuperset(set(unique_together)): - validator = UniqueTogetherValidator( - queryset=parent_class._default_manager, - fields=unique_together + # Skip if serializer does not map to all unique together sources + if not set(source_map).issuperset(set(unique_together)): + continue + + for source in unique_together: + assert len(source_map[source]) == 1, ( + "Unable to create `UniqueTogetherValidator` for " + "`{model}.{field}` as `{serializer}` has multiple " + "fields ({fields}) that map to this model field. " + "Either remove the extra fields, or override " + "`Meta.validators` with a `UniqueTogetherValidator` " + "using the desired field names." + .format( + model=self.Meta.model.__name__, + serializer=self.__class__.__name__, + field=source, + fields=', '.join(source_map[source]), + ) ) - validators.append(validator) + + field_names = tuple(source_map[f][0] for f in unique_together) + validator = UniqueTogetherValidator( + queryset=parent_class._default_manager, + fields=field_names + ) + validators.append(validator) return validators def get_unique_for_date_validators(self): diff --git a/rest_framework/static/rest_framework/js/jquery-3.4.1.min.js b/rest_framework/static/rest_framework/js/jquery-3.4.1.min.js deleted file mode 100644 index a1c07fd80..000000000 --- a/rest_framework/static/rest_framework/js/jquery-3.4.1.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! jQuery v3.4.1 | (c) JS Foundation and other contributors | jquery.org/license */ -!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],E=C.document,r=Object.getPrototypeOf,s=t.slice,g=t.concat,u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.4.1",k=function(e,t){return new k.fn.init(e,t)},p=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;function d(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp($),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+$),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ne=function(e,t,n){var r="0x"+t-65536;return r!=r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(m.childNodes),m.childNodes),t[m.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&((e?e.ownerDocument||e:m)!==C&&T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!A[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&U.test(t)){(s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=k),o=(l=h(t)).length;while(o--)l[o]="#"+s+" "+xe(l[o]);c=l.join(","),f=ee.test(t)&&ye(e.parentNode)||e}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){A(t,!0)}finally{s===k&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[k]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:m;return r!==C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),m!==C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=k,!C.getElementsByName||!C.getElementsByName(k).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+k+"-]").length||v.push("~="),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+k+"+*").length||v.push(".#.+[+~]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",$)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e===C||e.ownerDocument===m&&y(m,e)?-1:t===C||t.ownerDocument===m&&y(m,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===C?-1:t===C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]===m?-1:s[r]===m?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if((e.ownerDocument||e)!==C&&T(e),d.matchesSelector&&E&&!A[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){A(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=p[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&p(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?k.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?k.grep(e,function(e){return e===n!==r}):"string"!=typeof n?k.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(k.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:L.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof k?t[0]:t,k.merge(this,k.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),D.test(r[1])&&k.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(k):k.makeArray(e,this)}).prototype=k.fn,q=k(E);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}k.fn.extend({has:function(e){var t=k(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?k.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;nx",y.noCloneChecked=!!me.cloneNode(!0).lastChild.defaultValue;var Te=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ee=/^([^.]*)(?:\.(.+)|)/;function ke(){return!0}function Se(){return!1}function Ne(e,t){return e===function(){try{return E.activeElement}catch(e){}}()==("focus"===t)}function Ae(e,t,n,r,i,o){var a,s;if("object"==typeof t){for(s in"string"!=typeof n&&(r=r||n,n=void 0),t)Ae(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=Se;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return k().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=k.guid++)),e.each(function(){k.event.add(this,t,i,r,n)})}function De(e,i,o){o?(Q.set(e,i,!1),k.event.add(e,i,{namespace:!1,handler:function(e){var t,n,r=Q.get(this,i);if(1&e.isTrigger&&this[i]){if(r.length)(k.event.special[i]||{}).delegateType&&e.stopPropagation();else if(r=s.call(arguments),Q.set(this,i,r),t=o(this,i),this[i](),r!==(n=Q.get(this,i))||t?Q.set(this,i,!1):n={},r!==n)return e.stopImmediatePropagation(),e.preventDefault(),n.value}else r.length&&(Q.set(this,i,{value:k.event.trigger(k.extend(r[0],k.Event.prototype),r.slice(1),this)}),e.stopImmediatePropagation())}})):void 0===Q.get(e,i)&&k.event.add(e,i,ke)}k.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.get(t);if(v){n.handler&&(n=(o=n).handler,i=o.selector),i&&k.find.matchesSelector(ie,i),n.guid||(n.guid=k.guid++),(u=v.events)||(u=v.events={}),(a=v.handle)||(a=v.handle=function(e){return"undefined"!=typeof k&&k.event.triggered!==e.type?k.event.dispatch.apply(t,arguments):void 0}),l=(e=(e||"").match(R)||[""]).length;while(l--)d=g=(s=Ee.exec(e[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=k.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=k.event.special[d]||{},c=k.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&k.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(d,a)),f.add&&(f.add.call(t,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),k.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.hasData(e)&&Q.get(e);if(v&&(u=v.events)){l=(t=(t||"").match(R)||[""]).length;while(l--)if(d=g=(s=Ee.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d){f=k.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;while(o--)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||k.removeEvent(e,d,v.handle),delete u[d])}else for(d in u)k.event.remove(e,d+t[l],n,r,!0);k.isEmptyObject(u)&&Q.remove(e,"handle events")}},dispatch:function(e){var t,n,r,i,o,a,s=k.event.fix(e),u=new Array(arguments.length),l=(Q.get(this,"events")||{})[s.type]||[],c=k.event.special[s.type]||{};for(u[0]=s,t=1;t\x20\t\r\n\f]*)[^>]*)\/>/gi,qe=/\s*$/g;function Oe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&k(e).children("tbody")[0]||e}function Pe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Re(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Me(e,t){var n,r,i,o,a,s,u,l;if(1===t.nodeType){if(Q.hasData(e)&&(o=Q.access(e),a=Q.set(t,o),l=o.events))for(i in delete a.handle,a.events={},l)for(n=0,r=l[i].length;n")},clone:function(e,t,n){var r,i,o,a,s,u,l,c=e.cloneNode(!0),f=oe(e);if(!(y.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||k.isXMLDoc(e)))for(a=ve(c),r=0,i=(o=ve(e)).length;r").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Vt,Gt=[],Yt=/(=)\?(?=&|$)|\?\?/;k.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Gt.pop()||k.expando+"_"+kt++;return this[e]=!0,e}}),k.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Yt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Yt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Yt,"$1"+r):!1!==e.jsonp&&(e.url+=(St.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||k.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?k(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Gt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Vt=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Vt.childNodes.length),k.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=D.exec(e))?[t.createElement(i[1])]:(i=we([e],t,o),o&&o.length&&k(o).remove(),k.merge([],i.childNodes)));var r,i,o},k.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(k.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},k.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){k.fn[t]=function(e){return this.on(t,e)}}),k.expr.pseudos.animated=function(t){return k.grep(k.timers,function(e){return t===e.elem}).length},k.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=k.css(e,"position"),c=k(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=k.css(e,"top"),u=k.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,k.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},k.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){k.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===k.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===k.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=k(e).offset()).top+=k.css(e,"borderTopWidth",!0),i.left+=k.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-k.css(r,"marginTop",!0),left:t.left-i.left-k.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===k.css(e,"position"))e=e.offsetParent;return e||ie})}}),k.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;k.fn[t]=function(e){return _(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),k.each(["top","left"],function(e,n){k.cssHooks[n]=ze(y.pixelPosition,function(e,t){if(t)return t=_e(e,n),$e.test(t)?k(e).position()[n]+"px":t})}),k.each({Height:"height",Width:"width"},function(a,s){k.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){k.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return _(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?k.css(e,t,i):k.style(e,t,n,i)},s,n?e:void 0,n)}})}),k.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){k.fn[n]=function(e,t){return 0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,j=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function qe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function Le(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function He(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):("number"==typeof f.top&&(f.top+="px"),"number"==typeof f.left&&(f.left+="px"),c.css(f))}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=$e(y.pixelPosition,function(e,t){if(t)return t=Be(e,n),Me.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0 - + diff --git a/rest_framework/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html index 5d9d80b05..9207f049b 100644 --- a/rest_framework/templates/rest_framework/base.html +++ b/rest_framework/templates/rest_framework/base.html @@ -293,7 +293,7 @@ csrfToken: "{% if request %}{{ csrf_token }}{% endif %}" }; - + diff --git a/rest_framework/templates/rest_framework/docs/error.html b/rest_framework/templates/rest_framework/docs/error.html index 6afd25e7b..6afc4a88b 100644 --- a/rest_framework/templates/rest_framework/docs/error.html +++ b/rest_framework/templates/rest_framework/docs/error.html @@ -66,6 +66,6 @@ at rest_framework/docs/error.html.

- + diff --git a/rest_framework/templates/rest_framework/docs/index.html b/rest_framework/templates/rest_framework/docs/index.html index 6804afe10..dfd363772 100644 --- a/rest_framework/templates/rest_framework/docs/index.html +++ b/rest_framework/templates/rest_framework/docs/index.html @@ -38,7 +38,7 @@ {% include "rest_framework/docs/auth/basic.html" %} {% include "rest_framework/docs/auth/session.html" %} - + diff --git a/rest_framework/views.py b/rest_framework/views.py index 69db053d6..d1b5e4ed9 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -166,13 +166,13 @@ class APIView(View): """ raise exceptions.MethodNotAllowed(request.method) - def permission_denied(self, request, message=None): + def permission_denied(self, request, message=None, code=None): """ If request is not permitted, determine what kind of exception to raise. """ if request.authenticators and not request.successful_authenticator: raise exceptions.NotAuthenticated() - raise exceptions.PermissionDenied(detail=message) + raise exceptions.PermissionDenied(detail=message, code=code) def throttled(self, request, wait): """ @@ -331,7 +331,9 @@ class APIView(View): for permission in self.get_permissions(): if not permission.has_permission(request, self): self.permission_denied( - request, message=getattr(permission, 'message', None) + request, + message=getattr(permission, 'message', None), + code=getattr(permission, 'code', None) ) def check_object_permissions(self, request, obj): @@ -342,7 +344,9 @@ class APIView(View): for permission in self.get_permissions(): if not permission.has_object_permission(request, self, obj): self.permission_denied( - request, message=getattr(permission, 'message', None) + request, + message=getattr(permission, 'message', None), + code=getattr(permission, 'code', None) ) def check_throttles(self, request): diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index d94c81df4..cad032dd9 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -25,11 +25,12 @@ from django.utils.decorators import classonlymethod from django.views.decorators.csrf import csrf_exempt from rest_framework import generics, mixins, views +from rest_framework.decorators import MethodMapper from rest_framework.reverse import reverse def _is_extra_action(attr): - return hasattr(attr, 'mapping') + return hasattr(attr, 'mapping') and isinstance(attr.mapping, MethodMapper) class ViewSetMixin: @@ -92,6 +93,10 @@ class ViewSetMixin: def view(request, *args, **kwargs): self = cls(**initkwargs) + + if 'get' in actions and 'head' not in actions: + actions['head'] = actions['get'] + # We also store the mapping of request methods to actions, # so that we can later set the action attribute. # eg. `self.action = 'list'` on an incoming GET request. @@ -103,9 +108,6 @@ class ViewSetMixin: handler = getattr(self, action) setattr(self, method, handler) - if hasattr(self, 'get') and not hasattr(self, 'head'): - self.head = self.get - self.request = request self.args = args self.kwargs = kwargs diff --git a/setup.py b/setup.py index 65536885a..99826b4d0 100755 --- a/setup.py +++ b/setup.py @@ -82,17 +82,15 @@ setup( author_email='tom@tomchristie.com', # SEE NOTE BELOW (*) packages=find_packages(exclude=['tests*']), include_package_data=True, - install_requires=["django>=1.11"], + install_requires=["django>=2.2"], python_requires=">=3.5", zip_safe=False, classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', - 'Framework :: Django :: 1.11', - 'Framework :: Django :: 2.0', - 'Framework :: Django :: 2.1', 'Framework :: Django :: 2.2', + 'Framework :: Django :: 3.0', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', diff --git a/tests/authentication/test_authentication.py b/tests/authentication/test_authentication.py index 37e265e17..4760ea319 100644 --- a/tests/authentication/test_authentication.py +++ b/tests/authentication/test_authentication.py @@ -159,6 +159,25 @@ class BasicAuthTests(TestCase): ) assert response.status_code == status.HTTP_401_UNAUTHORIZED + def test_decoding_of_utf8_credentials(self): + username = 'walterwhité' + email = 'walterwhite@example.com' + password = 'pässwörd' + User.objects.create_user( + username, email, password + ) + credentials = ('%s:%s' % (username, password)) + base64_credentials = base64.b64encode( + credentials.encode('utf-8') + ).decode(HTTP_HEADER_ENCODING) + auth = 'Basic %s' % base64_credentials + response = self.csrf_client.post( + '/basic/', + {'example': 'example'}, + HTTP_AUTHORIZATION=auth + ) + assert response.status_code == status.HTTP_200_OK + @override_settings(ROOT_URLCONF=__name__) class SessionAuthTests(TestCase): diff --git a/tests/schemas/test_managementcommand.py b/tests/schemas/test_managementcommand.py index 6cdf7f8b1..115f871e5 100644 --- a/tests/schemas/test_managementcommand.py +++ b/tests/schemas/test_managementcommand.py @@ -1,4 +1,6 @@ import io +import os +import tempfile import pytest from django.conf.urls import url @@ -73,6 +75,21 @@ class GenerateSchemaTests(TestCase): out_json = yaml.safe_load(self.out.getvalue()) assert out_json == CustomSchemaGenerator.SCHEMA + def test_writes_schema_to_file_on_parameter(self): + fd, path = tempfile.mkstemp() + try: + call_command('generateschema', '--file={}'.format(path), stdout=self.out) + # nothing on stdout + assert not self.out.getvalue() + + call_command('generateschema', stdout=self.out) + expected_out = self.out.getvalue() + # file output identical to stdout output + with os.fdopen(fd) as fh: + assert expected_out and fh.read() == expected_out + finally: + os.remove(path) + @pytest.mark.skipif(yaml is None, reason='PyYAML is required.') @override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema'}) def test_coreapi_renders_default_schema_with_custom_title_url_and_description(self): diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index f734fd169..0e86a7f50 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -1,12 +1,16 @@ +import uuid +import warnings + import pytest from django.conf.urls import url from django.test import RequestFactory, TestCase, override_settings from django.utils.translation import gettext_lazy as _ from rest_framework import filters, generics, pagination, routers, serializers +from rest_framework.authtoken.views import obtain_auth_token from rest_framework.compat import uritemplate from rest_framework.parsers import JSONParser, MultiPartParser -from rest_framework.renderers import JSONRenderer +from rest_framework.renderers import JSONRenderer, OpenAPIRenderer from rest_framework.request import Request from rest_framework.schemas.openapi import AutoSchema, SchemaGenerator @@ -44,6 +48,8 @@ class TestBasics(TestCase): class TestFieldMapping(TestCase): def test_list_field_mapping(self): + uuid1 = uuid.uuid4() + uuid2 = uuid.uuid4() inspector = AutoSchema() cases = [ (serializers.ListField(), {'items': {}, 'type': 'array'}), @@ -53,23 +59,54 @@ class TestFieldMapping(TestCase): (serializers.ListField(child=serializers.IntegerField(max_value=4294967295)), {'items': {'type': 'integer', 'maximum': 4294967295, 'format': 'int64'}, 'type': 'array'}), (serializers.ListField(child=serializers.ChoiceField(choices=[('a', 'Choice A'), ('b', 'Choice B')])), - {'items': {'enum': ['a', 'b']}, 'type': 'array'}), + {'items': {'enum': ['a', 'b'], 'type': 'string'}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[(1, 'One'), (2, 'Two')])), + {'items': {'enum': [1, 2], 'type': 'integer'}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[(1.1, 'First'), (2.2, 'Second')])), + {'items': {'enum': [1.1, 2.2], 'type': 'number'}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[(True, 'true'), (False, 'false')])), + {'items': {'enum': [True, False], 'type': 'boolean'}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[(uuid1, 'uuid1'), (uuid2, 'uuid2')])), + {'items': {'enum': [uuid1, uuid2]}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[(1, 'One'), ('a', 'Choice A')])), + {'items': {'enum': [1, 'a']}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[ + (1, 'One'), ('a', 'Choice A'), (1.1, 'First'), (1.1, 'First'), (1, 'One'), ('a', 'Choice A'), (1, 'One') + ])), + {'items': {'enum': [1, 'a', 1.1]}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[ + (1, 'One'), (2, 'Two'), (3, 'Three'), (2, 'Two'), (3, 'Three'), (1, 'One'), + ])), + {'items': {'enum': [1, 2, 3], 'type': 'integer'}, 'type': 'array'}), (serializers.IntegerField(min_value=2147483648), {'type': 'integer', 'minimum': 2147483648, 'format': 'int64'}), ] for field, mapping in cases: with self.subTest(field=field): - assert inspector._map_field(field) == mapping + assert inspector.map_field(field) == mapping def test_lazy_string_field(self): - class Serializer(serializers.Serializer): + class ItemSerializer(serializers.Serializer): text = serializers.CharField(help_text=_('lazy string')) inspector = AutoSchema() - data = inspector._map_serializer(Serializer()) + data = inspector.map_serializer(ItemSerializer()) assert isinstance(data['properties']['text']['description'], str), "description must be str" + def test_boolean_default_field(self): + class Serializer(serializers.Serializer): + default_true = serializers.BooleanField(default=True) + default_false = serializers.BooleanField(default=False) + without_default = serializers.BooleanField() + + inspector = AutoSchema() + + data = inspector.map_serializer(Serializer()) + assert data['properties']['default_true']['default'] is True, "default must be true" + assert data['properties']['default_false']['default'] is False, "default must be false" + assert 'default' not in data['properties']['without_default'], "default must not be defined" + @pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.') class TestOperationIntrospection(TestCase): @@ -91,6 +128,7 @@ class TestOperationIntrospection(TestCase): 'operationId': 'listDocStringExamples', 'description': 'A description of my GET operation.', 'parameters': [], + 'tags': ['example'], 'responses': { '200': { 'description': '', @@ -120,7 +158,7 @@ class TestOperationIntrospection(TestCase): operation = inspector.get_operation(path, method) assert operation == { - 'operationId': 'RetrieveDocStringExampleDetail', + 'operationId': 'retrieveDocStringExampleDetail', 'description': 'A description of my GET operation.', 'parameters': [{ 'description': '', @@ -131,6 +169,7 @@ class TestOperationIntrospection(TestCase): 'type': 'string', }, }], + 'tags': ['example'], 'responses': { '200': { 'description': '', @@ -148,6 +187,33 @@ class TestOperationIntrospection(TestCase): path = '/' method = 'POST' + class ItemSerializer(serializers.Serializer): + text = serializers.CharField() + read_only = serializers.CharField(read_only=True) + + class View(generics.GenericAPIView): + serializer_class = ItemSerializer + + view = create_view( + View, + method, + create_request(path) + ) + inspector = AutoSchema() + inspector.view = view + + request_body = inspector.get_request_body(path, method) + print(request_body) + assert request_body['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item' + + components = inspector.get_components(path, method) + assert components['Item']['required'] == ['text'] + assert sorted(list(components['Item']['properties'].keys())) == ['read_only', 'text'] + + def test_invalid_serializer_class_name(self): + path = '/' + method = 'POST' + class Serializer(serializers.Serializer): text = serializers.CharField() read_only = serializers.CharField(read_only=True) @@ -163,20 +229,22 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - request_body = inspector._get_request_body(path, method) - assert request_body['content']['application/json']['schema']['required'] == ['text'] - assert list(request_body['content']['application/json']['schema']['properties'].keys()) == ['text'] + serializer = inspector.get_serializer(path, method) + + with pytest.raises(Exception) as exc: + inspector.get_component_name(serializer) + assert "is an invalid class name for schema generation" in str(exc.value) def test_empty_required(self): path = '/' method = 'POST' - class Serializer(serializers.Serializer): + class ItemSerializer(serializers.Serializer): read_only = serializers.CharField(read_only=True) write_only = serializers.CharField(write_only=True, required=False) class View(generics.GenericAPIView): - serializer_class = Serializer + serializer_class = ItemSerializer view = create_view( View, @@ -186,23 +254,24 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - request_body = inspector._get_request_body(path, method) + components = inspector.get_components(path, method) + component = components['Item'] # there should be no empty 'required' property, see #6834 - assert 'required' not in request_body['content']['application/json']['schema'] + assert 'required' not in component - for response in inspector._get_responses(path, method).values(): - assert 'required' not in response['content']['application/json']['schema'] + for response in inspector.get_responses(path, method).values(): + assert 'required' not in component def test_empty_required_with_patch_method(self): path = '/' method = 'PATCH' - class Serializer(serializers.Serializer): + class ItemSerializer(serializers.Serializer): read_only = serializers.CharField(read_only=True) write_only = serializers.CharField(write_only=True, required=False) class View(generics.GenericAPIView): - serializer_class = Serializer + serializer_class = ItemSerializer view = create_view( View, @@ -212,22 +281,23 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - request_body = inspector._get_request_body(path, method) + components = inspector.get_components(path, method) + component = components['Item'] # there should be no empty 'required' property, see #6834 - assert 'required' not in request_body['content']['application/json']['schema'] - for response in inspector._get_responses(path, method).values(): - assert 'required' not in response['content']['application/json']['schema'] + assert 'required' not in component + for response in inspector.get_responses(path, method).values(): + assert 'required' not in component def test_response_body_generation(self): path = '/' method = 'POST' - class Serializer(serializers.Serializer): + class ItemSerializer(serializers.Serializer): text = serializers.CharField() write_only = serializers.CharField(write_only=True) class View(generics.GenericAPIView): - serializer_class = Serializer + serializer_class = ItemSerializer view = create_view( View, @@ -237,10 +307,13 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) - assert responses['200']['content']['application/json']['schema']['required'] == ['text'] - assert list(responses['200']['content']['application/json']['schema']['properties'].keys()) == ['text'] - assert 'description' in responses['200'] + responses = inspector.get_responses(path, method) + assert responses['201']['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item' + + components = inspector.get_components(path, method) + assert sorted(components['Item']['required']) == ['text', 'write_only'] + assert sorted(list(components['Item']['properties'].keys())) == ['text', 'write_only'] + assert 'description' in responses['201'] def test_response_body_nested_serializer(self): path = '/' @@ -249,12 +322,12 @@ class TestOperationIntrospection(TestCase): class NestedSerializer(serializers.Serializer): number = serializers.IntegerField() - class Serializer(serializers.Serializer): + class ItemSerializer(serializers.Serializer): text = serializers.CharField() nested = NestedSerializer() class View(generics.GenericAPIView): - serializer_class = Serializer + serializer_class = ItemSerializer view = create_view( View, @@ -264,8 +337,12 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) - schema = responses['200']['content']['application/json']['schema'] + responses = inspector.get_responses(path, method) + assert responses['201']['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item' + components = inspector.get_components(path, method) + assert components['Item'] + + schema = components['Item'] assert sorted(schema['required']) == ['nested', 'text'] assert sorted(list(schema['properties'].keys())) == ['nested', 'text'] assert schema['properties']['nested']['type'] == 'object' @@ -291,7 +368,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) + responses = inspector.get_responses(path, method) assert responses == { '200': { 'description': '', @@ -300,18 +377,25 @@ class TestOperationIntrospection(TestCase): 'schema': { 'type': 'array', 'items': { - 'properties': { - 'text': { - 'type': 'string', - }, - }, - 'required': ['text'], + '$ref': '#/components/schemas/Item' }, }, }, }, }, } + components = inspector.get_components(path, method) + assert components == { + 'Item': { + 'type': 'object', + 'properties': { + 'text': { + 'type': 'string', + }, + }, + 'required': ['text'], + } + } def test_paginated_list_response_body_generation(self): """Test that pagination properties are added for a paginated list view.""" @@ -340,7 +424,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) + responses = inspector.get_responses(path, method) assert responses == { '200': { 'description': '', @@ -351,12 +435,7 @@ class TestOperationIntrospection(TestCase): 'item': { 'type': 'array', 'items': { - 'properties': { - 'text': { - 'type': 'string', - }, - }, - 'required': ['text'], + '$ref': '#/components/schemas/Item' }, }, }, @@ -364,6 +443,18 @@ class TestOperationIntrospection(TestCase): }, }, } + components = inspector.get_components(path, method) + assert components == { + 'Item': { + 'type': 'object', + 'properties': { + 'text': { + 'type': 'string', + }, + }, + 'required': ['text'], + } + } def test_delete_response_body_generation(self): """Test that a view's delete method generates a proper response body schema.""" @@ -381,7 +472,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) + responses = inspector.get_responses(path, method) assert responses == { '204': { 'description': '', @@ -405,7 +496,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - request_body = inspector._get_request_body(path, method) + request_body = inspector.get_request_body(path, method) assert len(request_body['content'].keys()) == 2 assert 'multipart/form-data' in request_body['content'] @@ -428,7 +519,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) + responses = inspector.get_responses(path, method) # TODO this should be changed once the multiple response # schema support is there success_response = responses['200'] @@ -436,6 +527,19 @@ class TestOperationIntrospection(TestCase): assert len(success_response['content'].keys()) == 1 assert 'application/json' in success_response['content'] + def test_openapi_yaml_rendering_without_aliases(self): + renderer = OpenAPIRenderer() + + reused_object = {'test': 'test'} + data = { + 'o1': reused_object, + 'o2': reused_object, + } + assert ( + renderer.render(data) == b'o1:\n test: test\no2:\n test: test\n' or + renderer.render(data) == b'o2:\n test: test\no1:\n test: test\n' # py <= 3.5 + ) + def test_serializer_filefield(self): path = '/{id}/' method = 'POST' @@ -454,10 +558,10 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - request_body = inspector._get_request_body(path, method) - mp_media = request_body['content']['multipart/form-data'] - attachment = mp_media['schema']['properties']['attachment'] - assert attachment['format'] == 'binary' + components = inspector.get_components(path, method) + component = components['Item'] + properties = component['properties'] + assert properties['attachment']['format'] == 'binary' def test_retrieve_response_body_generation(self): """ @@ -490,25 +594,33 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) + responses = inspector.get_responses(path, method) assert responses == { '200': { 'description': '', 'content': { 'application/json': { 'schema': { - 'properties': { - 'text': { - 'type': 'string', - }, - }, - 'required': ['text'], + '$ref': '#/components/schemas/Item' }, }, }, }, } + components = inspector.get_components(path, method) + assert components == { + 'Item': { + 'type': 'object', + 'properties': { + 'text': { + 'type': 'string', + }, + }, + 'required': ['text'], + } + } + def test_operation_id_generation(self): path = '/' method = 'GET' @@ -521,9 +633,75 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - operationId = inspector._get_operation_id(path, method) + operationId = inspector.get_operation_id(path, method) assert operationId == 'listExamples' + def test_operation_id_custom_operation_id_base(self): + path = '/' + method = 'GET' + + view = create_view( + views.ExampleGenericAPIView, + method, + create_request(path), + ) + inspector = AutoSchema(operation_id_base="Ulysse") + inspector.view = view + + operationId = inspector.get_operation_id(path, method) + assert operationId == 'listUlysses' + + def test_operation_id_custom_name(self): + path = '/' + method = 'GET' + + view = create_view( + views.ExampleGenericAPIView, + method, + create_request(path), + ) + inspector = AutoSchema(operation_id_base='Ulysse') + inspector.view = view + + operationId = inspector.get_operation_id(path, method) + assert operationId == 'listUlysses' + + def test_operation_id_override_get(self): + class CustomSchema(AutoSchema): + def get_operation_id(self, path, method): + return 'myCustomOperationId' + + path = '/' + method = 'GET' + view = create_view( + views.ExampleGenericAPIView, + method, + create_request(path), + ) + inspector = CustomSchema() + inspector.view = view + + operationId = inspector.get_operation_id(path, method) + assert operationId == 'myCustomOperationId' + + def test_operation_id_override_base(self): + class CustomSchema(AutoSchema): + def get_operation_id_base(self, path, method, action): + return 'Item' + + path = '/' + method = 'GET' + view = create_view( + views.ExampleGenericAPIView, + method, + create_request(path), + ) + inspector = CustomSchema() + inspector.view = view + + operationId = inspector.get_operation_id(path, method) + assert operationId == 'listItem' + def test_repeat_operation_ids(self): router = routers.SimpleRouter() router.register('account', views.ExampleGenericViewSet, basename="account") @@ -539,6 +717,41 @@ class TestOperationIntrospection(TestCase): assert schema_str.count("newExample") == 1 assert schema_str.count("oldExample") == 1 + def test_duplicate_operation_id(self): + patterns = [ + url(r'^duplicate1/?$', views.ExampleOperationIdDuplicate1.as_view()), + url(r'^duplicate2/?$', views.ExampleOperationIdDuplicate2.as_view()), + ] + + generator = SchemaGenerator(patterns=patterns) + request = create_request('/') + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + generator.get_schema(request=request) + + assert len(w) == 1 + assert issubclass(w[-1].category, UserWarning) + print(str(w[-1].message)) + assert 'You have a duplicated operationId' in str(w[-1].message) + + def test_operation_id_viewset(self): + router = routers.SimpleRouter() + router.register('account', views.ExampleViewSet, basename="account") + urlpatterns = router.urls + + generator = SchemaGenerator(patterns=urlpatterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + print(schema) + assert schema['paths']['/account/']['get']['operationId'] == 'listExampleViewSets' + assert schema['paths']['/account/']['post']['operationId'] == 'createExampleViewSet' + assert schema['paths']['/account/{id}/']['get']['operationId'] == 'retrieveExampleViewSet' + assert schema['paths']['/account/{id}/']['put']['operationId'] == 'updateExampleViewSet' + assert schema['paths']['/account/{id}/']['patch']['operationId'] == 'partialUpdateExampleViewSet' + assert schema['paths']['/account/{id}/']['delete']['operationId'] == 'destroyExampleViewSet' + def test_serializer_datefield(self): path = '/' method = 'GET' @@ -550,9 +763,9 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) - response_schema = responses['200']['content']['application/json']['schema'] - properties = response_schema['items']['properties'] + components = inspector.get_components(path, method) + component = components['Example'] + properties = component['properties'] assert properties['date']['type'] == properties['datetime']['type'] == 'string' assert properties['date']['format'] == 'date' assert properties['datetime']['format'] == 'date-time' @@ -568,9 +781,9 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) - response_schema = responses['200']['content']['application/json']['schema'] - properties = response_schema['items']['properties'] + components = inspector.get_components(path, method) + component = components['Example'] + properties = component['properties'] assert properties['hstore']['type'] == 'object' def test_serializer_callable_default(self): @@ -584,9 +797,9 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) - response_schema = responses['200']['content']['application/json']['schema'] - properties = response_schema['items']['properties'] + components = inspector.get_components(path, method) + component = components['Example'] + properties = component['properties'] assert 'default' not in properties['uuid_field'] def test_serializer_validators(self): @@ -600,9 +813,9 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) - response_schema = responses['200']['content']['application/json']['schema'] - properties = response_schema['items']['properties'] + components = inspector.get_components(path, method) + component = components['ExampleValidated'] + properties = component['properties'] assert properties['integer']['type'] == 'integer' assert properties['integer']['maximum'] == 99 @@ -625,6 +838,16 @@ class TestOperationIntrospection(TestCase): assert properties['decimal2']['type'] == 'number' assert properties['decimal2']['multipleOf'] == .0001 + assert properties['decimal3'] == { + 'type': 'string', 'format': 'decimal', 'maximum': 1000000, 'minimum': -1000000, 'multipleOf': 0.01 + } + assert properties['decimal4'] == { + 'type': 'string', 'format': 'decimal', 'maximum': 1000000, 'minimum': -1000000, 'multipleOf': 0.01 + } + assert properties['decimal5'] == { + 'type': 'string', 'format': 'decimal', 'maximum': 10000, 'minimum': -10000, 'multipleOf': 0.01 + } + assert properties['email']['type'] == 'string' assert properties['email']['format'] == 'email' assert properties['email']['default'] == 'foo@bar.com' @@ -645,6 +868,56 @@ class TestOperationIntrospection(TestCase): assert properties['ip']['type'] == 'string' assert 'format' not in properties['ip'] + def test_overridden_tags(self): + class ExampleStringTagsViewSet(views.ExampleGenericAPIView): + schema = AutoSchema(tags=['example1', 'example2']) + + url_patterns = [ + url(r'^test/?$', ExampleStringTagsViewSet.as_view()), + ] + generator = SchemaGenerator(patterns=url_patterns) + schema = generator.get_schema(request=create_request('/')) + assert schema['paths']['/test/']['get']['tags'] == ['example1', 'example2'] + + def test_overridden_get_tags_method(self): + class MySchema(AutoSchema): + def get_tags(self, path, method): + if path.endswith('/new/'): + tags = ['tag1', 'tag2'] + elif path.endswith('/old/'): + tags = ['tag2', 'tag3'] + else: + tags = ['tag4', 'tag5'] + + return tags + + class ExampleStringTagsViewSet(views.ExampleGenericViewSet): + schema = MySchema() + + router = routers.SimpleRouter() + router.register('example', ExampleStringTagsViewSet, basename="example") + generator = SchemaGenerator(patterns=router.urls) + schema = generator.get_schema(request=create_request('/')) + assert schema['paths']['/example/new/']['get']['tags'] == ['tag1', 'tag2'] + assert schema['paths']['/example/old/']['get']['tags'] == ['tag2', 'tag3'] + + def test_auto_generated_apiview_tags(self): + class RestaurantAPIView(views.ExampleGenericAPIView): + schema = AutoSchema(operation_id_base="restaurant") + pass + + class BranchAPIView(views.ExampleGenericAPIView): + pass + + url_patterns = [ + url(r'^any-dash_underscore/?$', RestaurantAPIView.as_view()), + url(r'^restaurants/branches/?$', BranchAPIView.as_view()) + ] + generator = SchemaGenerator(patterns=url_patterns) + schema = generator.get_schema(request=create_request('/')) + assert schema['paths']['/any-dash_underscore/']['get']['tags'] == ['any-dash-underscore'] + assert schema['paths']['/restaurants/branches/']['get']['tags'] == ['restaurants'] + @pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.') @override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema'}) @@ -744,3 +1017,95 @@ class TestGenerator(TestCase): assert schema['info']['title'] == '' assert schema['info']['version'] == '' + + def test_serializer_model(self): + """Construction of the top level dictionary.""" + patterns = [ + url(r'^example/?$', views.ExampleGenericAPIViewModel.as_view()), + ] + + generator = SchemaGenerator(patterns=patterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + + print(schema) + + assert 'components' in schema + assert 'schemas' in schema['components'] + assert 'ExampleModel' in schema['components']['schemas'] + + def test_authtoken_serializer(self): + patterns = [ + url(r'^api-token-auth/', obtain_auth_token) + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + + print(schema) + + route = schema['paths']['/api-token-auth/']['post'] + body_schema = route['requestBody']['content']['application/json']['schema'] + + assert body_schema == { + '$ref': '#/components/schemas/AuthToken' + } + assert schema['components']['schemas']['AuthToken'] == { + 'type': 'object', + 'properties': { + 'username': {'type': 'string', 'writeOnly': True}, + 'password': {'type': 'string', 'writeOnly': True}, + 'token': {'type': 'string', 'readOnly': True}, + }, + 'required': ['username', 'password'] + } + + def test_component_name(self): + patterns = [ + url(r'^example/?$', views.ExampleAutoSchemaComponentName.as_view()), + ] + + generator = SchemaGenerator(patterns=patterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + + print(schema) + assert 'components' in schema + assert 'schemas' in schema['components'] + assert 'Ulysses' in schema['components']['schemas'] + + def test_duplicate_component_name(self): + patterns = [ + url(r'^duplicate1/?$', views.ExampleAutoSchemaDuplicate1.as_view()), + url(r'^duplicate2/?$', views.ExampleAutoSchemaDuplicate2.as_view()), + ] + + generator = SchemaGenerator(patterns=patterns) + request = create_request('/') + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + schema = generator.get_schema(request=request) + + assert len(w) == 1 + assert issubclass(w[-1].category, UserWarning) + assert 'has been overriden with a different value.' in str(w[-1].message) + + assert 'components' in schema + assert 'schemas' in schema['components'] + assert 'Duplicate' in schema['components']['schemas'] + + def test_component_should_not_be_generated_for_delete_method(self): + class ExampleView(generics.DestroyAPIView): + schema = AutoSchema(operation_id_base='example') + + url_patterns = [ + url(r'^example/?$', ExampleView.as_view()), + ] + generator = SchemaGenerator(patterns=url_patterns) + schema = generator.get_schema(request=create_request('/')) + assert 'components' not in schema + assert 'content' not in schema['paths']['/example/']['delete']['responses']['204'] diff --git a/tests/schemas/views.py b/tests/schemas/views.py index e8307ccbd..18b3beae4 100644 --- a/tests/schemas/views.py +++ b/tests/schemas/views.py @@ -4,12 +4,14 @@ from django.core.validators import ( DecimalValidator, MaxLengthValidator, MaxValueValidator, MinLengthValidator, MinValueValidator, RegexValidator ) +from django.db import models from rest_framework import generics, permissions, serializers from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.schemas.openapi import AutoSchema from rest_framework.views import APIView -from rest_framework.viewsets import GenericViewSet +from rest_framework.viewsets import GenericViewSet, ViewSet class ExampleListView(APIView): @@ -117,9 +119,13 @@ class ExampleValidatedSerializer(serializers.Serializer): MinLengthValidator(limit_value=2), ) ) - decimal1 = serializers.DecimalField(max_digits=6, decimal_places=2) - decimal2 = serializers.DecimalField(max_digits=5, decimal_places=0, + decimal1 = serializers.DecimalField(max_digits=6, decimal_places=2, coerce_to_string=False) + decimal2 = serializers.DecimalField(max_digits=5, decimal_places=0, coerce_to_string=False, validators=(DecimalValidator(max_digits=17, decimal_places=4),)) + decimal3 = serializers.DecimalField(max_digits=8, decimal_places=2, coerce_to_string=True) + decimal4 = serializers.DecimalField(max_digits=8, decimal_places=2, coerce_to_string=True, + validators=(DecimalValidator(max_digits=17, decimal_places=4),)) + decimal5 = serializers.DecimalField(max_digits=6, decimal_places=2) email = serializers.EmailField(default='foo@bar.com') url = serializers.URLField(default='http://www.example.com', allow_null=True) uuid = serializers.UUIDField() @@ -137,3 +143,101 @@ class ExampleValidatedAPIView(generics.GenericAPIView): url='http://localhost', uuid=uuid.uuid4(), ip4='127.0.0.1', ip6='::1', ip='192.168.1.1') return Response(serializer.data) + + +# Serializer with model. +class OpenAPIExample(models.Model): + first_name = models.CharField(max_length=30) + + +class ExampleSerializerModel(serializers.Serializer): + date = serializers.DateField() + datetime = serializers.DateTimeField() + hstore = serializers.HStoreField() + uuid_field = serializers.UUIDField(default=uuid.uuid4) + + class Meta: + model = OpenAPIExample + + +class ExampleOperationIdDuplicate1(generics.GenericAPIView): + serializer_class = ExampleSerializerModel + + def get(self, *args, **kwargs): + pass + + +class ExampleOperationIdDuplicate2(generics.GenericAPIView): + serializer_class = ExampleSerializerModel + + def get(self, *args, **kwargs): + pass + + +class ExampleGenericAPIViewModel(generics.GenericAPIView): + serializer_class = ExampleSerializerModel + + def get(self, *args, **kwargs): + from datetime import datetime + now = datetime.now() + + serializer = self.get_serializer(data=now.date(), datetime=now) + return Response(serializer.data) + + +class ExampleAutoSchemaComponentName(generics.GenericAPIView): + serializer_class = ExampleSerializerModel + schema = AutoSchema(component_name="Ulysses") + + def get(self, *args, **kwargs): + from datetime import datetime + now = datetime.now() + + serializer = self.get_serializer(data=now.date(), datetime=now) + return Response(serializer.data) + + +class ExampleAutoSchemaDuplicate1(generics.GenericAPIView): + serializer_class = ExampleValidatedSerializer + schema = AutoSchema(component_name="Duplicate") + + def get(self, *args, **kwargs): + from datetime import datetime + now = datetime.now() + + serializer = self.get_serializer(data=now.date(), datetime=now) + return Response(serializer.data) + + +class ExampleAutoSchemaDuplicate2(generics.GenericAPIView): + serializer_class = ExampleSerializerModel + schema = AutoSchema(component_name="Duplicate") + + def get(self, *args, **kwargs): + from datetime import datetime + now = datetime.now() + + serializer = self.get_serializer(data=now.date(), datetime=now) + return Response(serializer.data) + + +class ExampleViewSet(ViewSet): + serializer_class = ExampleSerializerModel + + def list(self, request): + pass + + def create(self, request): + pass + + def retrieve(self, request, pk=None): + pass + + def update(self, request, pk=None): + pass + + def partial_update(self, request, pk=None): + pass + + def destroy(self, request, pk=None): + pass diff --git a/tests/test_decorators.py b/tests/test_decorators.py index e10f0e5c5..99ba13e60 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -22,10 +22,6 @@ class DecoratorTestCase(TestCase): def setUp(self): self.factory = APIRequestFactory() - def _finalize_response(self, request, response, *args, **kwargs): - response.request = request - return APIView.finalize_response(self, request, response, *args, **kwargs) - def test_api_view_incorrect(self): """ If @api_view is not applied correct, we should raise an assertion. diff --git a/tests/test_filters.py b/tests/test_filters.py index 6d7969a92..e69537666 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -1,9 +1,11 @@ import datetime from importlib import reload as reload_module +import django import pytest from django.core.exceptions import ImproperlyConfigured from django.db import models +from django.db.models import CharField, Transform from django.db.models.functions import Concat, Upper from django.test import TestCase from django.test.utils import override_settings @@ -189,6 +191,42 @@ class SearchFilterTests(TestCase): assert terms == ['asdf'] + @pytest.mark.skipif(django.VERSION[:2] < (2, 2), reason="requires django 2.2 or higher") + def test_search_field_with_additional_transforms(self): + from django.test.utils import register_lookup + + class SearchListView(generics.ListAPIView): + queryset = SearchFilterModel.objects.all() + serializer_class = SearchFilterSerializer + filter_backends = (filters.SearchFilter,) + search_fields = ('text__trim', ) + + view = SearchListView.as_view() + + # an example custom transform, that trims `a` from the string. + class TrimA(Transform): + function = 'TRIM' + lookup_name = 'trim' + + def as_sql(self, compiler, connection): + sql, params = compiler.compile(self.lhs) + return "trim(%s, 'a')" % sql, params + + with register_lookup(CharField, TrimA): + # Search including `a` + request = factory.get('/', {'search': 'abc'}) + + response = view(request) + assert response.data == [] + + # Search excluding `a` + request = factory.get('/', {'search': 'bc'}) + response = view(request) + assert response.data == [ + {'id': 1, 'title': 'z', 'text': 'abc'}, + {'id': 2, 'title': 'zz', 'text': 'bcd'}, + ] + class AttributeModel(models.Model): label = models.CharField(max_length=32) @@ -368,6 +406,21 @@ class SearchFilterAnnotatedFieldTests(TestCase): assert len(response.data) == 1 assert response.data[0]['title_text'] == 'ABCDEF' + def test_must_call_distinct_subsequent_m2m_fields(self): + f = filters.SearchFilter() + + queryset = SearchFilterModelM2M.objects.annotate( + title_text=Upper( + Concat(models.F('title'), models.F('text')) + ) + ).all() + + # Sanity check that m2m must call distinct + assert f.must_call_distinct(queryset, ['attributes']) + + # Annotated field should not prevent m2m must call distinct + assert f.must_call_distinct(queryset, ['title_text', 'attributes']) + class OrderingFilterModel(models.Model): title = models.CharField(max_length=20, verbose_name='verbose title') diff --git a/tests/test_generics.py b/tests/test_generics.py index 0b91e3465..2907d2773 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -662,3 +662,33 @@ class GetObjectOr404Tests(TestCase): def test_get_object_or_404_with_invalid_string_for_uuid(self): with pytest.raises(Http404): generics.get_object_or_404(UUIDForeignKeyTarget, pk='not-a-uuid') + + +class TestSerializer(TestCase): + + def test_serializer_class_not_provided(self): + class NoSerializerClass(generics.GenericAPIView): + pass + + with pytest.raises(AssertionError) as excinfo: + NoSerializerClass().get_serializer_class() + + assert str(excinfo.value) == ( + "'NoSerializerClass' should either include a `serializer_class` " + "attribute, or override the `get_serializer_class()` method.") + + def test_given_context_not_overridden(self): + context = object() + + class View(generics.ListAPIView): + serializer_class = serializers.Serializer + + def list(self, request): + response = Response() + response.serializer = self.get_serializer(context=context) + return response + + response = View.as_view()(factory.get('/')) + serializer = response.serializer + + assert serializer.context is context diff --git a/tests/test_model_serializer.py b/tests/test_model_serializer.py index fbb562792..0de628dc8 100644 --- a/tests/test_model_serializer.py +++ b/tests/test_model_serializer.py @@ -8,6 +8,7 @@ an appropriate set of serializer fields for each case. import datetime import decimal import sys +import tempfile from collections import OrderedDict import django @@ -71,7 +72,7 @@ class RegularFieldsModel(models.Model): time_field = models.TimeField() url_field = models.URLField(max_length=100) custom_field = CustomField() - file_path_field = models.FilePathField(path='/tmp/') + file_path_field = models.FilePathField(path=tempfile.gettempdir()) def method(self): return 'method' @@ -191,8 +192,8 @@ class TestRegularFieldMappings(TestCase): time_field = TimeField() url_field = URLField(max_length=100) custom_field = ModelField(model_field=) - file_path_field = FilePathField(path='/tmp/') - """) + file_path_field = FilePathField(path=%r) + """ % tempfile.gettempdir()) self.assertEqual(repr(TestSerializer()), expected) diff --git a/tests/test_pagination.py b/tests/test_pagination.py index cd84c8151..c028f0ea8 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -281,10 +281,14 @@ class TestPageNumberPagination: 'next': { 'type': 'string', 'nullable': True, + 'format': 'uri', + 'example': 'http://api.example.org/accounts/?page=4', }, 'previous': { 'type': 'string', 'nullable': True, + 'format': 'uri', + 'example': 'http://api.example.org/accounts/?page=2', }, 'results': unpaginated_schema, }, @@ -588,10 +592,14 @@ class TestLimitOffset: 'next': { 'type': 'string', 'nullable': True, + 'format': 'uri', + 'example': 'http://api.example.org/accounts/?offset=400&limit=100', }, 'previous': { 'type': 'string', 'nullable': True, + 'format': 'uri', + 'example': 'http://api.example.org/accounts/?offset=200&limit=100', }, 'results': unpaginated_schema, }, diff --git a/tests/test_permissions.py b/tests/test_permissions.py index b6178c0bb..d445f271d 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -438,6 +438,7 @@ class BasicPerm(permissions.BasePermission): class BasicPermWithDetail(permissions.BasePermission): message = 'Custom: You cannot access this resource' + code = 'permission_denied_custom' def has_permission(self, request, view): return False @@ -450,6 +451,7 @@ class BasicObjectPerm(permissions.BasePermission): class BasicObjectPermWithDetail(permissions.BasePermission): message = 'Custom: You cannot access this resource' + code = 'permission_denied_custom' def has_object_permission(self, request, view, obj): return False @@ -492,30 +494,35 @@ class CustomPermissionsTests(TestCase): credentials = basic_auth_header('username', 'password') self.request = factory.get('/1', format='json', HTTP_AUTHORIZATION=credentials) self.custom_message = 'Custom: You cannot access this resource' + self.custom_code = 'permission_denied_custom' def test_permission_denied(self): response = denied_view(self.request, pk=1) detail = response.data.get('detail') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertNotEqual(detail, self.custom_message) + self.assertNotEqual(detail.code, self.custom_code) def test_permission_denied_with_custom_detail(self): response = denied_view_with_detail(self.request, pk=1) detail = response.data.get('detail') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(detail, self.custom_message) + self.assertEqual(detail.code, self.custom_code) def test_permission_denied_for_object(self): response = denied_object_view(self.request, pk=1) detail = response.data.get('detail') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertNotEqual(detail, self.custom_message) + self.assertNotEqual(detail.code, self.custom_code) def test_permission_denied_for_object_with_custom_detail(self): response = denied_object_view_with_detail(self.request, pk=1) detail = response.data.get('detail') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(detail, self.custom_message) + self.assertEqual(detail.code, self.custom_code) class PermissionsCompositionTests(TestCase): diff --git a/tests/test_renderers.py b/tests/test_renderers.py index c79c0a766..71c5fb3f6 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -741,6 +741,11 @@ class AdminRendererTests(TestCase): class DummyGenericViewsetLike(APIView): lookup_field = 'test' + def get(self, request): + response = Response() + response.view = self + return response + def reverse_action(view, *args, **kwargs): self.assertEqual(kwargs['kwargs']['test'], 1) return '/example/' @@ -749,7 +754,7 @@ class AdminRendererTests(TestCase): view = DummyGenericViewsetLike.as_view() request = factory.get('/') response = view(request) - view = response.renderer_context['view'] + view = response.view self.assertEqual(self.renderer.get_result_url({'test': 1}, view), '/example/') self.assertIsNone(self.renderer.get_result_url({}, view)) @@ -760,11 +765,16 @@ class AdminRendererTests(TestCase): class DummyView(APIView): lookup_field = 'test' + def get(self, request): + response = Response() + response.view = self + return response + # get the view instance instead of the view function view = DummyView.as_view() request = factory.get('/') response = view(request) - view = response.renderer_context['view'] + view = response.view self.assertIsNone(self.renderer.get_result_url({'test': 1}, view)) self.assertIsNone(self.renderer.get_result_url({}, view)) diff --git a/tests/test_request.py b/tests/test_request.py index 0f682deb0..be84fe5f9 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -137,7 +137,9 @@ class MockView(APIView): class EchoView(APIView): def post(self, request): - return Response(status=status.HTTP_200_OK, data=request.data) + response = Response(status=status.HTTP_200_OK, data=request.data) + response._request = request # test client sets `request` input + return response class FileUploadView(APIView): @@ -272,6 +274,12 @@ class TestSecure(TestCase): class TestHttpRequest(TestCase): + def test_repr(self): + http_request = factory.get('/path') + request = Request(http_request) + + assert repr(request) == "" + def test_attribute_access_proxy(self): http_request = factory.get('/') request = Request(http_request) @@ -300,7 +308,7 @@ class TestHttpRequest(TestCase): `RawPostDataException` being raised. """ response = APIClient().post('/echo/', data={'a': 'b'}, format='json') - request = response.renderer_context['request'] + request = response._request # ensure that request stream was consumed by json parser assert request.content_type.startswith('application/json') @@ -319,7 +327,7 @@ class TestHttpRequest(TestCase): the duplicate stream parse exception. """ response = APIClient().post('/echo/', data={'a': 'b'}) - request = response.renderer_context['request'] + request = response._request # ensure that request stream was consumed by form parser assert request.content_type.startswith('multipart/form-data') @@ -327,7 +335,7 @@ class TestHttpRequest(TestCase): # pass same HttpRequest to view, form data set on underlying request response = EchoView.as_view()(request._request) - request = response.renderer_context['request'] + request = response._request # ensure that request stream was consumed by form parser assert request.content_type.startswith('multipart/form-data') diff --git a/tests/test_validators.py b/tests/test_validators.py index 21c00073d..4962cf581 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -344,6 +344,49 @@ class TestUniquenessTogetherValidation(TestCase): ] } + def test_default_validator_with_fields_with_source(self): + class TestSerializer(serializers.ModelSerializer): + name = serializers.CharField(source='race_name') + + class Meta: + model = UniquenessTogetherModel + fields = ['name', 'position'] + + serializer = TestSerializer() + expected = dedent(""" + TestSerializer(): + name = CharField(source='race_name') + position = IntegerField() + class Meta: + validators = [] + """) + assert repr(serializer) == expected + + def test_default_validator_with_multiple_fields_with_same_source(self): + class TestSerializer(serializers.ModelSerializer): + name = serializers.CharField(source='race_name') + other_name = serializers.CharField(source='race_name') + + class Meta: + model = UniquenessTogetherModel + fields = ['name', 'other_name', 'position'] + + serializer = TestSerializer(data={ + 'name': 'foo', + 'other_name': 'foo', + 'position': 1, + }) + with pytest.raises(AssertionError) as excinfo: + serializer.is_valid() + + expected = ( + "Unable to create `UniqueTogetherValidator` for " + "`UniquenessTogetherModel.race_name` as `TestSerializer` has " + "multiple fields (name, other_name) that map to this model field. " + "Either remove the extra fields, or override `Meta.validators` " + "with a `UniqueTogetherValidator` using the desired field names.") + assert str(excinfo.value) == expected + def test_allow_explict_override(self): """ Ensure validators can be explicitly removed.. diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index eac36f095..465806a09 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -37,14 +37,20 @@ class ActionViewSet(GenericViewSet): queryset = Action.objects.all() def list(self, request, *args, **kwargs): - return Response() + response = Response() + response.view = self + return response def retrieve(self, request, *args, **kwargs): - return Response() + response = Response() + response.view = self + return response @action(detail=False) def list_action(self, request, *args, **kwargs): - raise NotImplementedError + response = Response() + response.view = self + return response @action(detail=False, url_name='list-custom') def custom_list_action(self, request, *args, **kwargs): @@ -66,7 +72,9 @@ class ActionViewSet(GenericViewSet): class ActionNamesViewSet(GenericViewSet): def retrieve(self, request, *args, **kwargs): - return Response() + response = Response() + response.view = self + return response @action(detail=True) def unnamed_action(self, request, *args, **kwargs): @@ -81,10 +89,20 @@ class ActionNamesViewSet(GenericViewSet): raise NotImplementedError +class ThingWithMapping: + def __init__(self): + self.mapping = {} + + +class ActionViewSetWithMapping(ActionViewSet): + mapper = ThingWithMapping() + + router = SimpleRouter() router.register(r'actions', ActionViewSet) router.register(r'actions-alt', ActionViewSet, basename='actions-alt') router.register(r'names', ActionNamesViewSet, basename='names') +router.register(r'mapping', ActionViewSetWithMapping, basename='mapping') urlpatterns = [ @@ -103,7 +121,7 @@ class InitializeViewSetsTestCase(TestCase): assert response.status_code == status.HTTP_200_OK assert response.data == {'ACTION': 'LIST'} - def testhead_request_against_viewset(self): + def test_head_request_against_viewset(self): request = factory.head('/', '', content_type='application/json') my_view = BasicViewSet.as_view(actions={ 'get': 'list', @@ -145,6 +163,22 @@ class InitializeViewSetsTestCase(TestCase): self.assertNotIn(attribute, dir(bare_view)) self.assertIn(attribute, dir(view)) + def test_viewset_action_attr(self): + view = ActionViewSet.as_view(actions={'get': 'list'}) + + get = view(factory.get('/')) + head = view(factory.head('/')) + assert get.view.action == 'list' + assert head.view.action == 'list' + + def test_viewset_action_attr_for_extra_action(self): + view = ActionViewSet.as_view(actions=dict(ActionViewSet.list_action.mapping)) + + get = view(factory.get('/')) + head = view(factory.head('/')) + assert get.view.action == 'list_action' + assert head.view.action == 'list_action' + class GetExtraActionsTests(TestCase): @@ -161,13 +195,25 @@ class GetExtraActionsTests(TestCase): self.assertEqual(actual, expected) + def test_should_only_return_decorated_methods(self): + view = ActionViewSetWithMapping() + actual = [action.__name__ for action in view.get_extra_actions()] + expected = [ + 'custom_detail_action', + 'custom_list_action', + 'detail_action', + 'list_action', + 'unresolvable_detail_action', + ] + self.assertEqual(actual, expected) + @override_settings(ROOT_URLCONF='tests.test_viewsets') class GetExtraActionUrlMapTests(TestCase): def test_list_view(self): response = self.client.get('/api/actions/') - view = response.renderer_context['view'] + view = response.view expected = OrderedDict([ ('Custom list action', 'http://testserver/api/actions/custom_list_action/'), @@ -178,7 +224,7 @@ class GetExtraActionUrlMapTests(TestCase): def test_detail_view(self): response = self.client.get('/api/actions/1/') - view = response.renderer_context['view'] + view = response.view expected = OrderedDict([ ('Custom detail action', 'http://testserver/api/actions/1/custom_detail_action/'), @@ -194,7 +240,7 @@ class GetExtraActionUrlMapTests(TestCase): def test_action_names(self): # Action 'name' and 'suffix' kwargs should be respected response = self.client.get('/api/names/1/') - view = response.renderer_context['view'] + view = response.view expected = OrderedDict([ ('Custom Name', 'http://testserver/api/names/1/named_action/'), diff --git a/tox.ini b/tox.ini index 9b8069174..e5b8b6402 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,12 @@ [tox] envlist = - {py35,py36}-django111, - {py35,py36,py37}-django20, - {py35,py36,py37}-django21 - {py35,py36,py37}-django22 + {py35,py36,py37}-django22, {py36,py37,py38}-django30, {py36,py37,py38}-djangomaster, base,dist,lint,docs, [travis:env] DJANGO = - 1.11: django111 - 2.0: django20 - 2.1: django21 2.2: django22 3.0: django30 master: djangomaster @@ -24,9 +18,6 @@ setenv = PYTHONDONTWRITEBYTECODE=1 PYTHONWARNINGS=once deps = - django111: Django>=1.11,<2.0 - django20: Django>=2.0,<2.1 - django21: Django>=2.1,<2.2 django22: Django>=2.2,<3.0 django30: Django>=3.0,<3.1 djangomaster: https://github.com/django/django/archive/master.tar.gz