mirror of
https://github.com/encode/django-rest-framework.git
synced 2025-07-16 19:22:24 +03:00
Merge branch 'master' into math-a3k-315_rn
This commit is contained in:
commit
79cc4a6391
13
.github/dependabot.yml
vendored
Normal file
13
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# Keep GitHub Actions up to date with GitHub's Dependabot...
|
||||||
|
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
|
||||||
|
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns:
|
||||||
|
- "*" # Group all Action updates into a single larger pull request
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
7
.github/workflows/main.yml
vendored
7
.github/workflows/main.yml
vendored
|
@ -20,11 +20,12 @@ jobs:
|
||||||
- '3.9'
|
- '3.9'
|
||||||
- '3.10'
|
- '3.10'
|
||||||
- '3.11'
|
- '3.11'
|
||||||
|
- '3.12'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
@ -61,9 +62,9 @@ jobs:
|
||||||
name: Test documentation links
|
name: Test documentation links
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.9'
|
python-version: '3.9'
|
||||||
|
|
||||||
|
|
8
.github/workflows/pre-commit.yml
vendored
8
.github/workflows/pre-commit.yml
vendored
|
@ -11,14 +11,12 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
|
|
||||||
- uses: pre-commit/action@v3.0.0
|
- uses: pre-commit/action@v3.0.1
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v3.4.0
|
rev: v4.5.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
|
@ -9,19 +9,25 @@ repos:
|
||||||
- id: check-symlinks
|
- id: check-symlinks
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
- repo: https://github.com/pycqa/isort
|
- repo: https://github.com/pycqa/isort
|
||||||
rev: 5.12.0
|
rev: 5.13.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: isort
|
||||||
- repo: https://github.com/PyCQA/flake8
|
- repo: https://github.com/PyCQA/flake8
|
||||||
rev: 3.9.0
|
rev: 7.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- flake8-tidy-imports
|
- flake8-tidy-imports
|
||||||
- repo: https://github.com/adamchainz/blacken-docs
|
- repo: https://github.com/adamchainz/blacken-docs
|
||||||
rev: 1.13.0
|
rev: 1.16.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: blacken-docs
|
- id: blacken-docs
|
||||||
exclude: ^(?!docs).*$
|
exclude: ^(?!docs).*$
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- black==23.1.0
|
- black==23.1.0
|
||||||
|
- repo: https://github.com/codespell-project/codespell
|
||||||
|
# Configuration for codespell is in .codespellrc
|
||||||
|
rev: v2.2.6
|
||||||
|
hooks:
|
||||||
|
- id: codespell
|
||||||
|
exclude: locale|kickstarter-announcement.md|coreapi-0.1.1.js
|
||||||
|
|
|
@ -56,7 +56,7 @@ There is a live example API for testing purposes, [available here][sandbox].
|
||||||
# Requirements
|
# Requirements
|
||||||
|
|
||||||
* Python 3.6+
|
* Python 3.6+
|
||||||
* Django 5.0, 4.2, 4.1, 4.0, 3.2, 3.1, 3.0.
|
* Django 5.0, 4.2, 4.1, 4.0, 3.2, 3.1, 3.0
|
||||||
|
|
||||||
We **highly recommend** and only officially support the latest patch release of
|
We **highly recommend** and only officially support the latest patch release of
|
||||||
each Python and Django series.
|
each Python and Django series.
|
||||||
|
|
|
@ -303,7 +303,7 @@ Corresponds to `django.db.models.fields.DecimalField`.
|
||||||
* `min_value` Validate that the number provided is no less than this value.
|
* `min_value` Validate that the number provided is no less than this value.
|
||||||
* `localize` Set to `True` to enable localization of input and output based on the current locale. This will also force `coerce_to_string` to `True`. Defaults to `False`. Note that data formatting is enabled if you have set `USE_L10N=True` in your settings file.
|
* `localize` Set to `True` to enable localization of input and output based on the current locale. This will also force `coerce_to_string` to `True`. Defaults to `False`. Note that data formatting is enabled if you have set `USE_L10N=True` in your settings file.
|
||||||
* `rounding` Sets the rounding mode used when quantizing to the configured precision. Valid values are [`decimal` module rounding modes][python-decimal-rounding-modes]. Defaults to `None`.
|
* `rounding` Sets the rounding mode used when quantizing to the configured precision. Valid values are [`decimal` module rounding modes][python-decimal-rounding-modes]. Defaults to `None`.
|
||||||
* `normalize_output` Will normalize the decimal value when serialized. This will strip all trailing zeroes and change the value's precision to the minimum required precision to be able to represent the value without loosing data. Defaults to `False`.
|
* `normalize_output` Will normalize the decimal value when serialized. This will strip all trailing zeroes and change the value's precision to the minimum required precision to be able to represent the value without losing data. Defaults to `False`.
|
||||||
|
|
||||||
#### Example usage
|
#### Example usage
|
||||||
|
|
||||||
|
|
|
@ -173,11 +173,9 @@ If you want the date field to be entirely hidden from the user, then use `Hidden
|
||||||
# Advanced field defaults
|
# Advanced field defaults
|
||||||
|
|
||||||
Validators that are applied across multiple fields in the serializer can sometimes require a field input that should not be provided by the API client, but that *is* available as input to the validator.
|
Validators that are applied across multiple fields in the serializer can sometimes require a field input that should not be provided by the API client, but that *is* available as input to the validator.
|
||||||
|
For this purposes use `HiddenField`. This field will be present in `validated_data` but *will not* be used in the serializer output representation.
|
||||||
|
|
||||||
Two patterns that you may want to use for this sort of validation include:
|
**Note:** Using a `read_only=True` field is excluded from writable fields so it won't use a `default=…` argument. Look [3.8 announcement](https://www.django-rest-framework.org/community/3.8-announcement/#altered-the-behaviour-of-read_only-plus-default-on-field).
|
||||||
|
|
||||||
* Using `HiddenField`. This field will be present in `validated_data` but *will not* be used in the serializer output representation.
|
|
||||||
* Using a standard field with `read_only=True`, but that also includes a `default=…` argument. This field *will* be used in the serializer output representation, but cannot be set directly by the user.
|
|
||||||
|
|
||||||
REST framework includes a couple of defaults that may be useful in this context.
|
REST framework includes a couple of defaults that may be useful in this context.
|
||||||
|
|
||||||
|
@ -189,7 +187,7 @@ A default class that can be used to represent the current user. In order to use
|
||||||
default=serializers.CurrentUserDefault()
|
default=serializers.CurrentUserDefault()
|
||||||
)
|
)
|
||||||
|
|
||||||
#### CreateOnlyDefault
|
#### CreateOnlyDefault
|
||||||
|
|
||||||
A default class that can be used to *only set a default argument during create operations*. During updates the field is omitted.
|
A default class that can be used to *only set a default argument during create operations*. During updates the field is omitted.
|
||||||
|
|
||||||
|
|
|
@ -311,7 +311,7 @@ You may need to provide custom `ViewSet` classes that do not have the full set o
|
||||||
|
|
||||||
To create a base viewset class that provides `create`, `list` and `retrieve` operations, inherit from `GenericViewSet`, and mixin the required actions:
|
To create a base viewset class that provides `create`, `list` and `retrieve` operations, inherit from `GenericViewSet`, and mixin the required actions:
|
||||||
|
|
||||||
from rest_framework import mixins
|
from rest_framework import mixins, viewsets
|
||||||
|
|
||||||
class CreateListRetrieveViewSet(mixins.CreateModelMixin,
|
class CreateListRetrieveViewSet(mixins.CreateModelMixin,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
|
|
|
@ -64,7 +64,7 @@ In some circumstances a Validator class or a Default class may need to access th
|
||||||
* Uniqueness validators need to be able to determine the name of the field to which they are applied, in order to run an appropriate database query.
|
* Uniqueness validators need to be able to determine the name of the field to which they are applied, in order to run an appropriate database query.
|
||||||
* The `CurrentUserDefault` needs to be able to determine the context with which the serializer was instantiated, in order to return the current user instance.
|
* The `CurrentUserDefault` needs to be able to determine the context with which the serializer was instantiated, in order to return the current user instance.
|
||||||
|
|
||||||
Previous our approach to this was that implementations could include a `set_context` method, which would be called prior to validation. However this approach had issues with potential race conditions. We have now move this approach into a pending deprecation state. It will continue to function, but will be escalated to a deprecated state in 3.12, and removed entirely in 3.13.
|
Our previous approach to this was that implementations could include a `set_context` method, which would be called prior to validation. However this approach had issues with potential race conditions. We have now move this approach into a pending deprecation state. It will continue to function, but will be escalated to a deprecated state in 3.12, and removed entirely in 3.13.
|
||||||
|
|
||||||
Instead, validators or defaults which require the serializer context, should include a `requires_context = True` attribute on the class.
|
Instead, validators or defaults which require the serializer context, should include a `requires_context = True` attribute on the class.
|
||||||
|
|
||||||
|
|
|
@ -28,10 +28,10 @@ Our requirements are now:
|
||||||
* Python 3.6+
|
* Python 3.6+
|
||||||
* Django 4.1, 4.0, 3.2, 3.1, 3.0
|
* Django 4.1, 4.0, 3.2, 3.1, 3.0
|
||||||
|
|
||||||
## `raise_exceptions` argument for `is_valid` is now keyword-only.
|
## `raise_exception` argument for `is_valid` is now keyword-only.
|
||||||
|
|
||||||
Calling `serializer_instance.is_valid(True)` is no longer acceptable syntax.
|
Calling `serializer_instance.is_valid(True)` is no longer acceptable syntax.
|
||||||
If you'd like to use the `raise_exceptions` argument, you must use it as a
|
If you'd like to use the `raise_exception` argument, you must use it as a
|
||||||
keyword argument.
|
keyword argument.
|
||||||
|
|
||||||
See Pull Request [#7952](https://github.com/encode/django-rest-framework/pull/7952) for more details.
|
See Pull Request [#7952](https://github.com/encode/django-rest-framework/pull/7952) for more details.
|
||||||
|
|
|
@ -64,14 +64,10 @@ from rest_framework.schemas import get_schema_view
|
||||||
from rest_framework_swagger.renderers import OpenAPIRenderer, SwaggerUIRenderer
|
from rest_framework_swagger.renderers import OpenAPIRenderer, SwaggerUIRenderer
|
||||||
|
|
||||||
schema_view = get_schema_view(
|
schema_view = get_schema_view(
|
||||||
title='Example API',
|
title="Example API", renderer_classes=[OpenAPIRenderer, SwaggerUIRenderer]
|
||||||
renderer_classes=[OpenAPIRenderer, SwaggerUIRenderer]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [path("swagger/", schema_view), ...]
|
||||||
path('swagger/', schema_view),
|
|
||||||
...
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
There have been a large number of fixes to the schema generation. These should
|
There have been a large number of fixes to the schema generation. These should
|
||||||
|
|
|
@ -131,7 +131,7 @@ Date: 22nd September 2022
|
||||||
* Stop calling `set_context` on Validators. [[#8589](https://github.com/encode/django-rest-framework/pull/8589)]
|
* Stop calling `set_context` on Validators. [[#8589](https://github.com/encode/django-rest-framework/pull/8589)]
|
||||||
* Return `NotImplemented` from `ErrorDetails.__ne__`. [[#8538](https://github.com/encode/django-rest-framework/pull/8538)]
|
* Return `NotImplemented` from `ErrorDetails.__ne__`. [[#8538](https://github.com/encode/django-rest-framework/pull/8538)]
|
||||||
* Don't evaluate `DateTimeField.default_timezone` when a custom timezone is set. [[#8531](https://github.com/encode/django-rest-framework/pull/8531)]
|
* Don't evaluate `DateTimeField.default_timezone` when a custom timezone is set. [[#8531](https://github.com/encode/django-rest-framework/pull/8531)]
|
||||||
* Make relative URLs clickable in Browseable API. [[#8464](https://github.com/encode/django-rest-framework/pull/8464)]
|
* Make relative URLs clickable in Browsable API. [[#8464](https://github.com/encode/django-rest-framework/pull/8464)]
|
||||||
* Support `ManyRelatedField` falling back to the default value when the attribute specified by dot notation doesn't exist. Matches `ManyRelatedField.get_attribute` to `Field.get_attribute`. [[#7574](https://github.com/encode/django-rest-framework/pull/7574)]
|
* Support `ManyRelatedField` falling back to the default value when the attribute specified by dot notation doesn't exist. Matches `ManyRelatedField.get_attribute` to `Field.get_attribute`. [[#7574](https://github.com/encode/django-rest-framework/pull/7574)]
|
||||||
* Make `schemas.openapi.get_reference` public. [[#7515](https://github.com/encode/django-rest-framework/pull/7515)]
|
* Make `schemas.openapi.get_reference` public. [[#7515](https://github.com/encode/django-rest-framework/pull/7515)]
|
||||||
* Make `ReturnDict` support `dict` union operators on Python 3.9 and later. [[#8302](https://github.com/encode/django-rest-framework/pull/8302)]
|
* Make `ReturnDict` support `dict` union operators on Python 3.9 and later. [[#8302](https://github.com/encode/django-rest-framework/pull/8302)]
|
||||||
|
@ -149,7 +149,7 @@ Date: 15th December 2021
|
||||||
|
|
||||||
Date: 13th December 2021
|
Date: 13th December 2021
|
||||||
|
|
||||||
* Django 4.0 compatability. [#8178]
|
* Django 4.0 compatibility. [#8178]
|
||||||
* Add `max_length` and `min_length` options to `ListSerializer`. [#8165]
|
* Add `max_length` and `min_length` options to `ListSerializer`. [#8165]
|
||||||
* Add `get_request_serializer` and `get_response_serializer` hooks to `AutoSchema`. [#7424]
|
* Add `get_request_serializer` and `get_response_serializer` hooks to `AutoSchema`. [#7424]
|
||||||
* Fix OpenAPI representation of null-able read only fields. [#8116]
|
* Fix OpenAPI representation of null-able read only fields. [#8116]
|
||||||
|
@ -1030,7 +1030,7 @@ See the [release announcement][3.6-release].
|
||||||
* description.py codes and tests removal. ([#4153][gh4153])
|
* description.py codes and tests removal. ([#4153][gh4153])
|
||||||
* Wrap guardian.VERSION in tuple. ([#4149][gh4149])
|
* Wrap guardian.VERSION in tuple. ([#4149][gh4149])
|
||||||
* Refine validator for fields with <source=> kwargs. ([#4146][gh4146])
|
* Refine validator for fields with <source=> kwargs. ([#4146][gh4146])
|
||||||
* Fix None values representation in childs of ListField, DictField. ([#4118][gh4118])
|
* Fix None values representation in children of ListField, DictField. ([#4118][gh4118])
|
||||||
* Resolve TimeField representation for midnight value. ([#4107][gh4107])
|
* Resolve TimeField representation for midnight value. ([#4107][gh4107])
|
||||||
* Set proper status code in AdminRenderer for the redirection after POST/DELETE requests. ([#4106][gh4106])
|
* Set proper status code in AdminRenderer for the redirection after POST/DELETE requests. ([#4106][gh4106])
|
||||||
* TimeField render returns None instead of 00:00:00. ([#4105][gh4105])
|
* TimeField render returns None instead of 00:00:00. ([#4105][gh4105])
|
||||||
|
@ -1038,7 +1038,7 @@ See the [release announcement][3.6-release].
|
||||||
* Prevent raising exception when limit is 0. ([#4098][gh4098])
|
* Prevent raising exception when limit is 0. ([#4098][gh4098])
|
||||||
* TokenAuthentication: Allow custom keyword in the header. ([#4097][gh4097])
|
* TokenAuthentication: Allow custom keyword in the header. ([#4097][gh4097])
|
||||||
* Handle incorrectly padded HTTP basic auth header. ([#4090][gh4090])
|
* Handle incorrectly padded HTTP basic auth header. ([#4090][gh4090])
|
||||||
* LimitOffset pagination crashes Browseable API when limit=0. ([#4079][gh4079])
|
* LimitOffset pagination crashes Browsable API when limit=0. ([#4079][gh4079])
|
||||||
* Fixed DecimalField arbitrary precision support. ([#4075][gh4075])
|
* Fixed DecimalField arbitrary precision support. ([#4075][gh4075])
|
||||||
* Added support for custom CSRF cookie names. ([#4049][gh4049])
|
* Added support for custom CSRF cookie names. ([#4049][gh4049])
|
||||||
* Fix regression introduced by #4035. ([#4041][gh4041])
|
* Fix regression introduced by #4035. ([#4041][gh4041])
|
||||||
|
|
|
@ -152,6 +152,11 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque
|
||||||
* [drf-standardized-errors][drf-standardized-errors] - DRF exception handler to standardize error responses for all API endpoints.
|
* [drf-standardized-errors][drf-standardized-errors] - DRF exception handler to standardize error responses for all API endpoints.
|
||||||
* [drf-api-action][drf-api-action] - uses the power of DRF also as a library functions
|
* [drf-api-action][drf-api-action] - uses the power of DRF also as a library functions
|
||||||
|
|
||||||
|
### Customization
|
||||||
|
|
||||||
|
* [drf-redesign][drf-redesign] - A project that gives a fresh look to the browse-able API using Bootstrap 5.
|
||||||
|
* [drf-material][drf-material] - A project that gives a sleek and elegant look to the browsable API using Material Design.
|
||||||
|
|
||||||
[cite]: http://www.software-ecosystems.com/Software_Ecosystems/Ecosystems.html
|
[cite]: http://www.software-ecosystems.com/Software_Ecosystems/Ecosystems.html
|
||||||
[cookiecutter]: https://github.com/jpadilla/cookiecutter-django-rest-framework
|
[cookiecutter]: https://github.com/jpadilla/cookiecutter-django-rest-framework
|
||||||
[new-repo]: https://github.com/new
|
[new-repo]: https://github.com/new
|
||||||
|
@ -243,3 +248,5 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque
|
||||||
[django-requestlogs]: https://github.com/Raekkeri/django-requestlogs
|
[django-requestlogs]: https://github.com/Raekkeri/django-requestlogs
|
||||||
[drf-standardized-errors]: https://github.com/ghazi-git/drf-standardized-errors
|
[drf-standardized-errors]: https://github.com/ghazi-git/drf-standardized-errors
|
||||||
[drf-api-action]: https://github.com/Ori-Roza/drf-api-action
|
[drf-api-action]: https://github.com/Ori-Roza/drf-api-action
|
||||||
|
[drf-redesign]: https://github.com/youzarsiph/drf-redesign
|
||||||
|
[drf-material]: https://github.com/youzarsiph/drf-material
|
||||||
|
|
BIN
docs/img/rfm.png
Normal file
BIN
docs/img/rfm.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 134 KiB |
BIN
docs/img/rfr.png
Normal file
BIN
docs/img/rfr.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 142 KiB |
|
@ -87,7 +87,7 @@ continued development by **[signing up for a paid plan][funding]**.
|
||||||
REST framework requires the following:
|
REST framework requires the following:
|
||||||
|
|
||||||
* Python (3.6, 3.7, 3.8, 3.9, 3.10, 3.11)
|
* Python (3.6, 3.7, 3.8, 3.9, 3.10, 3.11)
|
||||||
* Django (3.0, 3.1, 3.2, 4.0, 4.1, 4.2)
|
* Django (3.0, 3.1, 3.2, 4.0, 4.1, 4.2, 5.0)
|
||||||
|
|
||||||
We **highly recommend** and only officially support the latest patch release of
|
We **highly recommend** and only officially support the latest patch release of
|
||||||
each Python and Django series.
|
each Python and Django series.
|
||||||
|
|
|
@ -15,6 +15,18 @@ If you include fully-qualified URLs in your resource output, they will be 'urliz
|
||||||
|
|
||||||
By default, the API will return the format specified by the headers, which in the case of the browser is HTML. The format can be specified using `?format=` in the request, so you can look at the raw JSON response in a browser by adding `?format=json` to the URL. There are helpful extensions for viewing JSON in [Firefox][ffjsonview] and [Chrome][chromejsonview].
|
By default, the API will return the format specified by the headers, which in the case of the browser is HTML. The format can be specified using `?format=` in the request, so you can look at the raw JSON response in a browser by adding `?format=json` to the URL. There are helpful extensions for viewing JSON in [Firefox][ffjsonview] and [Chrome][chromejsonview].
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
To quickly add authentication to the browesable api, add a routes named `"login"` and `"logout"` under the namespace `"rest_framework"`. DRF provides default routes for this which you can add to your urlconf:
|
||||||
|
|
||||||
|
```python
|
||||||
|
urlpatterns = [
|
||||||
|
# ...
|
||||||
|
url(r"^api-auth/", include("rest_framework.urls", namespace="rest_framework"))
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Customizing
|
## Customizing
|
||||||
|
|
||||||
The browsable API is built with [Twitter's Bootstrap][bootstrap] (v 3.4.1), making it easy to customize the look-and-feel.
|
The browsable API is built with [Twitter's Bootstrap][bootstrap] (v 3.4.1), making it easy to customize the look-and-feel.
|
||||||
|
@ -65,6 +77,27 @@ For more specific CSS tweaks than simply overriding the default bootstrap theme
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Third party packages for customization
|
||||||
|
|
||||||
|
You can use a third party package for customization, rather than doing it by yourself. Here is 2 packages for customizing the API:
|
||||||
|
|
||||||
|
* [rest-framework-redesign][rest-framework-redesign] - A package for customizing the API using Bootstrap 5. Modern and sleek design, it comes with the support for dark mode.
|
||||||
|
* [rest-framework-material][rest-framework-material] - Material design for Django REST Framework.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
![Django REST Framework Redesign][rfr]
|
||||||
|
|
||||||
|
*Screenshot of the rest-framework-redesign*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
![Django REST Framework Material][rfm]
|
||||||
|
|
||||||
|
*Screenshot of the rest-framework-material*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Blocks
|
### Blocks
|
||||||
|
|
||||||
All of the blocks available in the browsable API base template that can be used in your `api.html`.
|
All of the blocks available in the browsable API base template that can be used in your `api.html`.
|
||||||
|
@ -162,3 +195,7 @@ There are [a variety of packages for autocomplete widgets][autocomplete-packages
|
||||||
[bcomponentsnav]: https://getbootstrap.com/2.3.2/components.html#navbar
|
[bcomponentsnav]: https://getbootstrap.com/2.3.2/components.html#navbar
|
||||||
[autocomplete-packages]: https://www.djangopackages.com/grids/g/auto-complete/
|
[autocomplete-packages]: https://www.djangopackages.com/grids/g/auto-complete/
|
||||||
[django-autocomplete-light]: https://github.com/yourlabs/django-autocomplete-light
|
[django-autocomplete-light]: https://github.com/yourlabs/django-autocomplete-light
|
||||||
|
[rest-framework-redesign]: https://github.com/youzarsiph/rest-framework-redesign
|
||||||
|
[rest-framework-material]: https://github.com/youzarsiph/rest-framework-material
|
||||||
|
[rfr]: ../img/rfr.png
|
||||||
|
[rfm]: ../img/rfm.png
|
||||||
|
|
|
@ -10,7 +10,7 @@ A `ViewSet` class is only bound to a set of method handlers at the last moment,
|
||||||
|
|
||||||
Let's take our current set of views, and refactor them into view sets.
|
Let's take our current set of views, and refactor them into view sets.
|
||||||
|
|
||||||
First of all let's refactor our `UserList` and `UserDetail` classes into a single `UserViewSet` class. We can remove the two view classes, and replace them with a single ViewSet class:
|
First of all let's refactor our `UserList` and `UserDetail` classes into a single `UserViewSet` class. In the `snippets/views.py` file, we can remove the two view classes and replace them with a single ViewSet class:
|
||||||
|
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
|
|
@ -105,7 +105,7 @@ Right, we'd better write some views then. Open `tutorial/quickstart/views.py` a
|
||||||
"""
|
"""
|
||||||
API endpoint that allows groups to be viewed or edited.
|
API endpoint that allows groups to be viewed or edited.
|
||||||
"""
|
"""
|
||||||
queryset = Group.objects.all()
|
queryset = Group.objects.all().order_by('name')
|
||||||
serializer_class = GroupSerializer
|
serializer_class = GroupSerializer
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
@ -133,8 +133,6 @@ Okay, now let's wire up the API URLs. On to `tutorial/urls.py`...
|
||||||
path('api-auth/', include('rest_framework.urls', namespace='rest_framework'))
|
path('api-auth/', include('rest_framework.urls', namespace='rest_framework'))
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns += router.urls
|
|
||||||
|
|
||||||
Because we're using viewsets instead of views, we can automatically generate the URL conf for our API, by simply registering the viewsets with a router class.
|
Because we're using viewsets instead of views, we can automatically generate the URL conf for our API, by simply registering the viewsets with a router class.
|
||||||
|
|
||||||
Again, if we need more control over the API URLs we can simply drop down to using regular class-based views, and writing the URL conf explicitly.
|
Again, if we need more control over the API URLs we can simply drop down to using regular class-based views, and writing the URL conf explicitly.
|
||||||
|
|
|
@ -169,6 +169,21 @@ else:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if django.VERSION >= (5, 1):
|
||||||
|
# Django 5.1+: use the stock ip_address_validators function
|
||||||
|
# Note: Before Django 5.1, ip_address_validators returns a tuple containing
|
||||||
|
# 1) the list of validators and 2) the error message. Starting from
|
||||||
|
# Django 5.1 ip_address_validators only returns the list of validators
|
||||||
|
from django.core.validators import ip_address_validators
|
||||||
|
else:
|
||||||
|
# Django <= 5.1: create a compatibility shim for ip_address_validators
|
||||||
|
from django.core.validators import \
|
||||||
|
ip_address_validators as _ip_address_validators
|
||||||
|
|
||||||
|
def ip_address_validators(protocol, unpack_ipv4):
|
||||||
|
return _ip_address_validators(protocol, unpack_ipv4)[0]
|
||||||
|
|
||||||
|
|
||||||
# `separators` argument to `json.dumps()` differs between 2.x and 3.x
|
# `separators` argument to `json.dumps()` differs between 2.x and 3.x
|
||||||
# See: https://bugs.python.org/issue22767
|
# See: https://bugs.python.org/issue22767
|
||||||
SHORT_SEPARATORS = (',', ':')
|
SHORT_SEPARATORS = (',', ':')
|
||||||
|
|
|
@ -36,7 +36,7 @@ def api_view(http_method_names=None):
|
||||||
# WrappedAPIView.__doc__ = func.doc <--- Not possible to do this
|
# WrappedAPIView.__doc__ = func.doc <--- Not possible to do this
|
||||||
|
|
||||||
# api_view applied without (method_names)
|
# api_view applied without (method_names)
|
||||||
assert not(isinstance(http_method_names, types.FunctionType)), \
|
assert not isinstance(http_method_names, types.FunctionType), \
|
||||||
'@api_view missing list of allowed HTTP methods'
|
'@api_view missing list of allowed HTTP methods'
|
||||||
|
|
||||||
# api_view applied with eg. string instead of list of strings
|
# api_view applied with eg. string instead of list of strings
|
||||||
|
|
|
@ -16,7 +16,7 @@ from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.core.validators import (
|
from django.core.validators import (
|
||||||
EmailValidator, MaxLengthValidator, MaxValueValidator, MinLengthValidator,
|
EmailValidator, MaxLengthValidator, MaxValueValidator, MinLengthValidator,
|
||||||
MinValueValidator, ProhibitNullCharactersValidator, RegexValidator,
|
MinValueValidator, ProhibitNullCharactersValidator, RegexValidator,
|
||||||
URLValidator, ip_address_validators
|
URLValidator
|
||||||
)
|
)
|
||||||
from django.forms import FilePathField as DjangoFilePathField
|
from django.forms import FilePathField as DjangoFilePathField
|
||||||
from django.forms import ImageField as DjangoImageField
|
from django.forms import ImageField as DjangoImageField
|
||||||
|
@ -36,6 +36,7 @@ except ImportError:
|
||||||
pytz = None
|
pytz = None
|
||||||
|
|
||||||
from rest_framework import ISO_8601
|
from rest_framework import ISO_8601
|
||||||
|
from rest_framework.compat import ip_address_validators
|
||||||
from rest_framework.exceptions import ErrorDetail, ValidationError
|
from rest_framework.exceptions import ErrorDetail, ValidationError
|
||||||
from rest_framework.settings import api_settings
|
from rest_framework.settings import api_settings
|
||||||
from rest_framework.utils import html, humanize_datetime, json, representation
|
from rest_framework.utils import html, humanize_datetime, json, representation
|
||||||
|
@ -866,7 +867,7 @@ class IPAddressField(CharField):
|
||||||
self.protocol = protocol.lower()
|
self.protocol = protocol.lower()
|
||||||
self.unpack_ipv4 = (self.protocol == 'both')
|
self.unpack_ipv4 = (self.protocol == 'both')
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
validators, error_message = ip_address_validators(protocol, self.unpack_ipv4)
|
validators = ip_address_validators(protocol, self.unpack_ipv4)
|
||||||
self.validators.extend(validators)
|
self.validators.extend(validators)
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
|
|
|
@ -21,14 +21,14 @@ from rest_framework.settings import api_settings
|
||||||
|
|
||||||
|
|
||||||
def search_smart_split(search_terms):
|
def search_smart_split(search_terms):
|
||||||
"""generator that first splits string by spaces, leaving quoted phrases togheter,
|
"""generator that first splits string by spaces, leaving quoted phrases together,
|
||||||
then it splits non-quoted phrases by commas.
|
then it splits non-quoted phrases by commas.
|
||||||
"""
|
"""
|
||||||
for term in smart_split(search_terms):
|
for term in smart_split(search_terms):
|
||||||
# trim commas to avoid bad matching for quoted phrases
|
# trim commas to avoid bad matching for quoted phrases
|
||||||
term = term.strip(',')
|
term = term.strip(',')
|
||||||
if term.startswith(('"', "'")) and term[0] == term[-1]:
|
if term.startswith(('"', "'")) and term[0] == term[-1]:
|
||||||
# quoted phrases are kept togheter without any other split
|
# quoted phrases are kept together without any other split
|
||||||
yield unescape_string_literal(term)
|
yield unescape_string_literal(term)
|
||||||
else:
|
else:
|
||||||
# non-quoted tokens are split by comma, keeping only non-empty ones
|
# non-quoted tokens are split by comma, keeping only non-empty ones
|
||||||
|
|
|
@ -102,12 +102,12 @@ class EndpointEnumerator:
|
||||||
Given a URL conf regex, return a URI template string.
|
Given a URL conf regex, return a URI template string.
|
||||||
"""
|
"""
|
||||||
# ???: Would it be feasible to adjust this such that we generate the
|
# ???: Would it be feasible to adjust this such that we generate the
|
||||||
# path, plus the kwargs, plus the type from the convertor, such that we
|
# path, plus the kwargs, plus the type from the converter, such that we
|
||||||
# could feed that straight into the parameter schema object?
|
# could feed that straight into the parameter schema object?
|
||||||
|
|
||||||
path = simplify_regex(path_regex)
|
path = simplify_regex(path_regex)
|
||||||
|
|
||||||
# Strip Django 2.0 convertors as they are incompatible with uritemplate format
|
# Strip Django 2.0 converters as they are incompatible with uritemplate format
|
||||||
return re.sub(_PATH_PARAMETER_COMPONENT_RE, r'{\g<parameter>}', path)
|
return re.sub(_PATH_PARAMETER_COMPONENT_RE, r'{\g<parameter>}', path)
|
||||||
|
|
||||||
def should_include_endpoint(self, path, callback):
|
def should_include_endpoint(self, path, callback):
|
||||||
|
|
|
@ -84,7 +84,7 @@ class SchemaGenerator(BaseSchemaGenerator):
|
||||||
continue
|
continue
|
||||||
if components_schemas[k] == components[k]:
|
if components_schemas[k] == components[k]:
|
||||||
continue
|
continue
|
||||||
warnings.warn('Schema component "{}" has been overriden with a different value.'.format(k))
|
warnings.warn('Schema component "{}" has been overridden with a different value.'.format(k))
|
||||||
|
|
||||||
components_schemas.update(components)
|
components_schemas.update(components)
|
||||||
|
|
||||||
|
|
|
@ -76,6 +76,7 @@ LIST_SERIALIZER_KWARGS = (
|
||||||
'instance', 'data', 'partial', 'context', 'allow_null',
|
'instance', 'data', 'partial', 'context', 'allow_null',
|
||||||
'max_length', 'min_length'
|
'max_length', 'min_length'
|
||||||
)
|
)
|
||||||
|
LIST_SERIALIZER_KWARGS_REMOVE = ('allow_empty', 'min_length', 'max_length')
|
||||||
|
|
||||||
ALL_FIELDS = '__all__'
|
ALL_FIELDS = '__all__'
|
||||||
|
|
||||||
|
@ -145,19 +146,12 @@ class BaseSerializer(Field):
|
||||||
kwargs['child'] = cls()
|
kwargs['child'] = cls()
|
||||||
return CustomListSerializer(*args, **kwargs)
|
return CustomListSerializer(*args, **kwargs)
|
||||||
"""
|
"""
|
||||||
allow_empty = kwargs.pop('allow_empty', None)
|
list_kwargs = {}
|
||||||
max_length = kwargs.pop('max_length', None)
|
for key in LIST_SERIALIZER_KWARGS_REMOVE:
|
||||||
min_length = kwargs.pop('min_length', None)
|
value = kwargs.pop(key, None)
|
||||||
child_serializer = cls(*args, **kwargs)
|
if value is not None:
|
||||||
list_kwargs = {
|
list_kwargs[key] = value
|
||||||
'child': child_serializer,
|
list_kwargs['child'] = cls(*args, **kwargs)
|
||||||
}
|
|
||||||
if allow_empty is not None:
|
|
||||||
list_kwargs['allow_empty'] = allow_empty
|
|
||||||
if max_length is not None:
|
|
||||||
list_kwargs['max_length'] = max_length
|
|
||||||
if min_length is not None:
|
|
||||||
list_kwargs['min_length'] = min_length
|
|
||||||
list_kwargs.update({
|
list_kwargs.update({
|
||||||
key: value for key, value in kwargs.items()
|
key: value for key, value in kwargs.items()
|
||||||
if key in LIST_SERIALIZER_KWARGS
|
if key in LIST_SERIALIZER_KWARGS
|
||||||
|
@ -609,12 +603,6 @@ class ListSerializer(BaseSerializer):
|
||||||
self.min_length = kwargs.pop('min_length', None)
|
self.min_length = kwargs.pop('min_length', None)
|
||||||
assert self.child is not None, '`child` is a required argument.'
|
assert self.child is not None, '`child` is a required argument.'
|
||||||
assert not inspect.isclass(self.child), '`child` has not been instantiated.'
|
assert not inspect.isclass(self.child), '`child` has not been instantiated.'
|
||||||
|
|
||||||
instance = kwargs.get('instance', [])
|
|
||||||
data = kwargs.get('data', [])
|
|
||||||
if instance and data:
|
|
||||||
assert len(data) == len(instance), 'Data and instance should have same length'
|
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.child.bind(field_name='', parent=self)
|
self.child.bind(field_name='', parent=self)
|
||||||
|
|
||||||
|
@ -700,13 +688,7 @@ class ListSerializer(BaseSerializer):
|
||||||
ret = []
|
ret = []
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
for idx, item in enumerate(data):
|
for item in data:
|
||||||
if (
|
|
||||||
hasattr(self, 'instance')
|
|
||||||
and self.instance
|
|
||||||
and len(self.instance) > idx
|
|
||||||
):
|
|
||||||
self.child.instance = self.instance[idx]
|
|
||||||
try:
|
try:
|
||||||
validated = self.run_child_validation(item)
|
validated = self.run_child_validation(item)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
|
|
|
@ -116,7 +116,7 @@ DEFAULTS = {
|
||||||
'COERCE_DECIMAL_TO_STRING': True,
|
'COERCE_DECIMAL_TO_STRING': True,
|
||||||
'UPLOADED_FILES_USE_URL': True,
|
'UPLOADED_FILES_USE_URL': True,
|
||||||
|
|
||||||
# Browseable API
|
# Browsable API
|
||||||
'HTML_SELECT_CUTOFF': 1000,
|
'HTML_SELECT_CUTOFF': 1000,
|
||||||
'HTML_SELECT_CUTOFF_TEXT': "More than {count} items...",
|
'HTML_SELECT_CUTOFF_TEXT': "More than {count} items...",
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,12 @@ function replaceDocument(docString) {
|
||||||
|
|
||||||
doc.write(docString);
|
doc.write(docString);
|
||||||
doc.close();
|
doc.close();
|
||||||
|
|
||||||
|
if (window.djdt) {
|
||||||
|
// If Django Debug Toolbar is available, reinitialize it so that
|
||||||
|
// it can show updated panels from new `docString`.
|
||||||
|
window.addEventListener("load", djdt.init);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function doAjaxSubmit(e) {
|
function doAjaxSubmit(e) {
|
||||||
|
|
|
@ -42,7 +42,7 @@
|
||||||
<ul class="nav navbar-nav pull-right">
|
<ul class="nav navbar-nav pull-right">
|
||||||
{% block userlinks %}
|
{% block userlinks %}
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
{% optional_logout request user %}
|
{% optional_logout request user csrf_token %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% optional_login request %}
|
{% optional_login request %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -46,7 +46,7 @@
|
||||||
<ul class="nav navbar-nav pull-right">
|
<ul class="nav navbar-nav pull-right">
|
||||||
{% block userlinks %}
|
{% block userlinks %}
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
{% optional_logout request user %}
|
{% optional_logout request user csrf_token %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% optional_login request %}
|
{% optional_login request %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -119,7 +119,7 @@ def optional_docs_login(request):
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def optional_logout(request, user):
|
def optional_logout(request, user, csrf_token):
|
||||||
"""
|
"""
|
||||||
Include a logout snippet if REST framework's logout view is in the URLconf.
|
Include a logout snippet if REST framework's logout view is in the URLconf.
|
||||||
"""
|
"""
|
||||||
|
@ -135,11 +135,16 @@ def optional_logout(request, user):
|
||||||
<b class="caret"></b>
|
<b class="caret"></b>
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a href='{href}?next={next}'>Log out</a></li>
|
<form id="logoutForm" method="post" action="{href}?next={next}">
|
||||||
|
<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
|
||||||
|
</form>
|
||||||
|
<li>
|
||||||
|
<a href="#" onclick='document.getElementById("logoutForm").submit()'>Log out</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>"""
|
</li>"""
|
||||||
snippet = format_html(snippet, user=escape(user), href=logout_url, next=escape(request.path))
|
snippet = format_html(snippet, user=escape(user), href=logout_url,
|
||||||
|
next=escape(request.path), csrf_token=csrf_token)
|
||||||
return mark_safe(snippet)
|
return mark_safe(snippet)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -27,8 +27,8 @@ def smart_repr(value):
|
||||||
if isinstance(value, models.Manager):
|
if isinstance(value, models.Manager):
|
||||||
return manager_repr(value)
|
return manager_repr(value)
|
||||||
|
|
||||||
if isinstance(value, Promise) and value._delegate_text:
|
if isinstance(value, Promise):
|
||||||
value = force_str(value)
|
value = force_str(value, strings_only=True)
|
||||||
|
|
||||||
value = repr(value)
|
value = repr(value)
|
||||||
|
|
||||||
|
|
|
@ -160,10 +160,19 @@ class UniqueTogetherValidator:
|
||||||
queryset = self.exclude_current_instance(attrs, queryset, serializer.instance)
|
queryset = self.exclude_current_instance(attrs, queryset, serializer.instance)
|
||||||
|
|
||||||
# Ignore validation if any field is None
|
# Ignore validation if any field is None
|
||||||
checked_values = [
|
if serializer.instance is None:
|
||||||
value for field, value in attrs.items() if field in self.fields
|
checked_values = [
|
||||||
]
|
value for field, value in attrs.items() if field in self.fields
|
||||||
if None not in checked_values and qs_exists(queryset):
|
]
|
||||||
|
else:
|
||||||
|
# Ignore validation if all field values are unchanged
|
||||||
|
checked_values = [
|
||||||
|
value
|
||||||
|
for field, value in attrs.items()
|
||||||
|
if field in self.fields and value != getattr(serializer.instance, field)
|
||||||
|
]
|
||||||
|
|
||||||
|
if checked_values and None not in checked_values and qs_exists(queryset):
|
||||||
field_names = ', '.join(self.fields)
|
field_names = ', '.join(self.fields)
|
||||||
message = self.message.format(field_names=field_names)
|
message = self.message.format(field_names=field_names)
|
||||||
raise ValidationError(message, code='unique')
|
raise ValidationError(message, code='unique')
|
||||||
|
|
|
@ -29,3 +29,8 @@ include = rest_framework/*,tests/*
|
||||||
exclude_lines =
|
exclude_lines =
|
||||||
pragma: no cover
|
pragma: no cover
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
[codespell]
|
||||||
|
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
|
||||||
|
skip = */kickstarter-announcement.md,*.js,*.map,*.po
|
||||||
|
ignore-words-list = fo,malcom,ser
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -96,6 +96,7 @@ setup(
|
||||||
'Framework :: Django :: 4.0',
|
'Framework :: Django :: 4.0',
|
||||||
'Framework :: Django :: 4.1',
|
'Framework :: Django :: 4.1',
|
||||||
'Framework :: Django :: 4.2',
|
'Framework :: Django :: 4.2',
|
||||||
|
'Framework :: Django :: 5.0',
|
||||||
'Intended Audience :: Developers',
|
'Intended Audience :: Developers',
|
||||||
'License :: OSI Approved :: BSD License',
|
'License :: OSI Approved :: BSD License',
|
||||||
'Operating System :: OS Independent',
|
'Operating System :: OS Independent',
|
||||||
|
@ -107,6 +108,7 @@ setup(
|
||||||
'Programming Language :: Python :: 3.9',
|
'Programming Language :: Python :: 3.9',
|
||||||
'Programming Language :: Python :: 3.10',
|
'Programming Language :: Python :: 3.10',
|
||||||
'Programming Language :: Python :: 3.11',
|
'Programming Language :: Python :: 3.11',
|
||||||
|
'Programming Language :: Python :: 3.12',
|
||||||
'Programming Language :: Python :: 3 :: Only',
|
'Programming Language :: Python :: 3 :: Only',
|
||||||
'Topic :: Internet :: WWW/HTTP',
|
'Topic :: Internet :: WWW/HTTP',
|
||||||
],
|
],
|
||||||
|
|
|
@ -65,6 +65,12 @@ class DropdownWithAuthTests(TestCase):
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
assert '>Log in<' in content
|
assert '>Log in<' in content
|
||||||
|
|
||||||
|
def test_dropdown_contains_logout_form(self):
|
||||||
|
self.client.login(username=self.username, password=self.password)
|
||||||
|
response = self.client.get('/')
|
||||||
|
content = response.content.decode()
|
||||||
|
assert '<form id="logoutForm" method="post" action="/auth/logout/?next=/">' in content
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ROOT_URLCONF='tests.browsable_api.no_auth_urls')
|
@override_settings(ROOT_URLCONF='tests.browsable_api.no_auth_urls')
|
||||||
class NoDropdownWithoutAuthTests(TestCase):
|
class NoDropdownWithoutAuthTests(TestCase):
|
||||||
|
|
|
@ -8,7 +8,7 @@ from django.test import TestCase
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from rest_framework.compat import uritemplate, yaml
|
from rest_framework.compat import coreapi, uritemplate, yaml
|
||||||
from rest_framework.management.commands import generateschema
|
from rest_framework.management.commands import generateschema
|
||||||
from rest_framework.utils import formatting, json
|
from rest_framework.utils import formatting, json
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
@ -91,6 +91,7 @@ class GenerateSchemaTests(TestCase):
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
|
|
||||||
@pytest.mark.skipif(yaml is None, reason='PyYAML is required.')
|
@pytest.mark.skipif(yaml is None, reason='PyYAML is required.')
|
||||||
|
@pytest.mark.skipif(coreapi is None, reason='coreapi is required.')
|
||||||
@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema'})
|
@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema'})
|
||||||
def test_coreapi_renders_default_schema_with_custom_title_url_and_description(self):
|
def test_coreapi_renders_default_schema_with_custom_title_url_and_description(self):
|
||||||
expected_out = """info:
|
expected_out = """info:
|
||||||
|
@ -113,6 +114,7 @@ class GenerateSchemaTests(TestCase):
|
||||||
|
|
||||||
self.assertIn(formatting.dedent(expected_out), self.out.getvalue())
|
self.assertIn(formatting.dedent(expected_out), self.out.getvalue())
|
||||||
|
|
||||||
|
@pytest.mark.skipif(coreapi is None, reason='coreapi is required.')
|
||||||
@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema'})
|
@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema'})
|
||||||
def test_coreapi_renders_openapi_json_schema(self):
|
def test_coreapi_renders_openapi_json_schema(self):
|
||||||
expected_out = {
|
expected_out = {
|
||||||
|
@ -142,6 +144,7 @@ class GenerateSchemaTests(TestCase):
|
||||||
|
|
||||||
self.assertDictEqual(out_json, expected_out)
|
self.assertDictEqual(out_json, expected_out)
|
||||||
|
|
||||||
|
@pytest.mark.skipif(coreapi is None, reason='coreapi is required.')
|
||||||
@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema'})
|
@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema'})
|
||||||
def test_renders_corejson_schema(self):
|
def test_renders_corejson_schema(self):
|
||||||
expected_out = """{"_type":"document","":{"list":{"_type":"link","url":"/","action":"get"}}}"""
|
expected_out = """{"_type":"document","":{"list":{"_type":"link","url":"/","action":"get"}}}"""
|
||||||
|
|
|
@ -1347,7 +1347,7 @@ class TestGenerator(TestCase):
|
||||||
|
|
||||||
assert len(w) == 1
|
assert len(w) == 1
|
||||||
assert issubclass(w[-1].category, UserWarning)
|
assert issubclass(w[-1].category, UserWarning)
|
||||||
assert 'has been overriden with a different value.' in str(w[-1].message)
|
assert 'has been overridden with a different value.' in str(w[-1].message)
|
||||||
|
|
||||||
assert 'components' in schema
|
assert 'components' in schema
|
||||||
assert 'schemas' in schema['components']
|
assert 'schemas' in schema['components']
|
||||||
|
|
|
@ -1538,7 +1538,8 @@ class TestNoOutputFormatDateTimeField(FieldValues):
|
||||||
field = serializers.DateTimeField(format=None)
|
field = serializers.DateTimeField(format=None)
|
||||||
|
|
||||||
|
|
||||||
class TestNaiveDateTimeField(FieldValues):
|
@override_settings(TIME_ZONE='UTC', USE_TZ=False)
|
||||||
|
class TestNaiveDateTimeField(FieldValues, TestCase):
|
||||||
"""
|
"""
|
||||||
Valid and invalid values for `DateTimeField` with naive datetimes.
|
Valid and invalid values for `DateTimeField` with naive datetimes.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -8,6 +8,7 @@ an appropriate set of serializer fields for each case.
|
||||||
import datetime
|
import datetime
|
||||||
import decimal
|
import decimal
|
||||||
import json # noqa
|
import json # noqa
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
@ -169,33 +170,32 @@ class TestRegularFieldMappings(TestCase):
|
||||||
model = RegularFieldsModel
|
model = RegularFieldsModel
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
expected = dedent("""
|
expected = dedent(r"""
|
||||||
TestSerializer():
|
TestSerializer\(\):
|
||||||
auto_field = IntegerField(read_only=True)
|
auto_field = IntegerField\(read_only=True\)
|
||||||
big_integer_field = IntegerField()
|
big_integer_field = IntegerField\(.*\)
|
||||||
boolean_field = BooleanField(default=False, required=False)
|
boolean_field = BooleanField\(default=False, required=False\)
|
||||||
char_field = CharField(max_length=100)
|
char_field = CharField\(max_length=100\)
|
||||||
comma_separated_integer_field = CharField(max_length=100, validators=[<django.core.validators.RegexValidator object>])
|
comma_separated_integer_field = CharField\(max_length=100, validators=\[<django.core.validators.RegexValidator object>\]\)
|
||||||
date_field = DateField()
|
date_field = DateField\(\)
|
||||||
datetime_field = DateTimeField()
|
datetime_field = DateTimeField\(\)
|
||||||
decimal_field = DecimalField(decimal_places=1, max_digits=3)
|
decimal_field = DecimalField\(decimal_places=1, max_digits=3\)
|
||||||
email_field = EmailField(max_length=100)
|
email_field = EmailField\(max_length=100\)
|
||||||
float_field = FloatField()
|
float_field = FloatField\(\)
|
||||||
integer_field = IntegerField()
|
integer_field = IntegerField\(.*\)
|
||||||
null_boolean_field = BooleanField(allow_null=True, default=False, required=False)
|
null_boolean_field = BooleanField\(allow_null=True, default=False, required=False\)
|
||||||
positive_integer_field = IntegerField()
|
positive_integer_field = IntegerField\(.*\)
|
||||||
positive_small_integer_field = IntegerField()
|
positive_small_integer_field = IntegerField\(.*\)
|
||||||
slug_field = SlugField(allow_unicode=False, max_length=100)
|
slug_field = SlugField\(allow_unicode=False, max_length=100\)
|
||||||
small_integer_field = IntegerField()
|
small_integer_field = IntegerField\(.*\)
|
||||||
text_field = CharField(max_length=100, style={'base_template': 'textarea.html'})
|
text_field = CharField\(max_length=100, style={'base_template': 'textarea.html'}\)
|
||||||
file_field = FileField(max_length=100)
|
file_field = FileField\(max_length=100\)
|
||||||
time_field = TimeField()
|
time_field = TimeField\(\)
|
||||||
url_field = URLField(max_length=100)
|
url_field = URLField\(max_length=100\)
|
||||||
custom_field = ModelField(model_field=<tests.test_model_serializer.CustomField: custom_field>)
|
custom_field = ModelField\(model_field=<tests.test_model_serializer.CustomField: custom_field>\)
|
||||||
file_path_field = FilePathField(path=%r)
|
file_path_field = FilePathField\(path=%r\)
|
||||||
""" % tempfile.gettempdir())
|
""" % tempfile.gettempdir())
|
||||||
|
assert re.search(expected, repr(TestSerializer())) is not None
|
||||||
self.assertEqual(repr(TestSerializer()), expected)
|
|
||||||
|
|
||||||
def test_field_options(self):
|
def test_field_options(self):
|
||||||
class TestSerializer(serializers.ModelSerializer):
|
class TestSerializer(serializers.ModelSerializer):
|
||||||
|
@ -203,19 +203,19 @@ class TestRegularFieldMappings(TestCase):
|
||||||
model = FieldOptionsModel
|
model = FieldOptionsModel
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
expected = dedent("""
|
expected = dedent(r"""
|
||||||
TestSerializer():
|
TestSerializer\(\):
|
||||||
id = IntegerField(label='ID', read_only=True)
|
id = IntegerField\(label='ID', read_only=True\)
|
||||||
value_limit_field = IntegerField(max_value=10, min_value=1)
|
value_limit_field = IntegerField\(max_value=10, min_value=1\)
|
||||||
length_limit_field = CharField(max_length=12, min_length=3)
|
length_limit_field = CharField\(max_length=12, min_length=3\)
|
||||||
blank_field = CharField(allow_blank=True, max_length=10, required=False)
|
blank_field = CharField\(allow_blank=True, max_length=10, required=False\)
|
||||||
null_field = IntegerField(allow_null=True, required=False)
|
null_field = IntegerField\(allow_null=True,.*required=False\)
|
||||||
default_field = IntegerField(default=0, required=False)
|
default_field = IntegerField\(default=0,.*required=False\)
|
||||||
descriptive_field = IntegerField(help_text='Some help text', label='A label')
|
descriptive_field = IntegerField\(help_text='Some help text', label='A label'.*\)
|
||||||
choices_field = ChoiceField(choices=(('red', 'Red'), ('blue', 'Blue'), ('green', 'Green')))
|
choices_field = ChoiceField\(choices=(?:\[|\()\('red', 'Red'\), \('blue', 'Blue'\), \('green', 'Green'\)(?:\]|\))\)
|
||||||
text_choices_field = ChoiceField(choices=(('red', 'Red'), ('blue', 'Blue'), ('green', 'Green')))
|
text_choices_field = ChoiceField\(choices=(?:\[|\()\('red', 'Red'\), \('blue', 'Blue'\), \('green', 'Green'\)(?:\]|\))\)
|
||||||
""")
|
""")
|
||||||
self.assertEqual(repr(TestSerializer()), expected)
|
assert re.search(expected, repr(TestSerializer())) is not None
|
||||||
|
|
||||||
def test_nullable_boolean_field_choices(self):
|
def test_nullable_boolean_field_choices(self):
|
||||||
class NullableBooleanChoicesModel(models.Model):
|
class NullableBooleanChoicesModel(models.Model):
|
||||||
|
@ -1334,12 +1334,12 @@ class TestFieldSource(TestCase):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expected = dedent("""
|
expected = dedent(r"""
|
||||||
TestSerializer():
|
TestSerializer\(\):
|
||||||
number_field = IntegerField(source='integer_field')
|
number_field = IntegerField\(.*source='integer_field'\)
|
||||||
""")
|
""")
|
||||||
self.maxDiff = None
|
self.maxDiff = None
|
||||||
self.assertEqual(repr(TestSerializer()), expected)
|
assert re.search(expected, repr(TestSerializer())) is not None
|
||||||
|
|
||||||
|
|
||||||
class Issue6110TestModel(models.Model):
|
class Issue6110TestModel(models.Model):
|
||||||
|
|
|
@ -132,7 +132,7 @@ urlpatterns = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# TODO: Clean tests bellow - remove duplicates with above, better unit testing, ...
|
# TODO: Clean tests below - remove duplicates with above, better unit testing, ...
|
||||||
@override_settings(ROOT_URLCONF='tests.test_response')
|
@override_settings(ROOT_URLCONF='tests.test_response')
|
||||||
class RendererIntegrationTests(TestCase):
|
class RendererIntegrationTests(TestCase):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -2,7 +2,6 @@ import inspect
|
||||||
import pickle
|
import pickle
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
|
||||||
from collections import ChainMap
|
from collections import ChainMap
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
@ -784,63 +783,3 @@ class TestSetValueMethod:
|
||||||
ret = {'a': 1}
|
ret = {'a': 1}
|
||||||
self.s.set_value(ret, ['x', 'y'], 2)
|
self.s.set_value(ret, ['x', 'y'], 2)
|
||||||
assert ret == {'a': 1, 'x': {'y': 2}}
|
assert ret == {'a': 1, 'x': {'y': 2}}
|
||||||
|
|
||||||
|
|
||||||
class MyClass(models.Model):
|
|
||||||
name = models.CharField(max_length=100)
|
|
||||||
value = models.CharField(max_length=100, blank=True)
|
|
||||||
|
|
||||||
app_label = "test"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_valid(self):
|
|
||||||
return self.name == 'valid'
|
|
||||||
|
|
||||||
|
|
||||||
class MyClassSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = MyClass
|
|
||||||
fields = ('id', 'name', 'value')
|
|
||||||
|
|
||||||
def validate_value(self, value):
|
|
||||||
if value and not self.instance.is_valid:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
'Status cannot be set for invalid instance')
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class TestMultipleObjectsValidation(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.objs = [
|
|
||||||
MyClass(name='valid'),
|
|
||||||
MyClass(name='invalid'),
|
|
||||||
MyClass(name='other'),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_multiple_objects_are_validated_separately(self):
|
|
||||||
|
|
||||||
serializer = MyClassSerializer(
|
|
||||||
data=[{'value': 'set', 'id': instance.id} for instance in
|
|
||||||
self.objs],
|
|
||||||
instance=self.objs,
|
|
||||||
many=True,
|
|
||||||
partial=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not serializer.is_valid()
|
|
||||||
assert serializer.errors == [
|
|
||||||
{},
|
|
||||||
{'value': ['Status cannot be set for invalid instance']},
|
|
||||||
{'value': ['Status cannot be set for invalid instance']}
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_exception_raised_when_data_and_instance_length_different(self):
|
|
||||||
|
|
||||||
with self.assertRaises(AssertionError):
|
|
||||||
MyClassSerializer(
|
|
||||||
data=[{'value': 'set', 'id': instance.id} for instance in
|
|
||||||
self.objs],
|
|
||||||
instance=self.objs[:-1],
|
|
||||||
many=True,
|
|
||||||
partial=True,
|
|
||||||
)
|
|
||||||
|
|
|
@ -223,7 +223,7 @@ class TestNotRequiredNestedSerializerWithMany:
|
||||||
input_data = {}
|
input_data = {}
|
||||||
serializer = self.Serializer(data=input_data)
|
serializer = self.Serializer(data=input_data)
|
||||||
|
|
||||||
# request is empty, therefor 'nested' should not be in serializer.data
|
# request is empty, therefore 'nested' should not be in serializer.data
|
||||||
assert serializer.is_valid()
|
assert serializer.is_valid()
|
||||||
assert 'nested' not in serializer.validated_data
|
assert 'nested' not in serializer.validated_data
|
||||||
|
|
||||||
|
@ -237,7 +237,7 @@ class TestNotRequiredNestedSerializerWithMany:
|
||||||
input_data = QueryDict('')
|
input_data = QueryDict('')
|
||||||
serializer = self.Serializer(data=input_data)
|
serializer = self.Serializer(data=input_data)
|
||||||
|
|
||||||
# the querydict is empty, therefor 'nested' should not be in serializer.data
|
# the querydict is empty, therefore 'nested' should not be in serializer.data
|
||||||
assert serializer.is_valid()
|
assert serializer.is_valid()
|
||||||
assert 'nested' not in serializer.validated_data
|
assert 'nested' not in serializer.validated_data
|
||||||
|
|
||||||
|
|
|
@ -192,7 +192,7 @@ class ThrottlingTests(TestCase):
|
||||||
if expect is not None:
|
if expect is not None:
|
||||||
assert response['Retry-After'] == expect
|
assert response['Retry-After'] == expect
|
||||||
else:
|
else:
|
||||||
assert not'Retry-After' in response
|
assert 'Retry-After' not in response
|
||||||
|
|
||||||
def test_seconds_fields(self):
|
def test_seconds_fields(self):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import datetime
|
import datetime
|
||||||
from unittest.mock import MagicMock
|
import re
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django import VERSION as django_version
|
||||||
from django.db import DataError, models
|
from django.db import DataError, models
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
@ -112,11 +114,15 @@ class TestUniquenessValidation(TestCase):
|
||||||
def test_doesnt_pollute_model(self):
|
def test_doesnt_pollute_model(self):
|
||||||
instance = AnotherUniquenessModel.objects.create(code='100')
|
instance = AnotherUniquenessModel.objects.create(code='100')
|
||||||
serializer = AnotherUniquenessSerializer(instance)
|
serializer = AnotherUniquenessSerializer(instance)
|
||||||
assert AnotherUniquenessModel._meta.get_field('code').validators == []
|
assert all(
|
||||||
|
["Unique" not in repr(v) for v in AnotherUniquenessModel._meta.get_field('code').validators]
|
||||||
|
)
|
||||||
|
|
||||||
# Accessing data shouldn't effect validators on the model
|
# Accessing data shouldn't effect validators on the model
|
||||||
serializer.data
|
serializer.data
|
||||||
assert AnotherUniquenessModel._meta.get_field('code').validators == []
|
assert all(
|
||||||
|
["Unique" not in repr(v) for v in AnotherUniquenessModel._meta.get_field('code').validators]
|
||||||
|
)
|
||||||
|
|
||||||
def test_related_model_is_unique(self):
|
def test_related_model_is_unique(self):
|
||||||
data = {'username': 'Existing', 'email': 'new-email@example.com'}
|
data = {'username': 'Existing', 'email': 'new-email@example.com'}
|
||||||
|
@ -193,15 +199,15 @@ class TestUniquenessTogetherValidation(TestCase):
|
||||||
|
|
||||||
def test_repr(self):
|
def test_repr(self):
|
||||||
serializer = UniquenessTogetherSerializer()
|
serializer = UniquenessTogetherSerializer()
|
||||||
expected = dedent("""
|
expected = dedent(r"""
|
||||||
UniquenessTogetherSerializer():
|
UniquenessTogetherSerializer\(\):
|
||||||
id = IntegerField(label='ID', read_only=True)
|
id = IntegerField\(label='ID', read_only=True\)
|
||||||
race_name = CharField(max_length=100, required=True)
|
race_name = CharField\(max_length=100, required=True\)
|
||||||
position = IntegerField(required=True)
|
position = IntegerField\(.*required=True\)
|
||||||
class Meta:
|
class Meta:
|
||||||
validators = [<UniqueTogetherValidator(queryset=UniquenessTogetherModel.objects.all(), fields=('race_name', 'position'))>]
|
validators = \[<UniqueTogetherValidator\(queryset=UniquenessTogetherModel.objects.all\(\), fields=\('race_name', 'position'\)\)>\]
|
||||||
""")
|
""")
|
||||||
assert repr(serializer) == expected
|
assert re.search(expected, repr(serializer)) is not None
|
||||||
|
|
||||||
def test_is_not_unique_together(self):
|
def test_is_not_unique_together(self):
|
||||||
"""
|
"""
|
||||||
|
@ -282,13 +288,13 @@ class TestUniquenessTogetherValidation(TestCase):
|
||||||
read_only_fields = ('race_name',)
|
read_only_fields = ('race_name',)
|
||||||
|
|
||||||
serializer = ReadOnlyFieldSerializer()
|
serializer = ReadOnlyFieldSerializer()
|
||||||
expected = dedent("""
|
expected = dedent(r"""
|
||||||
ReadOnlyFieldSerializer():
|
ReadOnlyFieldSerializer\(\):
|
||||||
id = IntegerField(label='ID', read_only=True)
|
id = IntegerField\(label='ID', read_only=True\)
|
||||||
race_name = CharField(read_only=True)
|
race_name = CharField\(read_only=True\)
|
||||||
position = IntegerField(required=True)
|
position = IntegerField\(.*required=True\)
|
||||||
""")
|
""")
|
||||||
assert repr(serializer) == expected
|
assert re.search(expected, repr(serializer)) is not None
|
||||||
|
|
||||||
def test_read_only_fields_with_default(self):
|
def test_read_only_fields_with_default(self):
|
||||||
"""
|
"""
|
||||||
|
@ -366,14 +372,14 @@ class TestUniquenessTogetherValidation(TestCase):
|
||||||
fields = ['name', 'position']
|
fields = ['name', 'position']
|
||||||
|
|
||||||
serializer = TestSerializer()
|
serializer = TestSerializer()
|
||||||
expected = dedent("""
|
expected = dedent(r"""
|
||||||
TestSerializer():
|
TestSerializer\(\):
|
||||||
name = CharField(source='race_name')
|
name = CharField\(source='race_name'\)
|
||||||
position = IntegerField()
|
position = IntegerField\(.*\)
|
||||||
class Meta:
|
class Meta:
|
||||||
validators = [<UniqueTogetherValidator(queryset=UniquenessTogetherModel.objects.all(), fields=('name', 'position'))>]
|
validators = \[<UniqueTogetherValidator\(queryset=UniquenessTogetherModel.objects.all\(\), fields=\('name', 'position'\)\)>\]
|
||||||
""")
|
""")
|
||||||
assert repr(serializer) == expected
|
assert re.search(expected, repr(serializer)) is not None
|
||||||
|
|
||||||
def test_default_validator_with_multiple_fields_with_same_source(self):
|
def test_default_validator_with_multiple_fields_with_same_source(self):
|
||||||
class TestSerializer(serializers.ModelSerializer):
|
class TestSerializer(serializers.ModelSerializer):
|
||||||
|
@ -411,13 +417,13 @@ class TestUniquenessTogetherValidation(TestCase):
|
||||||
validators = []
|
validators = []
|
||||||
|
|
||||||
serializer = NoValidatorsSerializer()
|
serializer = NoValidatorsSerializer()
|
||||||
expected = dedent("""
|
expected = dedent(r"""
|
||||||
NoValidatorsSerializer():
|
NoValidatorsSerializer\(\):
|
||||||
id = IntegerField(label='ID', read_only=True)
|
id = IntegerField\(label='ID', read_only=True.*\)
|
||||||
race_name = CharField(max_length=100)
|
race_name = CharField\(max_length=100\)
|
||||||
position = IntegerField()
|
position = IntegerField\(.*\)
|
||||||
""")
|
""")
|
||||||
assert repr(serializer) == expected
|
assert re.search(expected, repr(serializer)) is not None
|
||||||
|
|
||||||
def test_ignore_validation_for_null_fields(self):
|
def test_ignore_validation_for_null_fields(self):
|
||||||
# None values that are on fields which are part of the uniqueness
|
# None values that are on fields which are part of the uniqueness
|
||||||
|
@ -447,6 +453,22 @@ class TestUniquenessTogetherValidation(TestCase):
|
||||||
serializer = NullUniquenessTogetherSerializer(data=data)
|
serializer = NullUniquenessTogetherSerializer(data=data)
|
||||||
assert not serializer.is_valid()
|
assert not serializer.is_valid()
|
||||||
|
|
||||||
|
def test_ignore_validation_for_unchanged_fields(self):
|
||||||
|
"""
|
||||||
|
If all fields in the unique together constraint are unchanged,
|
||||||
|
then the instance should skip uniqueness validation.
|
||||||
|
"""
|
||||||
|
instance = UniquenessTogetherModel.objects.create(
|
||||||
|
race_name="Paris Marathon", position=1
|
||||||
|
)
|
||||||
|
data = {"race_name": "Paris Marathon", "position": 1}
|
||||||
|
serializer = UniquenessTogetherSerializer(data=data, instance=instance)
|
||||||
|
with patch(
|
||||||
|
"rest_framework.validators.qs_exists"
|
||||||
|
) as mock:
|
||||||
|
assert serializer.is_valid()
|
||||||
|
assert not mock.called
|
||||||
|
|
||||||
def test_filter_queryset_do_not_skip_existing_attribute(self):
|
def test_filter_queryset_do_not_skip_existing_attribute(self):
|
||||||
"""
|
"""
|
||||||
filter_queryset should add value from existing instance attribute
|
filter_queryset should add value from existing instance attribute
|
||||||
|
@ -524,16 +546,16 @@ class TestUniqueConstraintValidation(TestCase):
|
||||||
# the order of validators isn't deterministic so delete
|
# the order of validators isn't deterministic so delete
|
||||||
# fancy_conditions field that has two of them
|
# fancy_conditions field that has two of them
|
||||||
del serializer.fields['fancy_conditions']
|
del serializer.fields['fancy_conditions']
|
||||||
expected = dedent("""
|
expected = dedent(r"""
|
||||||
UniqueConstraintSerializer():
|
UniqueConstraintSerializer\(\):
|
||||||
id = IntegerField(label='ID', read_only=True)
|
id = IntegerField\(label='ID', read_only=True\)
|
||||||
race_name = CharField(max_length=100, required=True)
|
race_name = CharField\(max_length=100, required=True\)
|
||||||
position = IntegerField(required=True)
|
position = IntegerField\(.*required=True\)
|
||||||
global_id = IntegerField(validators=[<UniqueValidator(queryset=UniqueConstraintModel.objects.all())>])
|
global_id = IntegerField\(.*validators=\[<UniqueValidator\(queryset=UniqueConstraintModel.objects.all\(\)\)>\]\)
|
||||||
class Meta:
|
class Meta:
|
||||||
validators = [<UniqueTogetherValidator(queryset=<QuerySet [<UniqueConstraintModel: UniqueConstraintModel object (1)>, <UniqueConstraintModel: UniqueConstraintModel object (2)>]>, fields=('race_name', 'position'))>]
|
validators = \[<UniqueTogetherValidator\(queryset=<QuerySet \[<UniqueConstraintModel: UniqueConstraintModel object \(1\)>, <UniqueConstraintModel: UniqueConstraintModel object \(2\)>\]>, fields=\('race_name', 'position'\)\)>\]
|
||||||
""")
|
""")
|
||||||
assert repr(serializer) == expected
|
assert re.search(expected, repr(serializer)) is not None
|
||||||
|
|
||||||
def test_unique_together_field(self):
|
def test_unique_together_field(self):
|
||||||
"""
|
"""
|
||||||
|
@ -553,15 +575,18 @@ class TestUniqueConstraintValidation(TestCase):
|
||||||
UniqueConstraint with single field must be transformed into
|
UniqueConstraint with single field must be transformed into
|
||||||
field's UniqueValidator
|
field's UniqueValidator
|
||||||
"""
|
"""
|
||||||
|
# Django 5 includes Max and Min values validators for IntergerField
|
||||||
|
extra_validators_qty = 2 if django_version[0] >= 5 else 0
|
||||||
|
#
|
||||||
serializer = UniqueConstraintSerializer()
|
serializer = UniqueConstraintSerializer()
|
||||||
assert len(serializer.validators) == 1
|
assert len(serializer.validators) == 1
|
||||||
validators = serializer.fields['global_id'].validators
|
validators = serializer.fields['global_id'].validators
|
||||||
assert len(validators) == 1
|
assert len(validators) == 1 + extra_validators_qty
|
||||||
assert validators[0].queryset == UniqueConstraintModel.objects
|
assert validators[0].queryset == UniqueConstraintModel.objects
|
||||||
|
|
||||||
validators = serializer.fields['fancy_conditions'].validators
|
validators = serializer.fields['fancy_conditions'].validators
|
||||||
assert len(validators) == 2
|
assert len(validators) == 2 + extra_validators_qty
|
||||||
ids_in_qs = {frozenset(v.queryset.values_list(flat=True)) for v in validators}
|
ids_in_qs = {frozenset(v.queryset.values_list(flat=True)) for v in validators if hasattr(v, "queryset")}
|
||||||
assert ids_in_qs == {frozenset([1]), frozenset([3])}
|
assert ids_in_qs == {frozenset([1]), frozenset([3])}
|
||||||
|
|
||||||
|
|
||||||
|
|
8
tox.ini
8
tox.ini
|
@ -4,7 +4,8 @@ envlist =
|
||||||
{py36,py37,py38,py39}-django31
|
{py36,py37,py38,py39}-django31
|
||||||
{py36,py37,py38,py39,py310}-django32
|
{py36,py37,py38,py39,py310}-django32
|
||||||
{py38,py39,py310}-{django40,django41,django42,djangomain}
|
{py38,py39,py310}-{django40,django41,django42,djangomain}
|
||||||
{py311}-{django41,django42,djangomain}
|
{py311}-{django41,django42,django50,djangomain}
|
||||||
|
{py312}-{django42,djanggo50,djangomain}
|
||||||
base
|
base
|
||||||
dist
|
dist
|
||||||
docs
|
docs
|
||||||
|
@ -22,9 +23,11 @@ deps =
|
||||||
django40: Django>=4.0,<4.1
|
django40: Django>=4.0,<4.1
|
||||||
django41: Django>=4.1,<4.2
|
django41: Django>=4.1,<4.2
|
||||||
django42: Django>=4.2,<5.0
|
django42: Django>=4.2,<5.0
|
||||||
|
django50: Django>=5.0,<5.1
|
||||||
djangomain: https://github.com/django/django/archive/main.tar.gz
|
djangomain: https://github.com/django/django/archive/main.tar.gz
|
||||||
-rrequirements/requirements-testing.txt
|
-rrequirements/requirements-testing.txt
|
||||||
-rrequirements/requirements-optionals.txt
|
-rrequirements/requirements-optionals.txt
|
||||||
|
setuptools
|
||||||
|
|
||||||
[testenv:base]
|
[testenv:base]
|
||||||
; Ensure optional dependencies are not required
|
; Ensure optional dependencies are not required
|
||||||
|
@ -57,3 +60,6 @@ ignore_outcome = true
|
||||||
|
|
||||||
[testenv:py311-djangomain]
|
[testenv:py311-djangomain]
|
||||||
ignore_outcome = true
|
ignore_outcome = true
|
||||||
|
|
||||||
|
[testenv:py312-djangomain]
|
||||||
|
ignore_outcome = true
|
||||||
|
|
Loading…
Reference in New Issue
Block a user