Merge branch 'encode:main' into validation-many-to-many

This commit is contained in:
Genaro Camele 2025-10-13 21:10:11 -03:00 committed by GitHub
commit 6f2960484f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 97 additions and 12 deletions

View File

@ -14,11 +14,11 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: python-version:
- '3.9'
- '3.10' - '3.10'
- '3.11' - '3.11'
- '3.12' - '3.12'
- '3.13' - '3.13'
- '3.14'
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
@ -26,6 +26,7 @@ jobs:
- uses: actions/setup-python@v6 - uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
allow-prereleases: true
cache: 'pip' cache: 'pip'
cache-dependency-path: 'requirements/*.txt' cache-dependency-path: 'requirements/*.txt'
@ -39,7 +40,7 @@ jobs:
run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d . | cut -f 1 -d '-') run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d . | cut -f 1 -d '-')
- name: Run extra tox targets - name: Run extra tox targets
if: ${{ matrix.python-version == '3.9' }} if: ${{ matrix.python-version == '3.13' }}
run: | run: |
tox -e base,dist,docs tox -e base,dist,docs
@ -56,7 +57,7 @@ jobs:
- uses: actions/setup-python@v6 - uses: actions/setup-python@v6
with: with:
python-version: '3.9' python-version: '3.13'
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements/requirements-documentation.txt run: pip install -r requirements/requirements-documentation.txt

View File

@ -54,7 +54,7 @@ Some reasons you might want to use REST framework:
# Requirements # Requirements
* Python 3.9+ * Python 3.10+
* Django 4.2, 5.0, 5.1, 5.2 * Django 4.2, 5.0, 5.1, 5.2
We **highly recommend** and only officially support the latest patch release of We **highly recommend** and only officially support the latest patch release of

View File

@ -235,7 +235,7 @@ For example:
search_fields = ['=username', '=email'] search_fields = ['=username', '=email']
By default, the search parameter is named `'search'`, but this may be overridden with the `SEARCH_PARAM` setting. By default, the search parameter is named `'search'`, but this may be overridden with the `SEARCH_PARAM` setting in the `REST_FRAMEWORK` configuration.
To dynamically change search fields based on request content, it's possible to subclass the `SearchFilter` and override the `get_search_fields()` function. For example, the following subclass will only search on `title` if the query parameter `title_only` is in the request: To dynamically change search fields based on request content, it's possible to subclass the `SearchFilter` and override the `get_search_fields()` function. For example, the following subclass will only search on `title` if the query parameter `title_only` is in the request:
@ -257,7 +257,7 @@ The `OrderingFilter` class supports simple query parameter controlled ordering o
![Ordering Filter](../img/ordering-filter.png) ![Ordering Filter](../img/ordering-filter.png)
By default, the query parameter is named `'ordering'`, but this may be overridden with the `ORDERING_PARAM` setting. By default, the query parameter is named `'ordering'`, but this may be overridden with the `ORDERING_PARAM` setting in the `REST_FRAMEWORK` configuration.
For example, to order users by username: For example, to order users by username:

View File

@ -88,7 +88,7 @@ continued development by **[signing up for a paid plan][funding]**.
REST framework requires the following: REST framework requires the following:
* Django (4.2, 5.0, 5.1, 5.2) * Django (4.2, 5.0, 5.1, 5.2)
* Python (3.9, 3.10, 3.11, 3.12, 3.13) * Python (3.10, 3.11, 3.12, 3.13, 3.14)
We **highly recommend** and only officially support the latest patch release of We **highly recommend** and only officially support the latest patch release of
each Python and Django series. each Python and Django series.

View File

@ -8,7 +8,7 @@ description = "Web APIs for Django, made easy."
readme = "README.md" readme = "README.md"
license = "BSD-3-Clause" license = "BSD-3-Clause"
authors = [ { name = "Tom Christie", email = "tom@tomchristie.com" } ] authors = [ { name = "Tom Christie", email = "tom@tomchristie.com" } ]
requires-python = ">=3.9" requires-python = ">=3.10"
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Environment :: Web Environment", "Environment :: Web Environment",
@ -21,11 +21,11 @@ classifiers = [
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python", "Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP",
] ]
dynamic = [ "version" ] dynamic = [ "version" ]
@ -57,6 +57,9 @@ known_first_party = [ "rest_framework", "tests" ]
skip = "*/kickstarter-announcement.md,*.js,*.map,*.po" skip = "*/kickstarter-announcement.md,*.js,*.map,*.po"
ignore-words-list = "fo,malcom,ser" ignore-words-list = "fo,malcom,ser"
[tool.pyproject-fmt]
max_supported_python = "3.14"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "--tb=short --strict-markers -ra" addopts = "--tb=short --strict-markers -ra"
testpaths = [ "tests" ] testpaths = [ "tests" ]

View File

@ -1576,6 +1576,17 @@ class ModelSerializer(Serializer):
self.get_unique_for_date_validators() self.get_unique_for_date_validators()
) )
def _get_constraint_violation_error_message(self, constraint):
"""
Returns the violation error message for the UniqueConstraint,
or None if the message is the default.
"""
violation_error_message = constraint.get_violation_error_message()
default_error_message = constraint.default_violation_error_message % {"name": constraint.name}
if violation_error_message == default_error_message:
return None
return violation_error_message
def get_unique_together_validators(self): def get_unique_together_validators(self):
""" """
Determine a default set of validators for any unique_together constraints. Determine a default set of validators for any unique_together constraints.
@ -1602,6 +1613,13 @@ class ModelSerializer(Serializer):
for name, source in field_sources.items(): for name, source in field_sources.items():
source_map[source].append(name) source_map[source].append(name)
unique_constraint_by_fields = {
constraint.fields: constraint
for model_cls in (*self.Meta.model._meta.parents, self.Meta.model)
for constraint in model_cls._meta.constraints
if isinstance(constraint, models.UniqueConstraint)
}
# Note that we make sure to check `unique_together` both on the # Note that we make sure to check `unique_together` both on the
# base model class, but also on any parent classes. # base model class, but also on any parent classes.
validators = [] validators = []
@ -1628,11 +1646,17 @@ class ModelSerializer(Serializer):
) )
field_names = tuple(source_map[f][0] for f in unique_together) field_names = tuple(source_map[f][0] for f in unique_together)
constraint = unique_constraint_by_fields.get(tuple(unique_together))
violation_error_message = self._get_constraint_violation_error_message(constraint) if constraint else None
validator = UniqueTogetherValidator( validator = UniqueTogetherValidator(
queryset=queryset, queryset=queryset,
fields=field_names, fields=field_names,
condition_fields=tuple(source_map[f][0] for f in condition_fields), condition_fields=tuple(source_map[f][0] for f in condition_fields),
condition=condition, condition=condition,
message=violation_error_message,
code=getattr(constraint, 'violation_error_code', None),
) )
validators.append(validator) validators.append(validator)
return validators return validators

View File

@ -111,13 +111,15 @@ class UniqueTogetherValidator:
message = _('The fields {field_names} must make a unique set.') message = _('The fields {field_names} must make a unique set.')
missing_message = _('This field is required.') missing_message = _('This field is required.')
requires_context = True requires_context = True
code = 'unique'
def __init__(self, queryset, fields, message=None, condition_fields=None, condition=None): def __init__(self, queryset, fields, message=None, condition_fields=None, condition=None, code=None):
self.queryset = queryset self.queryset = queryset
self.fields = fields self.fields = fields
self.message = message or self.message self.message = message or self.message
self.condition_fields = [] if condition_fields is None else condition_fields self.condition_fields = [] if condition_fields is None else condition_fields
self.condition = condition self.condition = condition
self.code = code or self.code
def enforce_required_fields(self, attrs, serializer): def enforce_required_fields(self, attrs, serializer):
""" """
@ -198,7 +200,7 @@ class UniqueTogetherValidator:
if checked_values and None not in checked_values and qs_exists_with_condition(queryset, self.condition, condition_kwargs): if checked_values and None not in checked_values and qs_exists_with_condition(queryset, self.condition, condition_kwargs):
field_names = ', '.join(self.fields) field_names = ', '.join(self.fields)
message = self.message.format(field_names=field_names) message = self.message.format(field_names=field_names)
raise ValidationError(message, code='unique') raise ValidationError(message, code=self.code)
def __repr__(self): def __repr__(self):
return '<{}({})>'.format( return '<{}({})>'.format(
@ -217,6 +219,7 @@ class UniqueTogetherValidator:
and self.missing_message == other.missing_message and self.missing_message == other.missing_message
and self.queryset == other.queryset and self.queryset == other.queryset
and self.fields == other.fields and self.fields == other.fields
and self.code == other.code
) )

View File

@ -616,6 +616,26 @@ class UniqueConstraintNullableModel(models.Model):
] ]
class UniqueConstraintCustomMessageCodeModel(models.Model):
username = models.CharField(max_length=32)
company_id = models.IntegerField()
role = models.CharField(max_length=32)
class Meta:
constraints = [
models.UniqueConstraint(
fields=("username", "company_id"),
name="unique_username_company_custom_msg",
violation_error_message="Username must be unique within a company.",
**(dict(violation_error_code="duplicate_username") if django_version[0] >= 5 else {}),
),
models.UniqueConstraint(
fields=("company_id", "role"),
name="unique_company_role_default_msg",
),
]
class UniqueConstraintSerializer(serializers.ModelSerializer): class UniqueConstraintSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = UniqueConstraintModel model = UniqueConstraintModel
@ -628,6 +648,12 @@ class UniqueConstraintNullableSerializer(serializers.ModelSerializer):
fields = ('title', 'age', 'tag') fields = ('title', 'age', 'tag')
class UniqueConstraintCustomMessageCodeSerializer(serializers.ModelSerializer):
class Meta:
model = UniqueConstraintCustomMessageCodeModel
fields = ('username', 'company_id', 'role')
class TestUniqueConstraintValidation(TestCase): class TestUniqueConstraintValidation(TestCase):
def setUp(self): def setUp(self):
self.instance = UniqueConstraintModel.objects.create( self.instance = UniqueConstraintModel.objects.create(
@ -778,6 +804,31 @@ class TestUniqueConstraintValidation(TestCase):
) )
assert serializer.is_valid() assert serializer.is_valid()
def test_unique_constraint_custom_message_code(self):
UniqueConstraintCustomMessageCodeModel.objects.create(username="Alice", company_id=1, role="member")
expected_code = "duplicate_username" if django_version[0] >= 5 else UniqueTogetherValidator.code
serializer = UniqueConstraintCustomMessageCodeSerializer(data={
"username": "Alice",
"company_id": 1,
"role": "admin",
})
assert not serializer.is_valid()
assert serializer.errors == {"non_field_errors": ["Username must be unique within a company."]}
assert serializer.errors["non_field_errors"][0].code == expected_code
def test_unique_constraint_default_message_code(self):
UniqueConstraintCustomMessageCodeModel.objects.create(username="Alice", company_id=1, role="member")
serializer = UniqueConstraintCustomMessageCodeSerializer(data={
"username": "John",
"company_id": 1,
"role": "member",
})
expected_message = UniqueTogetherValidator.message.format(field_names=', '.join(("company_id", "role")))
assert not serializer.is_valid()
assert serializer.errors == {"non_field_errors": [expected_message]}
assert serializer.errors["non_field_errors"][0].code == UniqueTogetherValidator.code
# Tests for `UniqueForDateValidator` # Tests for `UniqueForDateValidator`
# ---------------------------------- # ----------------------------------

View File

@ -1,10 +1,10 @@
[tox] [tox]
envlist = envlist =
{py39}-{django42}
{py310}-{django42,django51,django52} {py310}-{django42,django51,django52}
{py311}-{django42,django51,django52} {py311}-{django42,django51,django52}
{py312}-{django42,django51,django52,djangomain} {py312}-{django42,django51,django52,djangomain}
{py313}-{django51,django52,djangomain} {py313}-{django51,django52,djangomain}
{py314}-{django52,djangomain}
base base
dist dist
docs docs
@ -50,3 +50,6 @@ ignore_outcome = true
[testenv:py313-djangomain] [testenv:py313-djangomain]
ignore_outcome = true ignore_outcome = true
[testenv:py314-djangomain]
ignore_outcome = true