From 8637b3d62406204c586867b3bd60594c287c5cd6 Mon Sep 17 00:00:00 2001 From: decadenza <30215028+decadenza@users.noreply.github.com> Date: Thu, 30 Jan 2025 10:03:11 +0000 Subject: [PATCH 1/6] Improved description of allowed throttling rates (#9640) --- docs/api-guide/throttling.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index 4c58fa713..0ea8b4158 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -45,7 +45,7 @@ The default throttling policy may be set globally, using the `DEFAULT_THROTTLE_C } } -The rate descriptions used in `DEFAULT_THROTTLE_RATES` may include `second`, `minute`, `hour` or `day` as the throttle period. +The rates used in `DEFAULT_THROTTLE_RATES` can be specified over a period of second, minute, hour or day. The period must be specified after the `/` separator using `s`, `m`, `h` or `d`, respectively. For increased clarity, extended units such as `second`, `minute`, `hour`, `day` or even abbreviations like `sec`, `min`, `hr` are allowed, as only the first character is relevant to identify the rate. You can also set the throttling policy on a per-view or per-viewset basis, using the `APIView` class-based views. From 28d0261afcd6702900512e00c37f4e264c117d83 Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Sat, 1 Feb 2025 06:24:43 +0000 Subject: [PATCH 2/6] Add missing ignore_outcome=true for the Python 3.13 - Django main combination (#9637) --- tox.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tox.ini b/tox.ini index f565a1281..49833fe68 100644 --- a/tox.ini +++ b/tox.ini @@ -52,3 +52,6 @@ ignore_outcome = true [testenv:py312-djangomain] ignore_outcome = true + +[testenv:py313-djangomain] +ignore_outcome = true From f30c0e2eedda410a7e6a0d1b351377a9084361b4 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Fri, 14 Feb 2025 14:49:56 +0600 Subject: [PATCH 3/6] add django 5.2a1 initial support (#9634) * add django 5.2a1 for initial testing * declare django 5.2 support * change in docs --- README.md | 2 +- docs/index.md | 2 +- setup.py | 1 + tox.ini | 9 +++++---- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6e62fb39a..95fb1b012 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Some reasons you might want to use REST framework: # Requirements * Python 3.8+ -* Django 4.2, 5.0, 5.1 +* Django 4.2, 5.0, 5.1, 5.2 We **highly recommend** and only officially support the latest patch release of each Python and Django series. diff --git a/docs/index.md b/docs/index.md index ebeab3db3..2638b05fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -87,7 +87,7 @@ continued development by **[signing up for a paid plan][funding]**. REST framework requires the following: -* Django (4.2, 5.0, 5.1) +* Django (4.2, 5.0, 5.1, 5.2) * Python (3.8, 3.9, 3.10, 3.11, 3.12, 3.13) We **highly recommend** and only officially support the latest patch release of diff --git a/setup.py b/setup.py index 67904ec61..18f62470c 100755 --- a/setup.py +++ b/setup.py @@ -92,6 +92,7 @@ setup( 'Framework :: Django :: 4.2', 'Framework :: Django :: 5.0', 'Framework :: Django :: 5.1', + 'Framework :: Django :: 5.2', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', diff --git a/tox.ini b/tox.ini index 49833fe68..52a763ef5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,10 @@ [tox] envlist = {py38,py39}-{django42} - {py310}-{django42,django50,django51,djangomain} - {py311}-{django42,django50,django51,djangomain} - {py312}-{django42,django50,django51,djangomain} - {py313}-{django51,djangomain} + {py310}-{django42,django51,django52,djangomain} + {py311}-{django42,django51,django52,djangomain} + {py312}-{django42,django51,django52,djangomain} + {py313}-{django51,django52,djangomain} base dist docs @@ -19,6 +19,7 @@ deps = django42: Django>=4.2,<5.0 django50: Django>=5.0,<5.1 django51: Django>=5.1,<5.2 + django52: Django>=5.2a1,<6.0 djangomain: https://github.com/django/django/archive/main.tar.gz -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt From 17e95604f5056c4f5ceb40ca30cd349acd74c101 Mon Sep 17 00:00:00 2001 From: Konstantin Alekseev Date: Mon, 17 Feb 2025 10:01:32 +0200 Subject: [PATCH 4/6] Fix unique together validator doesn't respect condition's fields (#9360) --- rest_framework/compat.py | 36 +++++++++++++++++ rest_framework/serializers.py | 40 +++++++++++-------- rest_framework/validators.py | 30 +++++++++++--- tests/test_validators.py | 74 +++++++++++++++++++++++++++-------- 4 files changed, 140 insertions(+), 40 deletions(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 27c5632be..ff21bacff 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -3,6 +3,9 @@ The `compat` module provides support for backwards compatibility with older versions of Django/Python, and compatibility wrappers around optional packages. """ import django +from django.db import models +from django.db.models.constants import LOOKUP_SEP +from django.db.models.sql.query import Node from django.views.generic import View @@ -157,6 +160,10 @@ if django.VERSION >= (5, 1): # 1) the list of validators and 2) the error message. Starting from # Django 5.1 ip_address_validators only returns the list of validators from django.core.validators import ip_address_validators + + def get_referenced_base_fields_from_q(q): + return q.referenced_base_fields + else: # Django <= 5.1: create a compatibility shim for ip_address_validators from django.core.validators import \ @@ -165,6 +172,35 @@ else: def ip_address_validators(protocol, unpack_ipv4): return _ip_address_validators(protocol, unpack_ipv4)[0] + # Django < 5.1: create a compatibility shim for Q.referenced_base_fields + # https://github.com/django/django/blob/5.1a1/django/db/models/query_utils.py#L179 + def _get_paths_from_expression(expr): + if isinstance(expr, models.F): + yield expr.name + elif hasattr(expr, 'flatten'): + for child in expr.flatten(): + if isinstance(child, models.F): + yield child.name + elif isinstance(child, models.Q): + yield from _get_children_from_q(child) + + def _get_children_from_q(q): + for child in q.children: + if isinstance(child, Node): + yield from _get_children_from_q(child) + elif isinstance(child, tuple): + lhs, rhs = child + yield lhs + if hasattr(rhs, 'resolve_expression'): + yield from _get_paths_from_expression(rhs) + elif hasattr(child, 'resolve_expression'): + yield from _get_paths_from_expression(child) + + def get_referenced_base_fields_from_q(q): + return { + child.split(LOOKUP_SEP, 1)[0] for child in _get_children_from_q(q) + } + # `separators` argument to `json.dumps()` differs between 2.x and 3.x # See: https://bugs.python.org/issue22767 diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index f37bd3a3d..0b87aa8fc 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -26,7 +26,9 @@ from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from rest_framework.compat import postgres_fields +from rest_framework.compat import ( + get_referenced_base_fields_from_q, postgres_fields +) from rest_framework.exceptions import ErrorDetail, ValidationError from rest_framework.fields import get_error_detail from rest_framework.settings import api_settings @@ -1425,20 +1427,20 @@ class ModelSerializer(Serializer): def get_unique_together_constraints(self, model): """ - Returns iterator of (fields, queryset), each entry describes an unique together - constraint on `fields` in `queryset`. + Returns iterator of (fields, queryset, condition_fields, condition), + each entry describes an unique together constraint on `fields` in `queryset` + with respect of constraint's `condition`. """ for parent_class in [model] + list(model._meta.parents): for unique_together in parent_class._meta.unique_together: - yield unique_together, model._default_manager + yield unique_together, model._default_manager, [], None for constraint in parent_class._meta.constraints: if isinstance(constraint, models.UniqueConstraint) and len(constraint.fields) > 1: - yield ( - constraint.fields, - model._default_manager - if constraint.condition is None - else model._default_manager.filter(constraint.condition) - ) + if constraint.condition is None: + condition_fields = [] + else: + condition_fields = list(get_referenced_base_fields_from_q(constraint.condition)) + yield (constraint.fields, model._default_manager, condition_fields, constraint.condition) def get_uniqueness_extra_kwargs(self, field_names, declared_fields, extra_kwargs): """ @@ -1470,9 +1472,10 @@ class ModelSerializer(Serializer): # Include each of the `unique_together` and `UniqueConstraint` field names, # so long as all the field names are included on the serializer. - for unique_together_list, queryset in self.get_unique_together_constraints(model): - if set(field_names).issuperset(unique_together_list): - unique_constraint_names |= set(unique_together_list) + for unique_together_list, queryset, condition_fields, condition in self.get_unique_together_constraints(model): + unique_together_list_and_condition_fields = set(unique_together_list) | set(condition_fields) + if set(field_names).issuperset(unique_together_list_and_condition_fields): + unique_constraint_names |= unique_together_list_and_condition_fields # Now we have all the field names that have uniqueness constraints # applied, we can add the extra 'required=...' or 'default=...' @@ -1594,12 +1597,13 @@ class ModelSerializer(Serializer): # Note that we make sure to check `unique_together` both on the # base model class, but also on any parent classes. validators = [] - for unique_together, queryset in self.get_unique_together_constraints(self.Meta.model): + for unique_together, queryset, condition_fields, condition in self.get_unique_together_constraints(self.Meta.model): # Skip if serializer does not map to all unique together sources - if not set(source_map).issuperset(unique_together): + unique_together_and_condition_fields = set(unique_together) | set(condition_fields) + if not set(source_map).issuperset(unique_together_and_condition_fields): continue - for source in unique_together: + for source in unique_together_and_condition_fields: assert len(source_map[source]) == 1, ( "Unable to create `UniqueTogetherValidator` for " "`{model}.{field}` as `{serializer}` has multiple " @@ -1618,7 +1622,9 @@ class ModelSerializer(Serializer): field_names = tuple(source_map[f][0] for f in unique_together) validator = UniqueTogetherValidator( queryset=queryset, - fields=field_names + fields=field_names, + condition_fields=tuple(source_map[f][0] for f in condition_fields), + condition=condition, ) validators.append(validator) return validators diff --git a/rest_framework/validators.py b/rest_framework/validators.py index 71ebc2ca9..a152c6362 100644 --- a/rest_framework/validators.py +++ b/rest_framework/validators.py @@ -6,7 +6,9 @@ This gives us better separation of concerns, allows us to use single-step object creation, and makes it possible to switch between using the implicit `ModelSerializer` class and an equivalent explicit `Serializer` class. """ +from django.core.exceptions import FieldError from django.db import DataError +from django.db.models import Exists from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ValidationError @@ -23,6 +25,17 @@ def qs_exists(queryset): return False +def qs_exists_with_condition(queryset, condition, against): + if condition is None: + return qs_exists(queryset) + try: + # use the same query as UniqueConstraint.validate + # https://github.com/django/django/blob/7ba2a0db20c37a5b1500434ca4ed48022311c171/django/db/models/constraints.py#L672 + return (condition & Exists(queryset.filter(condition))).check(against) + except (TypeError, ValueError, DataError, FieldError): + return False + + def qs_filter(queryset, **kwargs): try: return queryset.filter(**kwargs) @@ -99,10 +112,12 @@ class UniqueTogetherValidator: missing_message = _('This field is required.') requires_context = True - def __init__(self, queryset, fields, message=None): + def __init__(self, queryset, fields, message=None, condition_fields=None, condition=None): self.queryset = queryset self.fields = fields self.message = message or self.message + self.condition_fields = [] if condition_fields is None else condition_fields + self.condition = condition def enforce_required_fields(self, attrs, serializer): """ @@ -114,7 +129,7 @@ class UniqueTogetherValidator: missing_items = { field_name: self.missing_message - for field_name in self.fields + for field_name in (*self.fields, *self.condition_fields) if serializer.fields[field_name].source not in attrs } if missing_items: @@ -173,16 +188,19 @@ class UniqueTogetherValidator: if attrs[field_name] != getattr(serializer.instance, field_name) ] - if checked_values and None not in checked_values and qs_exists(queryset): + condition_kwargs = {source: attrs[source] for source in self.condition_fields} + if checked_values and None not in checked_values and qs_exists_with_condition(queryset, self.condition, condition_kwargs): field_names = ', '.join(self.fields) message = self.message.format(field_names=field_names) raise ValidationError(message, code='unique') def __repr__(self): - return '<%s(queryset=%s, fields=%s)>' % ( + return '<{}({})>'.format( self.__class__.__name__, - smart_repr(self.queryset), - smart_repr(self.fields) + ', '.join( + f'{attr}={smart_repr(getattr(self, attr))}' + for attr in ('queryset', 'fields', 'condition') + if getattr(self, attr) is not None) ) def __eq__(self, other): diff --git a/tests/test_validators.py b/tests/test_validators.py index 9c1a0eac3..5b6cd973c 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -521,7 +521,7 @@ class UniqueConstraintModel(models.Model): race_name = models.CharField(max_length=100) position = models.IntegerField() global_id = models.IntegerField() - fancy_conditions = models.IntegerField(null=True) + fancy_conditions = models.IntegerField() class Meta: constraints = [ @@ -543,7 +543,12 @@ class UniqueConstraintModel(models.Model): name="unique_constraint_model_together_uniq", fields=('race_name', 'position'), condition=models.Q(race_name='example'), - ) + ), + models.UniqueConstraint( + name='unique_constraint_model_together_uniq2', + fields=('race_name', 'position'), + condition=models.Q(fancy_conditions__gte=10), + ), ] @@ -576,17 +581,20 @@ class TestUniqueConstraintValidation(TestCase): self.instance = UniqueConstraintModel.objects.create( race_name='example', position=1, - global_id=1 + global_id=1, + fancy_conditions=1 ) UniqueConstraintModel.objects.create( race_name='example', position=2, - global_id=2 + global_id=2, + fancy_conditions=1 ) UniqueConstraintModel.objects.create( race_name='other', position=1, - global_id=3 + global_id=3, + fancy_conditions=1 ) def test_repr(self): @@ -601,22 +609,55 @@ class TestUniqueConstraintValidation(TestCase): position = IntegerField\(.*required=True\) global_id = IntegerField\(.*validators=\[\]\) class Meta: - validators = \[, \]>, fields=\('race_name', 'position'\)\)>\] + validators = \[\)>\] """) assert re.search(expected, repr(serializer)) is not None - def test_unique_together_field(self): + def test_unique_together_condition(self): """ - UniqueConstraint fields and condition attributes must be passed - to UniqueTogetherValidator as fields and queryset + Fields used in UniqueConstraint's condition must be included + into queryset existence check """ - serializer = UniqueConstraintSerializer() - assert len(serializer.validators) == 1 - validator = serializer.validators[0] - assert validator.fields == ('race_name', 'position') - assert set(validator.queryset.values_list(flat=True)) == set( - UniqueConstraintModel.objects.filter(race_name='example').values_list(flat=True) + UniqueConstraintModel.objects.create( + race_name='condition', + position=1, + global_id=10, + fancy_conditions=10, ) + serializer = UniqueConstraintSerializer(data={ + 'race_name': 'condition', + 'position': 1, + 'global_id': 11, + 'fancy_conditions': 9, + }) + assert serializer.is_valid() + serializer = UniqueConstraintSerializer(data={ + 'race_name': 'condition', + 'position': 1, + 'global_id': 11, + 'fancy_conditions': 11, + }) + assert not serializer.is_valid() + + def test_unique_together_condition_fields_required(self): + """ + Fields used in UniqueConstraint's condition must be present in serializer + """ + serializer = UniqueConstraintSerializer(data={ + 'race_name': 'condition', + 'position': 1, + 'global_id': 11, + }) + assert not serializer.is_valid() + assert serializer.errors == {'fancy_conditions': ['This field is required.']} + + class NoFieldsSerializer(serializers.ModelSerializer): + class Meta: + model = UniqueConstraintModel + fields = ('race_name', 'position', 'global_id') + + serializer = NoFieldsSerializer() + assert len(serializer.validators) == 1 def test_single_field_uniq_validators(self): """ @@ -625,9 +666,8 @@ class TestUniqueConstraintValidation(TestCase): """ # Django 5 includes Max and Min values validators for IntergerField extra_validators_qty = 2 if django_version[0] >= 5 else 0 - # serializer = UniqueConstraintSerializer() - assert len(serializer.validators) == 1 + assert len(serializer.validators) == 2 validators = serializer.fields['global_id'].validators assert len(validators) == 1 + extra_validators_qty assert validators[0].queryset == UniqueConstraintModel.objects From fc98d3598d9c0762e0cb999992e954643e6dc091 Mon Sep 17 00:00:00 2001 From: Mojtaba A <83042940+mojtabaakbari221b@users.noreply.github.com> Date: Wed, 26 Feb 2025 09:41:09 +0330 Subject: [PATCH 5/6] Update relations.md (#9063) add rest-framework-gm2m-relations package that provides read/write serialization for generic many to many field --- docs/api-guide/relations.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/api-guide/relations.md b/docs/api-guide/relations.md index 56eb61e43..7c4eece4b 100644 --- a/docs/api-guide/relations.md +++ b/docs/api-guide/relations.md @@ -628,12 +628,16 @@ The [drf-nested-routers package][drf-nested-routers] provides routers and relati The [rest-framework-generic-relations][drf-nested-relations] library provides read/write serialization for generic foreign keys. +The [rest-framework-gm2m-relations][drf-gm2m-relations] library provides read/write serialization for [django-gm2m][django-gm2m-field]. + [cite]: http://users.ece.utexas.edu/~adnan/pike.html [reverse-relationships]: https://docs.djangoproject.com/en/stable/topics/db/queries/#following-relationships-backward [routers]: https://www.django-rest-framework.org/api-guide/routers#defaultrouter [generic-relations]: https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/#id1 [drf-nested-routers]: https://github.com/alanjds/drf-nested-routers [drf-nested-relations]: https://github.com/Ian-Foote/rest-framework-generic-relations +[drf-gm2m-relations]: https://github.com/mojtabaakbari221b/rest-framework-gm2m-relations +[django-gm2m-field]: https://github.com/tkhyn/django-gm2m [django-intermediary-manytomany]: https://docs.djangoproject.com/en/stable/topics/db/models/#intermediary-manytomany [dealing-with-nested-objects]: https://www.django-rest-framework.org/api-guide/serializers/#dealing-with-nested-objects [to_internal_value]: https://www.django-rest-framework.org/api-guide/serializers/#to_internal_valueself-data From 0e1c7d3613905a8f9db69abb82f883e22e967119 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Thu, 27 Feb 2025 11:27:32 +0000 Subject: [PATCH 6/6] Update django 5.2b1 (#9657) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 52a763ef5..b0bd54219 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ deps = django42: Django>=4.2,<5.0 django50: Django>=5.0,<5.1 django51: Django>=5.1,<5.2 - django52: Django>=5.2a1,<6.0 + django52: Django>=5.2b1,<6.0 djangomain: https://github.com/django/django/archive/main.tar.gz -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt