diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 9c29ed056..36d356493 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -8,17 +8,17 @@ on: jobs: pre-commit: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.10" - - uses: pre-commit/action@v2.0.0 + - uses: pre-commit/action@v3.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index e8375291d..c2975a418 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.6+ -* Django 4.1, 4.0, 3.2, 3.1, 3.0 +* Django 4.2, 4.1, 4.0, 3.2, 3.1, 3.0 We **highly recommend** and only officially support the latest patch release of each Python and Django series. diff --git a/docs/api-guide/permissions.md b/docs/api-guide/permissions.md index 27f7c5adb..5e0b6a153 100644 --- a/docs/api-guide/permissions.md +++ b/docs/api-guide/permissions.md @@ -165,7 +165,7 @@ This permission is suitable if you want your API to only be accessible to a subs ## IsAuthenticatedOrReadOnly -The `IsAuthenticatedOrReadOnly` will allow authenticated users to perform any request. Requests for unauthorised users will only be permitted if the request method is one of the "safe" methods; `GET`, `HEAD` or `OPTIONS`. +The `IsAuthenticatedOrReadOnly` will allow authenticated users to perform any request. Requests for unauthenticated users will only be permitted if the request method is one of the "safe" methods; `GET`, `HEAD` or `OPTIONS`. This permission is suitable if you want to your API to allow read permissions to anonymous users, and only allow write permissions to authenticated users. diff --git a/docs/api-guide/reverse.md b/docs/api-guide/reverse.md index 82dd16881..c3fa52f21 100644 --- a/docs/api-guide/reverse.md +++ b/docs/api-guide/reverse.md @@ -54,5 +54,5 @@ As with the `reverse` function, you should **include the request as a keyword ar api_root = reverse_lazy('api-root', request=request) [cite]: https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm#sec_5_1_5 -[reverse]: https://docs.djangoproject.com/en/stable/topics/http/urls/#reverse -[reverse-lazy]: https://docs.djangoproject.com/en/stable/topics/http/urls/#reverse-lazy +[reverse]: https://docs.djangoproject.com/en/stable/ref/urlresolvers/#reverse +[reverse-lazy]: https://docs.djangoproject.com/en/stable/ref/urlresolvers/#reverse-lazy diff --git a/docs/api-guide/validators.md b/docs/api-guide/validators.md index bb8466a2c..2a1e3e6b3 100644 --- a/docs/api-guide/validators.md +++ b/docs/api-guide/validators.md @@ -53,7 +53,7 @@ If we open up the Django shell using `manage.py shell` we can now The interesting bit here is the `reference` field. We can see that the uniqueness constraint is being explicitly enforced by a validator on the serializer field. -Because of this more explicit style REST framework includes a few validator classes that are not available in core Django. These classes are detailed below. +Because of this more explicit style REST framework includes a few validator classes that are not available in core Django. These classes are detailed below. REST framework validators, like their Django counterparts, implement the `__eq__` method, allowing you to compare instances for equality. --- @@ -295,13 +295,14 @@ To write a class-based validator, use the `__call__` method. Class-based validat In some advanced cases you might want a validator to be passed the serializer field it is being used with as additional context. You can do so by setting -a `requires_context = True` attribute on the validator. The `__call__` method +a `requires_context = True` attribute on the validator class. The `__call__` method will then be called with the `serializer_field` or `serializer` as an additional argument. - requires_context = True + class MultipleOf: + requires_context = True - def __call__(self, value, serializer_field): - ... + def __call__(self, value, serializer_field): + ... [cite]: https://docs.djangoproject.com/en/stable/ref/validators/ diff --git a/docs/community/3.12-announcement.md b/docs/community/3.12-announcement.md index 4a589e39c..3bfeb6576 100644 --- a/docs/community/3.12-announcement.md +++ b/docs/community/3.12-announcement.md @@ -143,6 +143,16 @@ class PublisherSearchView(generics.ListAPIView): --- +## Deprecations + +### `serializers.NullBooleanField` + +`serializers.NullBooleanField` is now pending deprecation, and will be removed in 3.14. + +Instead use `serializers.BooleanField` field and set `allow_null=True` which does the same thing. + +--- + ## Funding REST framework is a *collaboratively funded project*. If you use diff --git a/docs/community/3.14-announcement.md b/docs/community/3.14-announcement.md index 0543d0d6d..e7b10ede1 100644 --- a/docs/community/3.14-announcement.md +++ b/docs/community/3.14-announcement.md @@ -60,3 +60,13 @@ See Pull Request [#7522](https://github.com/encode/django-rest-framework/pull/75 ## Minor fixes and improvements There are a number of minor fixes and improvements in this release. See the [release notes](release-notes.md) page for a complete listing. + +--- + +## Deprecations + +### `serializers.NullBooleanField` + +`serializers.NullBooleanField` was moved to pending deprecation in 3.12, and deprecated in 3.13. It has now been removed from the core framework. + +Instead use `serializers.BooleanField` field and set `allow_null=True` which does the same thing. diff --git a/docs/community/release-notes.md b/docs/community/release-notes.md index 887cae3b4..fba7f63d6 100644 --- a/docs/community/release-notes.md +++ b/docs/community/release-notes.md @@ -157,6 +157,7 @@ Date: 28th September 2020 * Fix `PrimaryKeyRelatedField` and `HyperlinkedRelatedField` when source field is actually a property. [#7142] * `Token.generate_key` is now a class method. [#7502] * `@action` warns if method is wrapped in a decorator that does not preserve information using `@functools.wraps`. [#7098] +* Deprecate `serializers.NullBooleanField` in favour of `serializers.BooleanField` with `allow_null=True` [#7122] --- diff --git a/docs/index.md b/docs/index.md index cab5511ac..ad241c0a3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -86,7 +86,7 @@ continued development by **[signing up for a paid plan][funding]**. REST framework requires the following: * Python (3.6, 3.7, 3.8, 3.9, 3.10, 3.11) -* Django (2.2, 3.0, 3.1, 3.2, 4.0, 4.1) +* Django (3.0, 3.1, 3.2, 4.0, 4.1) We **highly recommend** and only officially support the latest patch release of each Python and Django series. diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index a9733185b..81f22a35a 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1,8 +1,8 @@ # Wheel for PyPI installs. -wheel>=0.35.1,<0.36 +wheel>=0.36.2,<0.40.0 # Twine for secured PyPI uploads. -twine>=3.2.0,<3.3 +twine>=3.4.2,<4.0.2 # Transifex client for managing translation resources. transifex-client diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index cc24ce46c..b9e3f9817 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -13,7 +13,7 @@ __title__ = 'Django REST framework' __version__ = '3.14.0' __author__ = 'Tom Christie' __license__ = 'BSD 3-Clause' -__copyright__ = 'Copyright 2011-2019 Encode OSS Ltd' +__copyright__ = 'Copyright 2011-2023 Encode OSS Ltd' # Version synonym VERSION = __version__ @@ -31,3 +31,7 @@ if django.VERSION < (3, 2): class RemovedInDRF315Warning(DeprecationWarning): pass + + +class RemovedInDRF317Warning(PendingDeprecationWarning): + pass diff --git a/rest_framework/authtoken/admin.py b/rest_framework/authtoken/admin.py index e41eb0002..163328eb0 100644 --- a/rest_framework/authtoken/admin.py +++ b/rest_framework/authtoken/admin.py @@ -4,6 +4,7 @@ from django.contrib.admin.views.main import ChangeList from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from rest_framework.authtoken.models import Token, TokenProxy @@ -23,6 +24,8 @@ class TokenChangeList(ChangeList): class TokenAdmin(admin.ModelAdmin): list_display = ('key', 'user', 'created') fields = ('user',) + search_fields = ('user__username',) + search_help_text = _('Username') ordering = ('-created',) actions = None # Actions not compatible with mapped IDs. autocomplete_fields = ("user",) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 613bd325a..4ce9c79c3 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -4,9 +4,9 @@ import datetime import decimal import functools import inspect +import logging import re import uuid -from collections import OrderedDict from collections.abc import Mapping from django.conf import settings @@ -17,6 +17,7 @@ from django.core.validators import ( MinValueValidator, ProhibitNullCharactersValidator, RegexValidator, URLValidator, ip_address_validators ) +from django.db.models import IntegerChoices, TextChoices from django.forms import FilePathField as DjangoFilePathField from django.forms import ImageField as DjangoImageField from django.utils import timezone @@ -28,15 +29,22 @@ from django.utils.encoding import is_protected_type, smart_str from django.utils.formats import localize_input, sanitize_separators from django.utils.ipv6 import clean_ipv6_address from django.utils.translation import gettext_lazy as _ -from pytz.exceptions import InvalidTimeError + +try: + import pytz +except ImportError: + pytz = None from rest_framework import ISO_8601 from rest_framework.exceptions import ErrorDetail, ValidationError from rest_framework.settings import api_settings from rest_framework.utils import html, humanize_datetime, json, representation from rest_framework.utils.formatting import lazy_format +from rest_framework.utils.timezone import valid_datetime from rest_framework.validators import ProhibitSurrogateCharactersValidator +logger = logging.getLogger("rest_framework.fields") + class empty: """ @@ -109,27 +117,6 @@ def get_attribute(instance, attrs): return instance -def set_value(dictionary, keys, value): - """ - Similar to Python's built in `dictionary[key] = value`, - but takes a list of nested keys instead of a single key. - - set_value({'a': 1}, [], {'b': 2}) -> {'a': 1, 'b': 2} - set_value({'a': 1}, ['x'], 2) -> {'a': 1, 'x': 2} - set_value({'a': 1}, ['x', 'y'], 2) -> {'a': 1, 'x': {'y': 2}} - """ - if not keys: - dictionary.update(value) - return - - for key in keys[:-1]: - if key not in dictionary: - dictionary[key] = {} - dictionary = dictionary[key] - - dictionary[keys[-1]] = value - - def to_choices_dict(choices): """ Convert choices into key/value dicts. @@ -142,7 +129,7 @@ def to_choices_dict(choices): # choices = [1, 2, 3] # choices = [(1, 'First'), (2, 'Second'), (3, 'Third')] # choices = [('Category', ((1, 'First'), (2, 'Second'))), (3, 'Third')] - ret = OrderedDict() + ret = {} for choice in choices: if not isinstance(choice, (list, tuple)): # single choice @@ -165,7 +152,7 @@ def flatten_choices_dict(choices): flatten_choices_dict({1: '1st', 2: '2nd'}) -> {1: '1st', 2: '2nd'} flatten_choices_dict({'Group': {1: '1st', 2: '2nd'}}) -> {1: '1st', 2: '2nd'} """ - ret = OrderedDict() + ret = {} for key, value in choices.items(): if isinstance(value, dict): # grouped choices (category, sub choices) @@ -677,22 +664,27 @@ class BooleanField(Field): default_empty_html = False initial = False TRUE_VALUES = { - 't', 'T', - 'y', 'Y', 'yes', 'Yes', 'YES', - 'true', 'True', 'TRUE', - 'on', 'On', 'ON', - '1', 1, - True + 't', + 'y', + 'yes', + 'true', + 'on', + '1', + 1, + True, } FALSE_VALUES = { - 'f', 'F', - 'n', 'N', 'no', 'No', 'NO', - 'false', 'False', 'FALSE', - 'off', 'Off', 'OFF', - '0', 0, 0.0, - False + 'f', + 'n', + 'no', + 'false', + 'off', + '0', + 0, + 0.0, + False, } - NULL_VALUES = {'null', 'Null', 'NULL', '', None} + NULL_VALUES = {'null', '', None} def __init__(self, **kwargs): if kwargs.get('allow_null', False): @@ -700,22 +692,28 @@ class BooleanField(Field): self.initial = None super().__init__(**kwargs) + @staticmethod + def _lower_if_str(value): + if isinstance(value, str): + return value.lower() + return value + def to_internal_value(self, data): with contextlib.suppress(TypeError): - if data in self.TRUE_VALUES: + if self._lower_if_str(data) in self.TRUE_VALUES: return True - elif data in self.FALSE_VALUES: + elif self._lower_if_str(data) in self.FALSE_VALUES: return False - elif data in self.NULL_VALUES and self.allow_null: + elif self._lower_if_str(data) in self.NULL_VALUES and self.allow_null: return None - self.fail('invalid', input=data) + self.fail("invalid", input=data) def to_representation(self, value): - if value in self.TRUE_VALUES: + if self._lower_if_str(value) in self.TRUE_VALUES: return True - elif value in self.FALSE_VALUES: + elif self._lower_if_str(value) in self.FALSE_VALUES: return False - if value in self.NULL_VALUES and self.allow_null: + if self._lower_if_str(value) in self.NULL_VALUES and self.allow_null: return None return bool(value) @@ -989,6 +987,11 @@ class DecimalField(Field): self.max_value = max_value self.min_value = min_value + if self.max_value is not None and not isinstance(self.max_value, decimal.Decimal): + logger.warning("max_value in DecimalField should be Decimal type.") + if self.min_value is not None and not isinstance(self.min_value, decimal.Decimal): + logger.warning("min_value in DecimalField should be Decimal type.") + if self.max_digits is not None and self.decimal_places is not None: self.max_whole_digits = self.max_digits - self.decimal_places else: @@ -1154,9 +1157,16 @@ class DateTimeField(Field): except OverflowError: self.fail('overflow') try: - return timezone.make_aware(value, field_timezone) - except InvalidTimeError: - self.fail('make_aware', timezone=field_timezone) + dt = timezone.make_aware(value, field_timezone) + # When the resulting datetime is a ZoneInfo instance, it won't necessarily + # throw given an invalid datetime, so we need to specifically check. + if not valid_datetime(dt): + self.fail('make_aware', timezone=field_timezone) + return dt + except Exception as e: + if pytz and isinstance(e, pytz.exceptions.InvalidTimeError): + self.fail('make_aware', timezone=field_timezone) + raise e elif (field_timezone is None) and timezone.is_aware(value): return timezone.make_naive(value, datetime.timezone.utc) return value @@ -1392,6 +1402,10 @@ class ChoiceField(Field): if data == '' and self.allow_blank: return '' + if isinstance(data, (IntegerChoices, TextChoices)) and str(data) != \ + str(data.value): + data = data.value + try: return self.choice_strings_to_values[str(data)] except KeyError: @@ -1400,6 +1414,11 @@ class ChoiceField(Field): def to_representation(self, value): if value in ('', None): return value + + if isinstance(value, (IntegerChoices, TextChoices)) and str(value) != \ + str(value.value): + value = value.value + return self.choice_strings_to_values.get(str(value), value) def iter_options(self): @@ -1423,7 +1442,8 @@ class ChoiceField(Field): # Allows us to deal with eg. integer choices while supporting either # integer or string input, but still get the correct datatype out. self.choice_strings_to_values = { - str(key): key for key in self.choices + str(key.value) if isinstance(key, (IntegerChoices, TextChoices)) + and str(key) != str(key.value) else str(key): key for key in self.choices } choices = property(_get_choices, _set_choices) @@ -1643,7 +1663,7 @@ class ListField(Field): def run_child_validation(self, data): result = [] - errors = OrderedDict() + errors = {} for idx, item in enumerate(data): try: @@ -1707,7 +1727,7 @@ class DictField(Field): def run_child_validation(self, data): result = {} - errors = OrderedDict() + errors = {} for key, value in data.items(): key = str(key) diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 1ffd9edc0..17e6975eb 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -3,6 +3,7 @@ Provides generic filtering backends that can be used to filter the results returned by list views. """ import operator +import warnings from functools import reduce from django.core.exceptions import ImproperlyConfigured @@ -12,6 +13,7 @@ from django.template import loader from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ +from rest_framework import RemovedInDRF317Warning from rest_framework.compat import coreapi, coreschema, distinct from rest_framework.settings import api_settings @@ -29,6 +31,8 @@ class BaseFilterBackend: def get_schema_fields(self, view): assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`' + if coreapi is not None: + warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning) assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`' return [] @@ -146,6 +150,8 @@ class SearchFilter(BaseFilterBackend): def get_schema_fields(self, view): assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`' + if coreapi is not None: + warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning) assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`' return [ coreapi.Field( @@ -306,6 +312,8 @@ class OrderingFilter(BaseFilterBackend): def get_schema_fields(self, view): assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`' + if coreapi is not None: + warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning) assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`' return [ coreapi.Field( diff --git a/rest_framework/metadata.py b/rest_framework/metadata.py index 015587897..364ca5b14 100644 --- a/rest_framework/metadata.py +++ b/rest_framework/metadata.py @@ -6,8 +6,6 @@ some fairly ad-hoc information about the view. Future implementations might use JSON schema or other definitions in order to return this information in a more standardized way. """ -from collections import OrderedDict - from django.core.exceptions import PermissionDenied from django.http import Http404 from django.utils.encoding import force_str @@ -59,11 +57,12 @@ class SimpleMetadata(BaseMetadata): }) def determine_metadata(self, request, view): - metadata = OrderedDict() - metadata['name'] = view.get_view_name() - metadata['description'] = view.get_view_description() - metadata['renders'] = [renderer.media_type for renderer in view.renderer_classes] - metadata['parses'] = [parser.media_type for parser in view.parser_classes] + metadata = { + "name": view.get_view_name(), + "description": view.get_view_description(), + "renders": [renderer.media_type for renderer in view.renderer_classes], + "parses": [parser.media_type for parser in view.parser_classes], + } if hasattr(view, 'get_serializer'): actions = self.determine_actions(request, view) if actions: @@ -106,25 +105,27 @@ class SimpleMetadata(BaseMetadata): # If this is a `ListSerializer` then we want to examine the # underlying child serializer instance instead. serializer = serializer.child - return OrderedDict([ - (field_name, self.get_field_info(field)) + return { + field_name: self.get_field_info(field) for field_name, field in serializer.fields.items() if not isinstance(field, serializers.HiddenField) - ]) + } def get_field_info(self, field): """ Given an instance of a serializer field, return a dictionary of metadata about it. """ - field_info = OrderedDict() - field_info['type'] = self.label_lookup[field] - field_info['required'] = getattr(field, 'required', False) + field_info = { + "type": self.label_lookup[field], + "required": getattr(field, "required", False), + } attrs = [ 'read_only', 'label', 'help_text', 'min_length', 'max_length', - 'min_value', 'max_value' + 'min_value', 'max_value', + 'max_digits', 'decimal_places' ] for attr in attrs: diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index f5c5b913b..7303890b0 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -4,16 +4,19 @@ be used for paginated responses. """ import contextlib +import warnings from base64 import b64decode, b64encode -from collections import OrderedDict, namedtuple +from collections import namedtuple from urllib import parse from django.core.paginator import InvalidPage from django.core.paginator import Paginator as DjangoPaginator +from django.db.models import Q from django.template import loader from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ +from rest_framework import RemovedInDRF317Warning from rest_framework.compat import coreapi, coreschema from rest_framework.exceptions import NotFound from rest_framework.response import Response @@ -151,6 +154,8 @@ class BasePagination: def get_schema_fields(self, view): assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`' + if coreapi is not None: + warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning) return [] def get_schema_operation_parameters(self, view): @@ -224,12 +229,12 @@ class PageNumberPagination(BasePagination): return page_number def get_paginated_response(self, data): - return Response(OrderedDict([ - ('count', self.page.paginator.count), - ('next', self.get_next_link()), - ('previous', self.get_previous_link()), - ('results', data) - ])) + return Response({ + 'count': self.page.paginator.count, + 'next': self.get_next_link(), + 'previous': self.get_previous_link(), + 'results': data, + }) def get_paginated_response_schema(self, schema): return { @@ -310,6 +315,8 @@ class PageNumberPagination(BasePagination): def get_schema_fields(self, view): assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`' + if coreapi is not None: + warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning) assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`' fields = [ coreapi.Field( @@ -394,12 +401,12 @@ class LimitOffsetPagination(BasePagination): return list(queryset[self.offset:self.offset + self.limit]) def get_paginated_response(self, data): - return Response(OrderedDict([ - ('count', self.count), - ('next', self.get_next_link()), - ('previous', self.get_previous_link()), - ('results', data) - ])) + return Response({ + 'count': self.count, + 'next': self.get_next_link(), + 'previous': self.get_previous_link(), + 'results': data + }) def get_paginated_response_schema(self, schema): return { @@ -524,6 +531,8 @@ class LimitOffsetPagination(BasePagination): def get_schema_fields(self, view): assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`' + if coreapi is not None: + warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning) assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`' return [ coreapi.Field( @@ -620,7 +629,7 @@ class CursorPagination(BasePagination): queryset = queryset.order_by(*self.ordering) # If we have a cursor with a fixed position then filter by that. - if current_position is not None: + if str(current_position) != 'None': order = self.ordering[0] is_reversed = order.startswith('-') order_attr = order.lstrip('-') @@ -631,7 +640,12 @@ class CursorPagination(BasePagination): else: kwargs = {order_attr + '__gt': current_position} - queryset = queryset.filter(**kwargs) + filter_query = Q(**kwargs) + # If some records contain a null for the ordering field, don't lose them. + # When reverse ordering, nulls will come last and need to be included. + if (reverse and not is_reversed) or is_reversed: + filter_query |= Q(**{order_attr + '__isnull': True}) + queryset = queryset.filter(filter_query) # If we have an offset cursor then offset the entire page by that amount. # We also always fetch an extra item in order to determine if there is a @@ -704,7 +718,7 @@ class CursorPagination(BasePagination): # The item in this position and the item following it # have different positions. We can use this position as # our marker. - has_item_with_unique_position = True + has_item_with_unique_position = position is not None break # The item in this position has the same position as the item @@ -757,7 +771,7 @@ class CursorPagination(BasePagination): # The item in this position and the item following it # have different positions. We can use this position as # our marker. - has_item_with_unique_position = True + has_item_with_unique_position = position is not None break # The item in this position has the same position as the item @@ -795,6 +809,10 @@ class CursorPagination(BasePagination): """ Return a tuple of strings, that may be used in an `order_by` method. """ + # The default case is to check for an `ordering` attribute + # on this pagination instance. + ordering = self.ordering + ordering_filters = [ filter_cls for filter_cls in getattr(view, 'filter_backends', []) if hasattr(filter_cls, 'get_ordering') @@ -805,26 +823,19 @@ class CursorPagination(BasePagination): # then we defer to that filter to determine the ordering. filter_cls = ordering_filters[0] filter_instance = filter_cls() - ordering = filter_instance.get_ordering(request, queryset, view) - assert ordering is not None, ( - 'Using cursor pagination, but filter class {filter_cls} ' - 'returned a `None` ordering.'.format( - filter_cls=filter_cls.__name__ - ) - ) - else: - # The default case is to check for an `ordering` attribute - # on this pagination instance. - ordering = self.ordering - assert ordering is not None, ( - 'Using cursor pagination, but no ordering attribute was declared ' - 'on the pagination class.' - ) - assert '__' not in ordering, ( - 'Cursor pagination does not support double underscore lookups ' - 'for orderings. Orderings should be an unchanging, unique or ' - 'nearly-unique field on the model, such as "-created" or "pk".' - ) + ordering_from_filter = filter_instance.get_ordering(request, queryset, view) + if ordering_from_filter: + ordering = ordering_from_filter + + assert ordering is not None, ( + 'Using cursor pagination, but no ordering attribute was declared ' + 'on the pagination class.' + ) + assert '__' not in ordering, ( + 'Cursor pagination does not support double underscore lookups ' + 'for orderings. Orderings should be an unchanging, unique or ' + 'nearly-unique field on the model, such as "-created" or "pk".' + ) assert isinstance(ordering, (str, list, tuple)), ( 'Invalid ordering. Expected string or tuple, but got {type}'.format( @@ -883,14 +894,14 @@ class CursorPagination(BasePagination): attr = instance[field_name] else: attr = getattr(instance, field_name) - return str(attr) + return None if attr is None else str(attr) def get_paginated_response(self, data): - return Response(OrderedDict([ - ('next', self.get_next_link()), - ('previous', self.get_previous_link()), - ('results', data) - ])) + return Response({ + 'next': self.get_next_link(), + 'previous': self.get_previous_link(), + 'results': data, + }) def get_paginated_response_schema(self, schema): return { @@ -927,6 +938,8 @@ class CursorPagination(BasePagination): def get_schema_fields(self, view): assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`' + if coreapi is not None: + warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning) assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`' fields = [ coreapi.Field( diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 62da685fb..4409bce77 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -1,6 +1,6 @@ import contextlib import sys -from collections import OrderedDict +from operator import attrgetter from urllib import parse from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist @@ -71,6 +71,7 @@ class PKOnlyObject: instance, but still want to return an object with a .pk attribute, in order to keep the same interface as a regular model instance. """ + def __init__(self, pk): self.pk = pk @@ -197,13 +198,9 @@ class RelatedField(Field): if cutoff is not None: queryset = queryset[:cutoff] - return OrderedDict([ - ( - self.to_representation(item), - self.display_value(item) - ) - for item in queryset - ]) + return { + self.to_representation(item): self.display_value(item) for item in queryset + } @property def choices(self): @@ -464,7 +461,11 @@ class SlugRelatedField(RelatedField): self.fail('invalid') def to_representation(self, obj): - return getattr(obj, self.slug_field) + slug = self.slug_field + if "__" in slug: + # handling nested relationship if defined + slug = slug.replace('__', '.') + return attrgetter(slug)(obj) class ManyRelatedField(Field): diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 8de0a77a1..db1fdd128 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -9,7 +9,7 @@ REST framework also provides an HTML renderer that renders the browsable API. import base64 import contextlib -from collections import OrderedDict +import datetime from urllib import parse from django import forms @@ -507,6 +507,9 @@ class BrowsableAPIRenderer(BaseRenderer): return self.render_form_for_serializer(serializer) def render_form_for_serializer(self, serializer): + if isinstance(serializer, serializers.ListSerializer): + return None + if hasattr(serializer, 'initial_data'): serializer.is_valid() @@ -556,10 +559,13 @@ class BrowsableAPIRenderer(BaseRenderer): context['indent'] = 4 # strip HiddenField from output + is_list_serializer = isinstance(serializer, serializers.ListSerializer) + serializer = serializer.child if is_list_serializer else serializer data = serializer.data.copy() for name, field in serializer.fields.items(): if isinstance(field, serializers.HiddenField): data.pop(name, None) + data = [data] if is_list_serializer else data content = renderer.render(data, accepted, context) # Renders returns bytes, but CharField expects a str. content = content.decode() @@ -653,7 +659,7 @@ class BrowsableAPIRenderer(BaseRenderer): raw_data_patch_form = self.get_raw_data_form(data, view, 'PATCH', request) raw_data_put_or_patch_form = raw_data_put_form or raw_data_patch_form - response_headers = OrderedDict(sorted(response.items())) + response_headers = dict(sorted(response.items())) renderer_content_type = '' if renderer: renderer_content_type = '%s' % renderer.media_type @@ -1057,6 +1063,7 @@ class OpenAPIRenderer(BaseRenderer): def ignore_aliases(self, data): return True Dumper.add_representer(SafeString, Dumper.represent_str) + Dumper.add_representer(datetime.timedelta, encoders.CustomScalar.represent_timedelta) return yaml.dump(data, default_flow_style=False, sort_keys=False, Dumper=Dumper).encode('utf-8') diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 722fc50a6..fa5d16922 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -14,7 +14,7 @@ For example, you might have a `urls.py` that looks something like this: urlpatterns = router.urls """ import itertools -from collections import OrderedDict, namedtuple +from collections import namedtuple from django.core.exceptions import ImproperlyConfigured from django.urls import NoReverseMatch, path, re_path @@ -321,7 +321,7 @@ class APIRootView(views.APIView): def get(self, request, *args, **kwargs): # Return a plain {"name": "hyperlink"} response. - ret = OrderedDict() + ret = {} namespace = request.resolver_match.namespace for key, url_name in self.api_root_dict.items(): if namespace: @@ -365,7 +365,7 @@ class DefaultRouter(SimpleRouter): """ Return a basic root view. """ - api_root_dict = OrderedDict() + api_root_dict = {} list_name = self.routes[0].name for prefix, viewset, basename in self.registry: api_root_dict[prefix] = list_name.format(basename=basename) diff --git a/rest_framework/schemas/coreapi.py b/rest_framework/schemas/coreapi.py index 908518e9d..582aba196 100644 --- a/rest_framework/schemas/coreapi.py +++ b/rest_framework/schemas/coreapi.py @@ -1,11 +1,11 @@ import warnings -from collections import Counter, OrderedDict +from collections import Counter from urllib import parse from django.db import models from django.utils.encoding import force_str -from rest_framework import exceptions, serializers +from rest_framework import RemovedInDRF317Warning, exceptions, serializers from rest_framework.compat import coreapi, coreschema, uritemplate from rest_framework.settings import api_settings @@ -54,7 +54,7 @@ to customise schema structure. """ -class LinkNode(OrderedDict): +class LinkNode(dict): def __init__(self): self.links = [] self.methods_counter = Counter() @@ -118,6 +118,8 @@ class SchemaGenerator(BaseSchemaGenerator): def __init__(self, title=None, url=None, description=None, patterns=None, urlconf=None, version=None): assert coreapi, '`coreapi` must be installed for schema support.' + if coreapi is not None: + warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning) assert coreschema, '`coreschema` must be installed for schema support.' super().__init__(title, url, description, patterns, urlconf) @@ -268,11 +270,11 @@ def field_to_schema(field): ) elif isinstance(field, serializers.Serializer): return coreschema.Object( - properties=OrderedDict([ - (key, field_to_schema(value)) + properties={ + key: field_to_schema(value) for key, value in field.fields.items() - ]), + }, title=title, description=description ) @@ -351,6 +353,9 @@ class AutoSchema(ViewInspector): will be added to auto-generated fields, overwriting on `Field.name` """ super().__init__() + if coreapi is not None: + warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning) + if manual_fields is None: manual_fields = [] self._manual_fields = manual_fields @@ -549,7 +554,7 @@ class AutoSchema(ViewInspector): if not update_with: return fields - by_name = OrderedDict((f.name, f) for f in fields) + by_name = {f.name: f for f in fields} for f in update_with: by_name[f.name] = f fields = list(by_name.values()) @@ -592,6 +597,9 @@ class ManualSchema(ViewInspector): * `description`: String description for view. Optional. """ super().__init__() + if coreapi is not None: + warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning) + assert all(isinstance(f, coreapi.Field) for f in fields), "`fields` must be a list of coreapi.Field instances" self._fields = fields self._description = description @@ -613,4 +621,6 @@ class ManualSchema(ViewInspector): def is_enabled(): """Is CoreAPI Mode enabled?""" + if coreapi is not None: + warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning) return issubclass(api_settings.DEFAULT_SCHEMA_CLASS, AutoSchema) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index ea220c631..d8707e1e1 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -1,6 +1,5 @@ import re import warnings -from collections import OrderedDict from decimal import Decimal from operator import attrgetter from urllib.parse import urljoin @@ -340,7 +339,7 @@ class AutoSchema(ViewInspector): return paginator.get_schema_operation_parameters(view) def map_choicefield(self, field): - choices = list(OrderedDict.fromkeys(field.choices)) # preserve order and remove duplicates + choices = list(dict.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): diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 815e97e29..976a34836 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -15,7 +15,7 @@ import contextlib import copy import inspect import traceback -from collections import OrderedDict, defaultdict +from collections import defaultdict from collections.abc import Mapping from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured @@ -315,7 +315,7 @@ class SerializerMetaclass(type): for name, f in base._declared_fields.items() if name not in known ] - return OrderedDict(base_fields + fields) + return dict(base_fields + fields) def __new__(cls, name, bases, attrs): attrs['_declared_fields'] = cls._get_declared_fields(bases, attrs) @@ -353,6 +353,26 @@ class Serializer(BaseSerializer, metaclass=SerializerMetaclass): 'invalid': _('Invalid data. Expected a dictionary, but got {datatype}.') } + def set_value(self, dictionary, keys, value): + """ + Similar to Python's built in `dictionary[key] = value`, + but takes a list of nested keys instead of a single key. + + set_value({'a': 1}, [], {'b': 2}) -> {'a': 1, 'b': 2} + set_value({'a': 1}, ['x'], 2) -> {'a': 1, 'x': 2} + set_value({'a': 1}, ['x', 'y'], 2) -> {'a': 1, 'x': {'y': 2}} + """ + if not keys: + dictionary.update(value) + return + + for key in keys[:-1]: + if key not in dictionary: + dictionary[key] = {} + dictionary = dictionary[key] + + dictionary[keys[-1]] = value + @cached_property def fields(self): """ @@ -400,20 +420,20 @@ class Serializer(BaseSerializer, metaclass=SerializerMetaclass): if hasattr(self, 'initial_data'): # initial_data may not be a valid type if not isinstance(self.initial_data, Mapping): - return OrderedDict() + return {} - return OrderedDict([ - (field_name, field.get_value(self.initial_data)) + return { + field_name: field.get_value(self.initial_data) for field_name, field in self.fields.items() if (field.get_value(self.initial_data) is not empty) and not field.read_only - ]) + } - return OrderedDict([ - (field.field_name, field.get_initial()) + return { + field.field_name: field.get_initial() for field in self.fields.values() if not field.read_only - ]) + } def get_value(self, dictionary): # We override the default field access in order to support @@ -448,7 +468,7 @@ class Serializer(BaseSerializer, metaclass=SerializerMetaclass): if (field.read_only) and (field.default != empty) and (field.source != '*') and ('.' not in field.source) ] - defaults = OrderedDict() + defaults = {} for field in fields: try: default = field.get_default() @@ -481,8 +501,8 @@ class Serializer(BaseSerializer, metaclass=SerializerMetaclass): api_settings.NON_FIELD_ERRORS_KEY: [message] }, code='invalid') - ret = OrderedDict() - errors = OrderedDict() + ret = {} + errors = {} fields = self._writable_fields for field in fields: @@ -499,7 +519,7 @@ class Serializer(BaseSerializer, metaclass=SerializerMetaclass): except SkipField: pass else: - set_value(ret, field.source_attrs, validated_value) + self.set_value(ret, field.source_attrs, validated_value) if errors: raise ValidationError(errors) @@ -510,7 +530,7 @@ class Serializer(BaseSerializer, metaclass=SerializerMetaclass): """ Object instance -> Dict of primitive datatypes. """ - ret = OrderedDict() + ret = {} fields = self._readable_fields for field in fields: @@ -596,6 +616,12 @@ class ListSerializer(BaseSerializer): self.min_length = kwargs.pop('min_length', None) assert self.child is not None, '`child` is a required argument.' 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) self.child.bind(field_name='', parent=self) @@ -670,7 +696,13 @@ class ListSerializer(BaseSerializer): ret = [] errors = [] - for item in data: + for idx, item in enumerate(data): + if ( + hasattr(self, 'instance') + and self.instance + and len(self.instance) > idx + ): + self.child.instance = self.instance[idx] try: validated = self.child.run_validation(item) except ValidationError as exc: @@ -1068,7 +1100,7 @@ class ModelSerializer(Serializer): ) # Determine the fields that should be included on the serializer. - fields = OrderedDict() + fields = {} for field_name in field_names: # If the field is explicitly declared on the class then use that. @@ -1553,16 +1585,16 @@ 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_sources = OrderedDict( - (field.field_name, field.source) for field in self._writable_fields + field_sources = { + 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_sources.update(OrderedDict( - (field.field_name, field.source) for field in self.fields.values() + field_sources.update({ + 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 diff --git a/rest_framework/static/rest_framework/js/jquery-3.6.4.min.js b/rest_framework/static/rest_framework/js/jquery-3.6.4.min.js new file mode 100644 index 000000000..0de648ed3 --- /dev/null +++ b/rest_framework/static/rest_framework/js/jquery-3.6.4.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.6.4 | (c) OpenJS 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=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,y=n.hasOwnProperty,a=y.toString,l=a.call(Object),v={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,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.6.4",S=function(e,t){return new S.fn.init(e,t)};function p(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(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))&&v(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+" "]&&(!y||!y.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)&&ve(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(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[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 ye(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 ve(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(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.cssHas=ce(function(){try{return C.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),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=[],y=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||y.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||y.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||y.push(".#.+[+~]"),e.querySelectorAll("\\\f"),y.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&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.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)}),d.cssHas||y.push(":has"),y=y.length&&new RegExp(y.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),v=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},j=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&&v(p,e)?-1:t==C||t.ownerDocument==p&&v(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))&&(!y||!y.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 j(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||D,"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,D=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),v.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",v.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",v.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ye(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 ve(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 je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(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"}),v.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||(v.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):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 B(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(v.pixelPosition,function(e,t){if(t)return t=Be(e,n),Pe.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 B(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 533db1378..53c964f23 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 694f88a15..0c369e9e8 100644 --- a/rest_framework/templates/rest_framework/docs/error.html +++ b/rest_framework/templates/rest_framework/docs/error.html @@ -30,7 +30,7 @@ being applied unexpectedly?

Your response status code is: {{ response.status_code }}

-

401 Unauthorised.

+

401 Unauthorized.

  • Do you have SessionAuthentication enabled?
  • Are you logged in?
  • @@ -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 dfd363772..8f8536fbe 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/templates/rest_framework/horizontal/select_multiple.html b/rest_framework/templates/rest_framework/horizontal/select_multiple.html index 36ff9fd0d..12e781cc6 100644 --- a/rest_framework/templates/rest_framework/horizontal/select_multiple.html +++ b/rest_framework/templates/rest_framework/horizontal/select_multiple.html @@ -11,7 +11,7 @@ {% endif %}
    - {% for select in field.iter_options %} {% if select.start_option_group %} diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index ccd9430b4..53916d3f2 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -1,5 +1,4 @@ import re -from collections import OrderedDict from django import template from django.template import loader @@ -49,10 +48,10 @@ def with_location(fields, location): @register.simple_tag def form_for_link(link): import coreschema - properties = OrderedDict([ - (field.name, field.schema or coreschema.String()) + properties = { + field.name: field.schema or coreschema.String() for field in link.fields - ]) + } required = [ field.name for field in link.fields @@ -272,7 +271,7 @@ def schema_links(section, sec_key=None): links.update(new_links) if sec_key is not None: - new_links = OrderedDict() + new_links = {} for link_key, link in links.items(): new_key = NESTED_FORMAT % (sec_key, link_key) new_links.update({new_key: link}) diff --git a/rest_framework/utils/encoders.py b/rest_framework/utils/encoders.py index 35a89eb09..aa4542286 100644 --- a/rest_framework/utils/encoders.py +++ b/rest_framework/utils/encoders.py @@ -65,3 +65,14 @@ class JSONEncoder(json.JSONEncoder): elif hasattr(obj, '__iter__'): return tuple(item for item in obj) return super().default(obj) + + +class CustomScalar: + """ + CustomScalar that knows how to encode timedelta that renderer + can understand. + """ + @classmethod + def represent_timedelta(cls, dumper, data): + value = str(data.total_seconds()) + return dumper.represent_scalar('tag:yaml.org,2002:str', value) diff --git a/rest_framework/utils/model_meta.py b/rest_framework/utils/model_meta.py index 4cc93b8ef..bd5d9177c 100644 --- a/rest_framework/utils/model_meta.py +++ b/rest_framework/utils/model_meta.py @@ -5,7 +5,7 @@ relationships and their associated metadata. Usage: `get_field_info(model)` returns a `FieldInfo` instance. """ -from collections import OrderedDict, namedtuple +from collections import namedtuple FieldInfo = namedtuple('FieldResult', [ 'pk', # Model field instance @@ -58,7 +58,7 @@ def _get_pk(opts): def _get_fields(opts): - fields = OrderedDict() + fields = {} for field in [field for field in opts.fields if field.serialize and not field.remote_field]: fields[field.name] = field @@ -71,9 +71,9 @@ def _get_to_field(field): def _get_forward_relationships(opts): """ - Returns an `OrderedDict` of field names to `RelationInfo`. + Returns a dict of field names to `RelationInfo`. """ - forward_relations = OrderedDict() + forward_relations = {} for field in [field for field in opts.fields if field.serialize and field.remote_field]: forward_relations[field.name] = RelationInfo( model_field=field, @@ -103,9 +103,9 @@ def _get_forward_relationships(opts): def _get_reverse_relationships(opts): """ - Returns an `OrderedDict` of field names to `RelationInfo`. + Returns a dict of field names to `RelationInfo`. """ - reverse_relations = OrderedDict() + reverse_relations = {} all_related_objects = [r for r in opts.related_objects if not r.field.many_to_many] for relation in all_related_objects: accessor_name = relation.get_accessor_name() @@ -139,19 +139,14 @@ def _get_reverse_relationships(opts): def _merge_fields_and_pk(pk, fields): - fields_and_pk = OrderedDict() - fields_and_pk['pk'] = pk - fields_and_pk[pk.name] = pk + fields_and_pk = {'pk': pk, pk.name: pk} fields_and_pk.update(fields) return fields_and_pk def _merge_relationships(forward_relations, reverse_relations): - return OrderedDict( - list(forward_relations.items()) + - list(reverse_relations.items()) - ) + return {**forward_relations, **reverse_relations} def is_abstract_model(model): diff --git a/rest_framework/utils/serializer_helpers.py b/rest_framework/utils/serializer_helpers.py index 6ca794584..0e59aa66a 100644 --- a/rest_framework/utils/serializer_helpers.py +++ b/rest_framework/utils/serializer_helpers.py @@ -1,6 +1,5 @@ import contextlib import sys -from collections import OrderedDict from collections.abc import Mapping, MutableMapping from django.utils.encoding import force_str @@ -8,7 +7,7 @@ from django.utils.encoding import force_str from rest_framework.utils import json -class ReturnDict(OrderedDict): +class ReturnDict(dict): """ Return object from `serializer.data` for the `Serializer` class. Includes a backlink to the serializer instance for renderers @@ -161,7 +160,7 @@ class BindingDict(MutableMapping): def __init__(self, serializer): self.serializer = serializer - self.fields = OrderedDict() + self.fields = {} def __setitem__(self, key, field): self.fields[key] = field diff --git a/rest_framework/utils/timezone.py b/rest_framework/utils/timezone.py new file mode 100644 index 000000000..3257c8e27 --- /dev/null +++ b/rest_framework/utils/timezone.py @@ -0,0 +1,25 @@ +from datetime import datetime, timezone, tzinfo + + +def datetime_exists(dt): + """Check if a datetime exists. Taken from: https://pytz-deprecation-shim.readthedocs.io/en/latest/migration.html""" + # There are no non-existent times in UTC, and comparisons between + # aware time zones always compare absolute times; if a datetime is + # not equal to the same datetime represented in UTC, it is imaginary. + return dt.astimezone(timezone.utc) == dt + + +def datetime_ambiguous(dt: datetime): + """Check whether a datetime is ambiguous. Taken from: https://pytz-deprecation-shim.readthedocs.io/en/latest/migration.html""" + # If a datetime exists and its UTC offset changes in response to + # changing `fold`, it is ambiguous in the zone specified. + return datetime_exists(dt) and ( + dt.replace(fold=not dt.fold).utcoffset() != dt.utcoffset() + ) + + +def valid_datetime(dt): + """Returns True if the datetime is not ambiguous or imaginary, False otherwise.""" + if isinstance(dt.tzinfo, tzinfo) and not datetime_ambiguous(dt): + return True + return False diff --git a/rest_framework/validators.py b/rest_framework/validators.py index a5cb75a84..07ad11b47 100644 --- a/rest_framework/validators.py +++ b/rest_framework/validators.py @@ -79,6 +79,15 @@ class UniqueValidator: smart_repr(self.queryset) ) + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return (self.message == other.message + and self.requires_context == other.requires_context + and self.queryset == other.queryset + and self.lookup == other.lookup + ) + class UniqueTogetherValidator: """ @@ -166,6 +175,16 @@ class UniqueTogetherValidator: smart_repr(self.fields) ) + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return (self.message == other.message + and self.requires_context == other.requires_context + and self.missing_message == other.missing_message + and self.queryset == other.queryset + and self.fields == other.fields + ) + class ProhibitSurrogateCharactersValidator: message = _('Surrogate characters are not allowed: U+{code_point:X}.') @@ -177,6 +196,13 @@ class ProhibitSurrogateCharactersValidator: message = self.message.format(code_point=ord(surrogate_character)) raise ValidationError(message, code=self.code) + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return (self.message == other.message + and self.code == other.code + ) + class BaseUniqueForValidator: message = None @@ -230,6 +256,17 @@ class BaseUniqueForValidator: self.field: message }, code='unique') + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return (self.message == other.message + and self.missing_message == other.missing_message + and self.requires_context == other.requires_context + and self.queryset == other.queryset + and self.field == other.field + and self.date_field == other.date_field + ) + def __repr__(self): return '<%s(queryset=%s, field=%s, date_field=%s)>' % ( self.__class__.__name__, diff --git a/rest_framework/versioning.py b/rest_framework/versioning.py index 78cfc9dc8..a1c0ce4d7 100644 --- a/rest_framework/versioning.py +++ b/rest_framework/versioning.py @@ -81,8 +81,10 @@ class URLPathVersioning(BaseVersioning): def reverse(self, viewname, args=None, kwargs=None, request=None, format=None, **extra): if request.version is not None: - kwargs = {} if (kwargs is None) else kwargs - kwargs[self.version_param] = request.version + kwargs = { + self.version_param: request.version, + **(kwargs or {}) + } return super().reverse( viewname, args, kwargs, request, format, **extra @@ -117,15 +119,16 @@ class NamespaceVersioning(BaseVersioning): def determine_version(self, request, *args, **kwargs): resolver_match = getattr(request, 'resolver_match', None) - if resolver_match is None or not resolver_match.namespace: - return self.default_version + if resolver_match is not None and resolver_match.namespace: + # Allow for possibly nested namespaces. + possible_versions = resolver_match.namespace.split(':') + for version in possible_versions: + if self.is_allowed_version(version): + return version - # Allow for possibly nested namespaces. - possible_versions = resolver_match.namespace.split(':') - for version in possible_versions: - if self.is_allowed_version(version): - return version - raise exceptions.NotFound(self.invalid_version_message) + if not self.is_allowed_version(self.default_version): + raise exceptions.NotFound(self.invalid_version_message) + return self.default_version def reverse(self, viewname, args=None, kwargs=None, request=None, format=None, **extra): if request.version is not None: diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 1c56f61e8..2eba17b4a 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -16,7 +16,6 @@ automatically. router.register(r'users', UserViewSet, 'user') urlpatterns = router.urls """ -from collections import OrderedDict from functools import update_wrapper from inspect import getmembers @@ -183,7 +182,7 @@ class ViewSetMixin: This method will noop if `detail` was not provided as a view initkwarg. """ - action_urls = OrderedDict() + action_urls = {} # exit early if `detail` has not been provided if self.detail is None: diff --git a/setup.cfg b/setup.cfg index 294e9afdd..487d99db9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,6 +3,8 @@ license_files = LICENSE.md [tool:pytest] addopts=--tb=short --strict-markers -ra +testspath = tests +filterwarnings = ignore:CoreAPI compatibility is deprecated*:rest_framework.RemovedInDRF317Warning [flake8] ignore = E501,W503,W504 diff --git a/setup.py b/setup.py index 9a5b272f3..6afd5e05e 100755 --- a/setup.py +++ b/setup.py @@ -37,7 +37,8 @@ an older version of Django REST Framework: def read(f): - return open(f, 'r', encoding='utf-8').read() + with open(f, 'r', encoding='utf-8') as file: + return file.read() def get_version(package): @@ -82,7 +83,7 @@ setup( author_email='tom@tomchristie.com', # SEE NOTE BELOW (*) packages=find_packages(exclude=['tests*']), include_package_data=True, - install_requires=["django>=3.0", "pytz"], + install_requires=["django>=3.0", 'backports.zoneinfo;python_version<"3.9"'], python_requires=">=3.6", zip_safe=False, classifiers=[ diff --git a/tests/schemas/test_coreapi.py b/tests/schemas/test_coreapi.py index eddc5243e..98fd46f9f 100644 --- a/tests/schemas/test_coreapi.py +++ b/tests/schemas/test_coreapi.py @@ -7,16 +7,24 @@ from django.test import TestCase, override_settings from django.urls import include, path from rest_framework import ( - filters, generics, pagination, permissions, serializers + RemovedInDRF317Warning, filters, generics, pagination, permissions, + serializers ) from rest_framework.compat import coreapi, coreschema from rest_framework.decorators import action, api_view, schema +from rest_framework.filters import ( + BaseFilterBackend, OrderingFilter, SearchFilter +) +from rest_framework.pagination import ( + BasePagination, CursorPagination, LimitOffsetPagination, + PageNumberPagination +) from rest_framework.request import Request from rest_framework.routers import DefaultRouter, SimpleRouter from rest_framework.schemas import ( AutoSchema, ManualSchema, SchemaGenerator, get_schema_view ) -from rest_framework.schemas.coreapi import field_to_schema +from rest_framework.schemas.coreapi import field_to_schema, is_enabled from rest_framework.schemas.generators import EndpointEnumerator from rest_framework.schemas.utils import is_list_view from rest_framework.test import APIClient, APIRequestFactory @@ -1433,3 +1441,46 @@ def test_schema_handles_exception(): response.render() assert response.status_code == 403 assert b"You do not have permission to perform this action." in response.content + + +@pytest.mark.skipif(not coreapi, reason='coreapi is not installed') +def test_coreapi_deprecation(): + with pytest.warns(RemovedInDRF317Warning): + SchemaGenerator() + + with pytest.warns(RemovedInDRF317Warning): + AutoSchema() + + with pytest.warns(RemovedInDRF317Warning): + ManualSchema({}) + + with pytest.warns(RemovedInDRF317Warning): + deprecated_filter = OrderingFilter() + deprecated_filter.get_schema_fields({}) + + with pytest.warns(RemovedInDRF317Warning): + deprecated_filter = BaseFilterBackend() + deprecated_filter.get_schema_fields({}) + + with pytest.warns(RemovedInDRF317Warning): + deprecated_filter = SearchFilter() + deprecated_filter.get_schema_fields({}) + + with pytest.warns(RemovedInDRF317Warning): + paginator = BasePagination() + paginator.get_schema_fields({}) + + with pytest.warns(RemovedInDRF317Warning): + paginator = PageNumberPagination() + paginator.get_schema_fields({}) + + with pytest.warns(RemovedInDRF317Warning): + paginator = LimitOffsetPagination() + paginator.get_schema_fields({}) + + with pytest.warns(RemovedInDRF317Warning): + paginator = CursorPagination() + paginator.get_schema_fields({}) + + with pytest.warns(RemovedInDRF317Warning): + is_enabled() diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 0ea6d1ff9..1eb5b84b7 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -1162,6 +1162,31 @@ class TestGenerator(TestCase): assert b'"openapi": "' in ret assert b'"default": "0.0"' in ret + def test_schema_rendering_to_yaml(self): + patterns = [ + path('example/', views.ExampleGenericAPIView.as_view()), + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + ret = OpenAPIRenderer().render(schema) + assert b"openapi: " in ret + assert b"default: '0.0'" in ret + + def test_schema_rendering_timedelta_to_yaml_with_validator(self): + + patterns = [ + path('example/', views.ExampleValidatedAPIView.as_view()), + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + ret = OpenAPIRenderer().render(schema) + assert b"openapi: " in ret + assert b"duration:\n type: string\n minimum: \'10.0\'\n" in ret + def test_schema_with_no_paths(self): patterns = [] generator = SchemaGenerator(patterns=patterns) diff --git a/tests/schemas/views.py b/tests/schemas/views.py index f1ed0bd4e..c08208bf2 100644 --- a/tests/schemas/views.py +++ b/tests/schemas/views.py @@ -134,6 +134,11 @@ class ExampleValidatedSerializer(serializers.Serializer): ip4 = serializers.IPAddressField(protocol='ipv4') ip6 = serializers.IPAddressField(protocol='ipv6') ip = serializers.IPAddressField() + duration = serializers.DurationField( + validators=( + MinValueValidator(timedelta(seconds=10)), + ) + ) class ExampleValidatedAPIView(generics.GenericAPIView): diff --git a/tests/test_fields.py b/tests/test_fields.py index 56e2a45ba..03584431e 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -5,10 +5,18 @@ import re import sys import uuid from decimal import ROUND_DOWN, ROUND_UP, Decimal +from enum import auto +from unittest.mock import patch import pytest -import pytz + +try: + import pytz +except ImportError: + pytz = None + from django.core.exceptions import ValidationError as DjangoValidationError +from django.db.models import IntegerChoices, TextChoices from django.http import QueryDict from django.test import TestCase, override_settings from django.utils.timezone import activate, deactivate, override @@ -21,6 +29,11 @@ from rest_framework.fields import ( ) from tests.models import UUIDForeignKeyTarget +if sys.version_info >= (3, 9): + from zoneinfo import ZoneInfo +else: + from backports.zoneinfo import ZoneInfo + utc = datetime.timezone.utc # Tests for helper functions. @@ -651,7 +664,7 @@ class FieldValues: """ Base class for testing valid and invalid input values. """ - def test_valid_inputs(self): + def test_valid_inputs(self, *args): """ Ensure that valid values return the expected validated data. """ @@ -659,7 +672,7 @@ class FieldValues: assert self.field.run_validation(input_value) == expected_output, \ 'input value: {}'.format(repr(input_value)) - def test_invalid_inputs(self): + def test_invalid_inputs(self, *args): """ Ensure that invalid values raise the expected validation error. """ @@ -669,7 +682,7 @@ class FieldValues: assert exc_info.value.detail == expected_failure, \ 'input value: {}'.format(repr(input_value)) - def test_outputs(self): + def test_outputs(self, *args): for output_value, expected_output in get_items(self.outputs): assert self.field.to_representation(output_value) == expected_output, \ 'output value: {}'.format(repr(output_value)) @@ -682,8 +695,24 @@ class TestBooleanField(FieldValues): Valid and invalid values for `BooleanField`. """ valid_inputs = { + 'True': True, + 'TRUE': True, + 'tRuE': True, + 't': True, + 'T': True, 'true': True, + 'on': True, + 'ON': True, + 'oN': True, + 'False': False, + 'FALSE': False, + 'fALse': False, + 'f': False, + 'F': False, 'false': False, + 'off': False, + 'OFF': False, + 'oFf': False, '1': True, '0': False, 1: True, @@ -696,8 +725,24 @@ class TestBooleanField(FieldValues): None: ['This field may not be null.'] } outputs = { + 'True': True, + 'TRUE': True, + 'tRuE': True, + 't': True, + 'T': True, 'true': True, + 'on': True, + 'ON': True, + 'oN': True, + 'False': False, + 'FALSE': False, + 'fALse': False, + 'f': False, + 'F': False, 'false': False, + 'off': False, + 'OFF': False, + 'oFf': False, '1': True, '0': False, 1: True, @@ -1208,6 +1253,17 @@ class TestMinMaxDecimalField(FieldValues): min_value=10, max_value=20 ) + def test_warning_when_not_decimal_types(self, caplog): + import logging + serializers.DecimalField( + max_digits=3, decimal_places=1, + min_value=10, max_value=20 + ) + assert caplog.record_tuples == [ + ("rest_framework.fields", logging.WARNING, "max_value in DecimalField should be Decimal type."), + ("rest_framework.fields", logging.WARNING, "min_value in DecimalField should be Decimal type.") + ] + class TestAllowEmptyStrDecimalFieldWithValidators(FieldValues): """ @@ -1505,12 +1561,12 @@ class TestTZWithDateTimeField(FieldValues): @classmethod def setup_class(cls): # use class setup method, as class-level attribute will still be evaluated even if test is skipped - kolkata = pytz.timezone('Asia/Kolkata') + kolkata = ZoneInfo('Asia/Kolkata') cls.valid_inputs = { - '2016-12-19T10:00:00': kolkata.localize(datetime.datetime(2016, 12, 19, 10)), - '2016-12-19T10:00:00+05:30': kolkata.localize(datetime.datetime(2016, 12, 19, 10)), - datetime.datetime(2016, 12, 19, 10): kolkata.localize(datetime.datetime(2016, 12, 19, 10)), + '2016-12-19T10:00:00': datetime.datetime(2016, 12, 19, 10, tzinfo=kolkata), + '2016-12-19T10:00:00+05:30': datetime.datetime(2016, 12, 19, 10, tzinfo=kolkata), + datetime.datetime(2016, 12, 19, 10): datetime.datetime(2016, 12, 19, 10, tzinfo=kolkata), } cls.invalid_inputs = {} cls.outputs = { @@ -1529,7 +1585,7 @@ class TestDefaultTZDateTimeField(TestCase): @classmethod def setup_class(cls): cls.field = serializers.DateTimeField() - cls.kolkata = pytz.timezone('Asia/Kolkata') + cls.kolkata = ZoneInfo('Asia/Kolkata') def assertUTC(self, tzinfo): """ @@ -1551,18 +1607,17 @@ class TestDefaultTZDateTimeField(TestCase): self.assertUTC(self.field.default_timezone()) -@pytest.mark.skipif(pytz is None, reason='pytz not installed') @override_settings(TIME_ZONE='UTC', USE_TZ=True) class TestCustomTimezoneForDateTimeField(TestCase): @classmethod def setup_class(cls): - cls.kolkata = pytz.timezone('Asia/Kolkata') + cls.kolkata = ZoneInfo('Asia/Kolkata') cls.date_format = '%d/%m/%Y %H:%M' def test_should_render_date_time_in_default_timezone(self): field = serializers.DateTimeField(default_timezone=self.kolkata, format=self.date_format) - dt = datetime.datetime(2018, 2, 8, 14, 15, 16, tzinfo=pytz.utc) + dt = datetime.datetime(2018, 2, 8, 14, 15, 16, tzinfo=ZoneInfo("UTC")) with override(self.kolkata): rendered_date = field.to_representation(dt) @@ -1572,6 +1627,33 @@ class TestCustomTimezoneForDateTimeField(TestCase): assert rendered_date == rendered_date_in_timezone +@pytest.mark.skipif(pytz is None, reason="As Django 4.0 has deprecated pytz, this test should eventually be able to get removed.") +class TestPytzNaiveDayLightSavingTimeTimeZoneDateTimeField(FieldValues): + """ + Invalid values for `DateTimeField` with datetime in DST shift (non-existing or ambiguous) and timezone with DST. + Timezone America/New_York has DST shift from 2017-03-12T02:00:00 to 2017-03-12T03:00:00 and + from 2017-11-05T02:00:00 to 2017-11-05T01:00:00 in 2017. + """ + valid_inputs = {} + invalid_inputs = { + '2017-03-12T02:30:00': ['Invalid datetime for the timezone "America/New_York".'], + '2017-11-05T01:30:00': ['Invalid datetime for the timezone "America/New_York".'] + } + outputs = {} + + if pytz: + class MockTimezone(pytz.BaseTzInfo): + @staticmethod + def localize(value, is_dst): + raise pytz.InvalidTimeError() + + def __str__(self): + return 'America/New_York' + + field = serializers.DateTimeField(default_timezone=MockTimezone()) + + +@patch('rest_framework.utils.timezone.datetime_ambiguous', return_value=True) class TestNaiveDayLightSavingTimeTimeZoneDateTimeField(FieldValues): """ Invalid values for `DateTimeField` with datetime in DST shift (non-existing or ambiguous) and timezone with DST. @@ -1585,15 +1667,11 @@ class TestNaiveDayLightSavingTimeTimeZoneDateTimeField(FieldValues): } outputs = {} - class MockTimezone(pytz.BaseTzInfo): - @staticmethod - def localize(value, is_dst): - raise pytz.InvalidTimeError() - + class MockZoneInfoTimezone(datetime.tzinfo): def __str__(self): return 'America/New_York' - field = serializers.DateTimeField(default_timezone=MockTimezone()) + field = serializers.DateTimeField(default_timezone=MockZoneInfoTimezone()) class TestTimeField(FieldValues): @@ -1797,6 +1875,54 @@ class TestChoiceField(FieldValues): field.run_validation(2) assert exc_info.value.detail == ['"2" is not a valid choice.'] + def test_integer_choices(self): + class ChoiceCase(IntegerChoices): + first = auto() + second = auto() + # Enum validate + choices = [ + (ChoiceCase.first, "1"), + (ChoiceCase.second, "2") + ] + + field = serializers.ChoiceField(choices=choices) + assert field.run_validation(1) == 1 + assert field.run_validation(ChoiceCase.first) == 1 + assert field.run_validation("1") == 1 + + choices = [ + (ChoiceCase.first.value, "1"), + (ChoiceCase.second.value, "2") + ] + + field = serializers.ChoiceField(choices=choices) + assert field.run_validation(1) == 1 + assert field.run_validation(ChoiceCase.first) == 1 + assert field.run_validation("1") == 1 + + def test_text_choices(self): + class ChoiceCase(TextChoices): + first = auto() + second = auto() + # Enum validate + choices = [ + (ChoiceCase.first, "first"), + (ChoiceCase.second, "second") + ] + + field = serializers.ChoiceField(choices=choices) + assert field.run_validation(ChoiceCase.first) == "first" + assert field.run_validation("first") == "first" + + choices = [ + (ChoiceCase.first.value, "first"), + (ChoiceCase.second.value, "second") + ] + + field = serializers.ChoiceField(choices=choices) + assert field.run_validation(ChoiceCase.first) == "first" + assert field.run_validation("first") == "first" + class TestChoiceFieldWithType(FieldValues): """ diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 4abc0fc07..1bdc8697c 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -324,6 +324,13 @@ class TestSimpleMetadataFieldInfo(TestCase): ) assert 'choices' not in field_info + def test_decimal_field_info_type(self): + options = metadata.SimpleMetadata() + field_info = options.get_field_info(serializers.DecimalField(max_digits=18, decimal_places=4)) + assert field_info['type'] == 'decimal' + assert field_info['max_digits'] == 18 + assert field_info['decimal_places'] == 4 + class TestModelSerializerMetadata(TestCase): def test_read_only_primary_key_related_field(self): diff --git a/tests/test_model_serializer.py b/tests/test_model_serializer.py index 419eae632..c5ac888f5 100644 --- a/tests/test_model_serializer.py +++ b/tests/test_model_serializer.py @@ -10,7 +10,6 @@ import decimal import json # noqa import sys import tempfile -from collections import OrderedDict import django import pytest @@ -762,7 +761,7 @@ class TestRelationalFieldDisplayValue(TestCase): fields = '__all__' serializer = TestSerializer() - expected = OrderedDict([(1, 'Red Color'), (2, 'Yellow Color'), (3, 'Green Color')]) + expected = {1: 'Red Color', 2: 'Yellow Color', 3: 'Green Color'} self.assertEqual(serializer.fields['color'].choices, expected) def test_custom_display_value(self): @@ -778,7 +777,7 @@ class TestRelationalFieldDisplayValue(TestCase): fields = '__all__' serializer = TestSerializer() - expected = OrderedDict([(1, 'My Red Color'), (2, 'My Yellow Color'), (3, 'My Green Color')]) + expected = {1: 'My Red Color', 2: 'My Yellow Color', 3: 'My Green Color'} self.assertEqual(serializer.fields['color'].choices, expected) diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 2812c4489..d606986ab 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -632,6 +632,24 @@ class CursorPaginationTestsMixin: ordering = self.pagination.get_ordering(request, [], MockView()) assert ordering == ('created',) + def test_use_with_ordering_filter_without_ordering_default_value(self): + class MockView: + filter_backends = (filters.OrderingFilter,) + ordering_fields = ['username', 'created'] + + request = Request(factory.get('/')) + ordering = self.pagination.get_ordering(request, [], MockView()) + # it gets the value of `ordering` provided by CursorPagination + assert ordering == ('created',) + + request = Request(factory.get('/', {'ordering': 'username'})) + ordering = self.pagination.get_ordering(request, [], MockView()) + assert ordering == ('username',) + + request = Request(factory.get('/', {'ordering': 'invalid'})) + ordering = self.pagination.get_ordering(request, [], MockView()) + assert ordering == ('created',) + def test_cursor_pagination(self): (previous, current, next, previous_url, next_url) = self.get_pages('/') @@ -951,17 +969,24 @@ class TestCursorPagination(CursorPaginationTestsMixin): def __init__(self, items): self.items = items - def filter(self, created__gt=None, created__lt=None): + def filter(self, q): + q_args = dict(q.deconstruct()[1]) + if not q_args: + # django 3.0.x artifact + q_args = dict(q.deconstruct()[2]) + created__gt = q_args.get('created__gt') + created__lt = q_args.get('created__lt') + if created__gt is not None: return MockQuerySet([ item for item in self.items - if item.created > int(created__gt) + if item.created is None or item.created > int(created__gt) ]) assert created__lt is not None return MockQuerySet([ item for item in self.items - if item.created < int(created__lt) + if item.created is None or item.created < int(created__lt) ]) def order_by(self, *ordering): @@ -1080,6 +1105,127 @@ class TestCursorPaginationWithValueQueryset(CursorPaginationTestsMixin, TestCase return (previous, current, next, previous_url, next_url) +class NullableCursorPaginationModel(models.Model): + created = models.IntegerField(null=True) + + +class TestCursorPaginationWithNulls(TestCase): + """ + Unit tests for `pagination.CursorPagination` with ordering on a nullable field. + """ + + def setUp(self): + class ExamplePagination(pagination.CursorPagination): + page_size = 1 + ordering = 'created' + + self.pagination = ExamplePagination() + data = [ + None, None, 3, 4 + ] + for idx in data: + NullableCursorPaginationModel.objects.create(created=idx) + + self.queryset = NullableCursorPaginationModel.objects.all() + + get_pages = TestCursorPagination.get_pages + + def test_ascending(self): + """Test paginating one row at a time, current should go 1, 2, 3, 4, 3, 2, 1.""" + (previous, current, next, previous_url, next_url) = self.get_pages('/') + + assert previous is None + assert current == [None] + assert next == [None] + + (previous, current, next, previous_url, next_url) = self.get_pages(next_url) + + assert previous == [None] + assert current == [None] + assert next == [3] + + (previous, current, next, previous_url, next_url) = self.get_pages(next_url) + + assert previous == [3] # [None] paging artifact documented at https://github.com/ddelange/django-rest-framework/blob/3.14.0/rest_framework/pagination.py#L789 + assert current == [3] + assert next == [4] + + (previous, current, next, previous_url, next_url) = self.get_pages(next_url) + + assert previous == [3] + assert current == [4] + assert next is None + assert next_url is None + + (previous, current, next, previous_url, next_url) = self.get_pages(previous_url) + + assert previous == [None] + assert current == [3] + assert next == [4] + + (previous, current, next, previous_url, next_url) = self.get_pages(previous_url) + + assert previous == [None] + assert current == [None] + assert next == [None] # [3] paging artifact documented at https://github.com/ddelange/django-rest-framework/blob/3.14.0/rest_framework/pagination.py#L731 + + (previous, current, next, previous_url, next_url) = self.get_pages(previous_url) + + assert previous is None + assert current == [None] + assert next == [None] + + def test_descending(self): + """Test paginating one row at a time, current should go 4, 3, 2, 1, 2, 3, 4.""" + self.pagination.ordering = ('-created',) + (previous, current, next, previous_url, next_url) = self.get_pages('/') + + assert previous is None + assert current == [4] + assert next == [3] + + (previous, current, next, previous_url, next_url) = self.get_pages(next_url) + + assert previous == [None] # [4] paging artifact + assert current == [3] + assert next == [None] + + (previous, current, next, previous_url, next_url) = self.get_pages(next_url) + + assert previous == [None] # [3] paging artifact + assert current == [None] + assert next == [None] + + (previous, current, next, previous_url, next_url) = self.get_pages(next_url) + + assert previous == [None] + assert current == [None] + assert next is None + assert next_url is None + + (previous, current, next, previous_url, next_url) = self.get_pages(previous_url) + + assert previous == [3] + assert current == [None] + assert next == [None] + + (previous, current, next, previous_url, next_url) = self.get_pages(previous_url) + + assert previous == [None] + assert current == [3] + assert next == [3] # [4] paging artifact documented at https://github.com/ddelange/django-rest-framework/blob/3.14.0/rest_framework/pagination.py#L731 + + # skip back artifact + (previous, current, next, previous_url, next_url) = self.get_pages(previous_url) + (previous, current, next, previous_url, next_url) = self.get_pages(previous_url) + + (previous, current, next, previous_url, next_url) = self.get_pages(previous_url) + + assert previous is None + assert current == [4] + assert next == [3] + + def test_get_displayed_page_numbers(): """ Test our contextual page display function. diff --git a/tests/test_relations.py b/tests/test_relations.py index 7a4db1c48..b9ab15789 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -342,6 +342,142 @@ class TestSlugRelatedField(APISimpleTestCase): field.to_internal_value(self.instance.name) +class TestNestedSlugRelatedField(APISimpleTestCase): + def setUp(self): + self.queryset = MockQueryset([ + MockObject( + pk=1, name='foo', nested=MockObject( + pk=2, name='bar', nested=MockObject( + pk=7, name="foobar" + ) + ) + ), + MockObject( + pk=3, name='hello', nested=MockObject( + pk=4, name='world', nested=MockObject( + pk=8, name="helloworld" + ) + ) + ), + MockObject( + pk=5, name='harry', nested=MockObject( + pk=6, name='potter', nested=MockObject( + pk=9, name="harrypotter" + ) + ) + ) + ]) + self.instance = self.queryset.items[2] + self.field = serializers.SlugRelatedField( + slug_field='name', queryset=self.queryset + ) + self.nested_field = serializers.SlugRelatedField( + slug_field='nested__name', queryset=self.queryset + ) + + self.nested_nested_field = serializers.SlugRelatedField( + slug_field='nested__nested__name', queryset=self.queryset + ) + + # testing nested inside nested relations + def test_slug_related_nested_nested_lookup_exists(self): + instance = self.nested_nested_field.to_internal_value( + self.instance.nested.nested.name + ) + assert instance is self.instance + + def test_slug_related_nested_nested_lookup_does_not_exist(self): + with pytest.raises(serializers.ValidationError) as excinfo: + self.nested_nested_field.to_internal_value('doesnotexist') + msg = excinfo.value.detail[0] + assert msg == \ + 'Object with nested__nested__name=doesnotexist does not exist.' + + def test_slug_related_nested_nested_lookup_invalid_type(self): + with pytest.raises(serializers.ValidationError) as excinfo: + self.nested_nested_field.to_internal_value(BadType()) + msg = excinfo.value.detail[0] + assert msg == 'Invalid value.' + + def test_nested_nested_representation(self): + representation =\ + self.nested_nested_field.to_representation(self.instance) + assert representation == self.instance.nested.nested.name + + def test_nested_nested_overriding_get_queryset(self): + qs = self.queryset + + class NoQuerySetSlugRelatedField(serializers.SlugRelatedField): + def get_queryset(self): + return qs + + field = NoQuerySetSlugRelatedField(slug_field='nested__nested__name') + field.to_internal_value(self.instance.nested.nested.name) + + # testing nested relations + def test_slug_related_nested_lookup_exists(self): + instance = \ + self.nested_field.to_internal_value(self.instance.nested.name) + assert instance is self.instance + + def test_slug_related_nested_lookup_does_not_exist(self): + with pytest.raises(serializers.ValidationError) as excinfo: + self.nested_field.to_internal_value('doesnotexist') + msg = excinfo.value.detail[0] + assert msg == 'Object with nested__name=doesnotexist does not exist.' + + def test_slug_related_nested_lookup_invalid_type(self): + with pytest.raises(serializers.ValidationError) as excinfo: + self.nested_field.to_internal_value(BadType()) + msg = excinfo.value.detail[0] + assert msg == 'Invalid value.' + + def test_nested_representation(self): + representation = self.nested_field.to_representation(self.instance) + assert representation == self.instance.nested.name + + def test_nested_overriding_get_queryset(self): + qs = self.queryset + + class NoQuerySetSlugRelatedField(serializers.SlugRelatedField): + def get_queryset(self): + return qs + + field = NoQuerySetSlugRelatedField(slug_field='nested__name') + field.to_internal_value(self.instance.nested.name) + + # testing non-nested relations + def test_slug_related_lookup_exists(self): + instance = self.field.to_internal_value(self.instance.name) + assert instance is self.instance + + def test_slug_related_lookup_does_not_exist(self): + with pytest.raises(serializers.ValidationError) as excinfo: + self.field.to_internal_value('doesnotexist') + msg = excinfo.value.detail[0] + assert msg == 'Object with name=doesnotexist does not exist.' + + def test_slug_related_lookup_invalid_type(self): + with pytest.raises(serializers.ValidationError) as excinfo: + self.field.to_internal_value(BadType()) + msg = excinfo.value.detail[0] + assert msg == 'Invalid value.' + + def test_representation(self): + representation = self.field.to_representation(self.instance) + assert representation == self.instance.name + + def test_overriding_get_queryset(self): + qs = self.queryset + + class NoQuerySetSlugRelatedField(serializers.SlugRelatedField): + def get_queryset(self): + return qs + + field = NoQuerySetSlugRelatedField(slug_field='name') + field.to_internal_value(self.instance.name) + + class TestManyRelatedField(APISimpleTestCase): def setUp(self): self.instance = MockObject(pk=1, name='foo') diff --git a/tests/test_renderers.py b/tests/test_renderers.py index 8271608e1..247737576 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -1,5 +1,4 @@ import re -from collections import OrderedDict from collections.abc import MutableMapping import pytest @@ -457,12 +456,12 @@ class CacheRenderTest(TestCase): class TestJSONIndentationStyles: def test_indented(self): renderer = JSONRenderer() - data = OrderedDict([('a', 1), ('b', 2)]) + data = {"a": 1, "b": 2} assert renderer.render(data) == b'{"a":1,"b":2}' def test_compact(self): renderer = JSONRenderer() - data = OrderedDict([('a', 1), ('b', 2)]) + data = {"a": 1, "b": 2} context = {'indent': 4} assert ( renderer.render(data, renderer_context=context) == @@ -472,7 +471,7 @@ class TestJSONIndentationStyles: def test_long_form(self): renderer = JSONRenderer() renderer.compact = False - data = OrderedDict([('a', 1), ('b', 2)]) + data = {"a": 1, "b": 2} assert renderer.render(data) == b'{"a": 1, "b": 2}' @@ -634,6 +633,9 @@ class BrowsableAPIRendererTests(URLPatternsTestCase): class AuthExampleViewSet(ExampleViewSet): permission_classes = [permissions.IsAuthenticated] + class SimpleSerializer(serializers.Serializer): + name = serializers.CharField() + router = SimpleRouter() router.register('examples', ExampleViewSet, basename='example') router.register('auth-examples', AuthExampleViewSet, basename='auth-example') @@ -641,6 +643,62 @@ class BrowsableAPIRendererTests(URLPatternsTestCase): def setUp(self): self.renderer = BrowsableAPIRenderer() + self.renderer.accepted_media_type = '' + self.renderer.renderer_context = {} + + def test_render_form_for_serializer(self): + with self.subTest('Serializer'): + serializer = BrowsableAPIRendererTests.SimpleSerializer(data={'name': 'Name'}) + form = self.renderer.render_form_for_serializer(serializer) + assert isinstance(form, str), 'Must return form for serializer' + + with self.subTest('ListSerializer'): + list_serializer = BrowsableAPIRendererTests.SimpleSerializer(data=[{'name': 'Name'}], many=True) + form = self.renderer.render_form_for_serializer(list_serializer) + assert form is None, 'Must not return form for list serializer' + + def test_get_raw_data_form(self): + with self.subTest('Serializer'): + class DummyGenericViewsetLike(APIView): + def get_serializer(self, **kwargs): + return BrowsableAPIRendererTests.SimpleSerializer(**kwargs) + + def get(self, request): + response = Response() + response.view = self + return response + + post = get + + view = DummyGenericViewsetLike.as_view() + _request = APIRequestFactory().get('/') + request = Request(_request) + response = view(_request) + view = response.view + + raw_data_form = self.renderer.get_raw_data_form({'name': 'Name'}, view, 'POST', request) + assert raw_data_form['_content'].initial == '{\n "name": ""\n}' + + with self.subTest('ListSerializer'): + class DummyGenericViewsetLike(APIView): + def get_serializer(self, **kwargs): + return BrowsableAPIRendererTests.SimpleSerializer(many=True, **kwargs) # returns ListSerializer + + def get(self, request): + response = Response() + response.view = self + return response + + post = get + + view = DummyGenericViewsetLike.as_view() + _request = APIRequestFactory().get('/') + request = Request(_request) + response = view(_request) + view = response.view + + raw_data_form = self.renderer.get_raw_data_form([{'name': 'Name'}], view, 'POST', request) + assert raw_data_form['_content'].initial == '[\n {\n "name": ""\n }\n]' def test_get_description_returns_empty_string_for_401_and_403_statuses(self): assert self.renderer.get_description({}, status_code=401) == '' diff --git a/tests/test_serializer.py b/tests/test_serializer.py index 9934a0780..382900dea 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -2,6 +2,7 @@ import inspect import pickle import re import sys +import unittest from collections import ChainMap from collections.abc import Mapping @@ -764,3 +765,84 @@ class Test8301Regression: assert (s.data | {}).__class__ == s.data.__class__ assert ({} | s.data).__class__ == s.data.__class__ + + +class TestSetValueMethod: + # Serializer.set_value() modifies the first parameter in-place. + + s = serializers.Serializer() + + def test_no_keys(self): + ret = {'a': 1} + self.s.set_value(ret, [], {'b': 2}) + assert ret == {'a': 1, 'b': 2} + + def test_one_key(self): + ret = {'a': 1} + self.s.set_value(ret, ['x'], 2) + assert ret == {'a': 1, 'x': 2} + + def test_nested_key(self): + ret = {'a': 1} + self.s.set_value(ret, ['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, + ) diff --git a/tests/test_validators.py b/tests/test_validators.py index 35fef6f26..1cf42ed07 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,4 +1,5 @@ import datetime +from unittest.mock import MagicMock import pytest from django.db import DataError, models @@ -787,3 +788,13 @@ class ValidatorsTests(TestCase): validator.filter_queryset( attrs=None, queryset=None, field_name='', date_field_name='' ) + + def test_equality_operator(self): + mock_queryset = MagicMock() + validator = BaseUniqueForValidator(queryset=mock_queryset, field='foo', + date_field='bar') + validator2 = BaseUniqueForValidator(queryset=mock_queryset, field='foo', + date_field='bar') + assert validator == validator2 + validator2.date_field = "bar2" + assert validator != validator2 diff --git a/tests/test_versioning.py b/tests/test_versioning.py index 93f61d2be..1ccecae0b 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -152,6 +152,8 @@ class TestURLReversing(URLPatternsTestCase, APITestCase): path('v1/', include((included, 'v1'), namespace='v1')), path('another/', dummy_view, name='another'), re_path(r'^(?P[v1|v2]+)/another/$', dummy_view, name='another'), + re_path(r'^(?P.+)/unversioned/$', dummy_view, name='unversioned'), + ] def test_reverse_unversioned(self): @@ -198,6 +200,14 @@ class TestURLReversing(URLPatternsTestCase, APITestCase): response = view(request) assert response.data == {'url': 'http://testserver/another/'} + # Test fallback when kwargs is not None + request = factory.get('/v1/endpoint/') + request.versioning_scheme = scheme() + request.version = 'v1' + + reversed_url = reverse('unversioned', request=request, kwargs={'foo': 'bar'}) + assert reversed_url == 'http://testserver/bar/unversioned/' + def test_reverse_namespace_versioning(self): class FakeResolverMatch(ResolverMatch): namespace = 'v1' @@ -262,7 +272,7 @@ class TestInvalidVersion: assert response.status_code == status.HTTP_404_NOT_FOUND -class TestAllowedAndDefaultVersion: +class TestAcceptHeaderAllowedAndDefaultVersion: def test_missing_without_default(self): scheme = versioning.AcceptHeaderVersioning view = AllowedVersionsView.as_view(versioning_class=scheme) @@ -308,6 +318,97 @@ class TestAllowedAndDefaultVersion: assert response.data == {'version': 'v2'} +class TestNamespaceAllowedAndDefaultVersion: + def test_no_namespace_without_default(self): + class FakeResolverMatch: + namespace = None + + scheme = versioning.NamespaceVersioning + view = AllowedVersionsView.as_view(versioning_class=scheme) + + request = factory.get('/endpoint/') + request.resolver_match = FakeResolverMatch + response = view(request) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_no_namespace_with_default(self): + class FakeResolverMatch: + namespace = None + + scheme = versioning.NamespaceVersioning + view = AllowedAndDefaultVersionsView.as_view(versioning_class=scheme) + + request = factory.get('/endpoint/') + request.resolver_match = FakeResolverMatch + response = view(request) + assert response.status_code == status.HTTP_200_OK + assert response.data == {'version': 'v2'} + + def test_no_match_without_default(self): + class FakeResolverMatch: + namespace = 'no_match' + + scheme = versioning.NamespaceVersioning + view = AllowedVersionsView.as_view(versioning_class=scheme) + + request = factory.get('/endpoint/') + request.resolver_match = FakeResolverMatch + response = view(request) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_no_match_with_default(self): + class FakeResolverMatch: + namespace = 'no_match' + + scheme = versioning.NamespaceVersioning + view = AllowedAndDefaultVersionsView.as_view(versioning_class=scheme) + + request = factory.get('/endpoint/') + request.resolver_match = FakeResolverMatch + response = view(request) + assert response.status_code == status.HTTP_200_OK + assert response.data == {'version': 'v2'} + + def test_with_default(self): + class FakeResolverMatch: + namespace = 'v1' + + scheme = versioning.NamespaceVersioning + view = AllowedAndDefaultVersionsView.as_view(versioning_class=scheme) + + request = factory.get('/endpoint/') + request.resolver_match = FakeResolverMatch + response = view(request) + assert response.status_code == status.HTTP_200_OK + assert response.data == {'version': 'v1'} + + def test_no_match_without_default_but_none_allowed(self): + class FakeResolverMatch: + namespace = 'no_match' + + scheme = versioning.NamespaceVersioning + view = AllowedWithNoneVersionsView.as_view(versioning_class=scheme) + + request = factory.get('/endpoint/') + request.resolver_match = FakeResolverMatch + response = view(request) + assert response.status_code == status.HTTP_200_OK + assert response.data == {'version': None} + + def test_no_match_with_default_and_none_allowed(self): + class FakeResolverMatch: + namespace = 'no_match' + + scheme = versioning.NamespaceVersioning + view = AllowedWithNoneAndDefaultVersionsView.as_view(versioning_class=scheme) + + request = factory.get('/endpoint/') + request.resolver_match = FakeResolverMatch + response = view(request) + assert response.status_code == status.HTTP_200_OK + assert response.data == {'version': 'v2'} + + class TestHyperlinkedRelatedField(URLPatternsTestCase, APITestCase): included = [ path('namespaced//', dummy_pk_view, name='namespaced'), diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index 8842b0b1c..8e439c86e 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -1,4 +1,3 @@ -from collections import OrderedDict from functools import wraps import pytest @@ -261,11 +260,11 @@ class GetExtraActionUrlMapTests(TestCase): response = self.client.get('/api/actions/') view = response.view - expected = OrderedDict([ - ('Custom list action', 'http://testserver/api/actions/custom_list_action/'), - ('List action', 'http://testserver/api/actions/list_action/'), - ('Wrapped list action', 'http://testserver/api/actions/wrapped_list_action/'), - ]) + expected = { + 'Custom list action': 'http://testserver/api/actions/custom_list_action/', + 'List action': 'http://testserver/api/actions/list_action/', + 'Wrapped list action': 'http://testserver/api/actions/wrapped_list_action/', + } self.assertEqual(view.get_extra_action_url_map(), expected) @@ -273,28 +272,28 @@ class GetExtraActionUrlMapTests(TestCase): response = self.client.get('/api/actions/1/') view = response.view - expected = OrderedDict([ - ('Custom detail action', 'http://testserver/api/actions/1/custom_detail_action/'), - ('Detail action', 'http://testserver/api/actions/1/detail_action/'), - ('Wrapped detail action', 'http://testserver/api/actions/1/wrapped_detail_action/'), + expected = { + 'Custom detail action': 'http://testserver/api/actions/1/custom_detail_action/', + 'Detail action': 'http://testserver/api/actions/1/detail_action/', + 'Wrapped detail action': 'http://testserver/api/actions/1/wrapped_detail_action/', # "Unresolvable detail action" excluded, since it's not resolvable - ]) + } self.assertEqual(view.get_extra_action_url_map(), expected) def test_uninitialized_view(self): - self.assertEqual(ActionViewSet().get_extra_action_url_map(), OrderedDict()) + self.assertEqual(ActionViewSet().get_extra_action_url_map(), {}) def test_action_names(self): # Action 'name' and 'suffix' kwargs should be respected response = self.client.get('/api/names/1/') view = response.view - expected = OrderedDict([ - ('Custom Name', 'http://testserver/api/names/1/named_action/'), - ('Action Names Custom Suffix', 'http://testserver/api/names/1/suffixed_action/'), - ('Unnamed action', 'http://testserver/api/names/1/unnamed_action/'), - ]) + expected = { + 'Custom Name': 'http://testserver/api/names/1/named_action/', + 'Action Names Custom Suffix': 'http://testserver/api/names/1/suffixed_action/', + 'Unnamed action': 'http://testserver/api/names/1/unnamed_action/', + } self.assertEqual(view.get_extra_action_url_map(), expected) diff --git a/tests/utils.py b/tests/utils.py index 06e5b9abe..4ceb35309 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,3 +1,5 @@ +from operator import attrgetter + from django.core.exceptions import ObjectDoesNotExist from django.urls import NoReverseMatch @@ -26,7 +28,7 @@ class MockQueryset: def get(self, **lookup): for item in self.items: if all([ - getattr(item, key, None) == value + attrgetter(key.replace('__', '.'))(item) == value for key, value in lookup.items() ]): return item @@ -39,6 +41,7 @@ class BadType: will raise a `TypeError`, as occurs in Django when making queryset lookups with an incorrect type for the lookup value. """ + def __eq__(self): raise TypeError() diff --git a/tox.ini b/tox.ini index 7027612e0..2b8733d7d 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,7 @@ deps = django32: Django>=3.2,<4.0 django40: Django>=4.0,<4.1 django41: Django>=4.1,<4.2 - django42: Django>=4.2b1,<5.0 + django42: Django>=4.2,<5.0 djangomain: https://github.com/django/django/archive/main.tar.gz -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt