mirror of
https://github.com/encode/django-rest-framework.git
synced 2025-10-22 03:34:24 +03:00
Merge branch 'encode:main' into validation-many-to-many
This commit is contained in:
commit
6f2960484f
7
.github/workflows/main.yml
vendored
7
.github/workflows/main.yml
vendored
|
@ -14,11 +14,11 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- '3.9'
|
||||
- '3.10'
|
||||
- '3.11'
|
||||
- '3.12'
|
||||
- '3.13'
|
||||
- '3.14'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
@ -26,6 +26,7 @@ jobs:
|
|||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
allow-prereleases: true
|
||||
cache: 'pip'
|
||||
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 '-')
|
||||
|
||||
- name: Run extra tox targets
|
||||
if: ${{ matrix.python-version == '3.9' }}
|
||||
if: ${{ matrix.python-version == '3.13' }}
|
||||
run: |
|
||||
tox -e base,dist,docs
|
||||
|
||||
|
@ -56,7 +57,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.9'
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements/requirements-documentation.txt
|
||||
|
|
|
@ -54,7 +54,7 @@ Some reasons you might want to use REST framework:
|
|||
|
||||
# Requirements
|
||||
|
||||
* Python 3.9+
|
||||
* Python 3.10+
|
||||
* Django 4.2, 5.0, 5.1, 5.2
|
||||
|
||||
We **highly recommend** and only officially support the latest patch release of
|
||||
|
|
|
@ -235,7 +235,7 @@ For example:
|
|||
|
||||
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:
|
||||
|
||||
|
@ -257,7 +257,7 @@ The `OrderingFilter` class supports simple query parameter controlled ordering o
|
|||
|
||||

|
||||
|
||||
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:
|
||||
|
||||
|
|
|
@ -88,7 +88,7 @@ continued development by **[signing up for a paid plan][funding]**.
|
|||
REST framework requires the following:
|
||||
|
||||
* 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
|
||||
each Python and Django series.
|
||||
|
|
|
@ -8,7 +8,7 @@ description = "Web APIs for Django, made easy."
|
|||
readme = "README.md"
|
||||
license = "BSD-3-Clause"
|
||||
authors = [ { name = "Tom Christie", email = "tom@tomchristie.com" } ]
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Web Environment",
|
||||
|
@ -21,11 +21,11 @@ classifiers = [
|
|||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
dynamic = [ "version" ]
|
||||
|
@ -57,6 +57,9 @@ known_first_party = [ "rest_framework", "tests" ]
|
|||
skip = "*/kickstarter-announcement.md,*.js,*.map,*.po"
|
||||
ignore-words-list = "fo,malcom,ser"
|
||||
|
||||
[tool.pyproject-fmt]
|
||||
max_supported_python = "3.14"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--tb=short --strict-markers -ra"
|
||||
testpaths = [ "tests" ]
|
||||
|
|
|
@ -1576,6 +1576,17 @@ class ModelSerializer(Serializer):
|
|||
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):
|
||||
"""
|
||||
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():
|
||||
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
|
||||
# base model class, but also on any parent classes.
|
||||
validators = []
|
||||
|
@ -1628,11 +1646,17 @@ class ModelSerializer(Serializer):
|
|||
)
|
||||
|
||||
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(
|
||||
queryset=queryset,
|
||||
fields=field_names,
|
||||
condition_fields=tuple(source_map[f][0] for f in condition_fields),
|
||||
condition=condition,
|
||||
message=violation_error_message,
|
||||
code=getattr(constraint, 'violation_error_code', None),
|
||||
)
|
||||
validators.append(validator)
|
||||
return validators
|
||||
|
|
|
@ -111,13 +111,15 @@ class UniqueTogetherValidator:
|
|||
message = _('The fields {field_names} must make a unique set.')
|
||||
missing_message = _('This field is required.')
|
||||
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.fields = fields
|
||||
self.message = message or self.message
|
||||
self.condition_fields = [] if condition_fields is None else condition_fields
|
||||
self.condition = condition
|
||||
self.code = code or self.code
|
||||
|
||||
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):
|
||||
field_names = ', '.join(self.fields)
|
||||
message = self.message.format(field_names=field_names)
|
||||
raise ValidationError(message, code='unique')
|
||||
raise ValidationError(message, code=self.code)
|
||||
|
||||
def __repr__(self):
|
||||
return '<{}({})>'.format(
|
||||
|
@ -217,6 +219,7 @@ class UniqueTogetherValidator:
|
|||
and self.missing_message == other.missing_message
|
||||
and self.queryset == other.queryset
|
||||
and self.fields == other.fields
|
||||
and self.code == other.code
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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 Meta:
|
||||
model = UniqueConstraintModel
|
||||
|
@ -628,6 +648,12 @@ class UniqueConstraintNullableSerializer(serializers.ModelSerializer):
|
|||
fields = ('title', 'age', 'tag')
|
||||
|
||||
|
||||
class UniqueConstraintCustomMessageCodeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UniqueConstraintCustomMessageCodeModel
|
||||
fields = ('username', 'company_id', 'role')
|
||||
|
||||
|
||||
class TestUniqueConstraintValidation(TestCase):
|
||||
def setUp(self):
|
||||
self.instance = UniqueConstraintModel.objects.create(
|
||||
|
@ -778,6 +804,31 @@ class TestUniqueConstraintValidation(TestCase):
|
|||
)
|
||||
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`
|
||||
# ----------------------------------
|
||||
|
|
5
tox.ini
5
tox.ini
|
@ -1,10 +1,10 @@
|
|||
[tox]
|
||||
envlist =
|
||||
{py39}-{django42}
|
||||
{py310}-{django42,django51,django52}
|
||||
{py311}-{django42,django51,django52}
|
||||
{py312}-{django42,django51,django52,djangomain}
|
||||
{py313}-{django51,django52,djangomain}
|
||||
{py314}-{django52,djangomain}
|
||||
base
|
||||
dist
|
||||
docs
|
||||
|
@ -50,3 +50,6 @@ ignore_outcome = true
|
|||
|
||||
[testenv:py313-djangomain]
|
||||
ignore_outcome = true
|
||||
|
||||
[testenv:py314-djangomain]
|
||||
ignore_outcome = true
|
||||
|
|
Loading…
Reference in New Issue
Block a user