diff --git a/django/core/validators.py b/django/core/validators.py index a9b8855d0e..830b533848 100644 --- a/django/core/validators.py +++ b/django/core/validators.py @@ -48,7 +48,7 @@ class RegexValidator: regex_matches = self.regex.search(str(value)) invalid_input = regex_matches if self.inverse_match else not regex_matches if invalid_input: - raise ValidationError(self.message, code=self.code) + raise ValidationError(self.message, code=self.code, params={'value': value}) def __eq__(self, other): return ( @@ -100,11 +100,11 @@ class URLValidator(RegexValidator): def __call__(self, value): if not isinstance(value, str): - raise ValidationError(self.message, code=self.code) + raise ValidationError(self.message, code=self.code, params={'value': value}) # Check if the scheme is valid. scheme = value.split('://')[0].lower() if scheme not in self.schemes: - raise ValidationError(self.message, code=self.code) + raise ValidationError(self.message, code=self.code, params={'value': value}) # Then check full URL try: @@ -115,7 +115,7 @@ class URLValidator(RegexValidator): try: scheme, netloc, path, query, fragment = urlsplit(value) except ValueError: # for example, "Invalid IPv6 URL" - raise ValidationError(self.message, code=self.code) + raise ValidationError(self.message, code=self.code, params={'value': value}) try: netloc = punycode(netloc) # IDN -> ACE except UnicodeError: # invalid domain part @@ -132,14 +132,14 @@ class URLValidator(RegexValidator): try: validate_ipv6_address(potential_ip) except ValidationError: - raise ValidationError(self.message, code=self.code) + raise ValidationError(self.message, code=self.code, params={'value': value}) # The maximum length of a full host name is 253 characters per RFC 1034 # section 3.1. It's defined to be 255 bytes or less, but this includes # one byte for the length of the name and one byte for the trailing dot # that's used to indicate absolute names in DNS. if len(urlsplit(value).netloc) > 253: - raise ValidationError(self.message, code=self.code) + raise ValidationError(self.message, code=self.code, params={'value': value}) integer_validator = RegexValidator( @@ -208,12 +208,12 @@ class EmailValidator: def __call__(self, value): if not value or '@' not in value: - raise ValidationError(self.message, code=self.code) + raise ValidationError(self.message, code=self.code, params={'value': value}) user_part, domain_part = value.rsplit('@', 1) if not self.user_regex.match(user_part): - raise ValidationError(self.message, code=self.code) + raise ValidationError(self.message, code=self.code, params={'value': value}) if (domain_part not in self.domain_allowlist and not self.validate_domain_part(domain_part)): @@ -225,7 +225,7 @@ class EmailValidator: else: if self.validate_domain_part(domain_part): return - raise ValidationError(self.message, code=self.code) + raise ValidationError(self.message, code=self.code, params={'value': value}) def validate_domain_part(self, domain_part): if self.domain_regex.match(domain_part): @@ -272,12 +272,12 @@ def validate_ipv4_address(value): try: ipaddress.IPv4Address(value) except ValueError: - raise ValidationError(_('Enter a valid IPv4 address.'), code='invalid') + raise ValidationError(_('Enter a valid IPv4 address.'), code='invalid', params={'value': value}) def validate_ipv6_address(value): if not is_valid_ipv6_address(value): - raise ValidationError(_('Enter a valid IPv6 address.'), code='invalid') + raise ValidationError(_('Enter a valid IPv6 address.'), code='invalid', params={'value': value}) def validate_ipv46_address(value): @@ -287,7 +287,7 @@ def validate_ipv46_address(value): try: validate_ipv6_address(value) except ValidationError: - raise ValidationError(_('Enter a valid IPv4 or IPv6 address.'), code='invalid') + raise ValidationError(_('Enter a valid IPv4 or IPv6 address.'), code='invalid', params={'value': value}) ip_address_validator_map = { @@ -438,7 +438,7 @@ class DecimalValidator: def __call__(self, value): digit_tuple, exponent = value.as_tuple()[1:] if exponent in {'F', 'n', 'N'}: - raise ValidationError(self.messages['invalid'], code='invalid') + raise ValidationError(self.messages['invalid'], code='invalid', params={'value': value}) if exponent >= 0: # A positive exponent adds that many trailing zeros. digits = len(digit_tuple) + exponent @@ -460,20 +460,20 @@ class DecimalValidator: raise ValidationError( self.messages['max_digits'], code='max_digits', - params={'max': self.max_digits}, + params={'max': self.max_digits, 'value': value}, ) if self.decimal_places is not None and decimals > self.decimal_places: raise ValidationError( self.messages['max_decimal_places'], code='max_decimal_places', - params={'max': self.decimal_places}, + params={'max': self.decimal_places, 'value': value}, ) if (self.max_digits is not None and self.decimal_places is not None and whole_digits > (self.max_digits - self.decimal_places)): raise ValidationError( self.messages['max_whole_digits'], code='max_whole_digits', - params={'max': (self.max_digits - self.decimal_places)}, + params={'max': (self.max_digits - self.decimal_places), 'value': value}, ) def __eq__(self, other): @@ -509,7 +509,8 @@ class FileExtensionValidator: code=self.code, params={ 'extension': extension, - 'allowed_extensions': ', '.join(self.allowed_extensions) + 'allowed_extensions': ', '.join(self.allowed_extensions), + 'value': value, } ) @@ -550,7 +551,7 @@ class ProhibitNullCharactersValidator: def __call__(self, value): if '\x00' in str(value): - raise ValidationError(self.message, code=self.code) + raise ValidationError(self.message, code=self.code, params={'value': value}) def __eq__(self, other): return ( diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt index 5e75f497f4..225f3855f3 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -327,7 +327,9 @@ Utilities Validators ~~~~~~~~~~ -* ... +* Built-in validators now include the provided value in the ``params`` argument + of a raised :exc:`~django.core.exceptions.ValidationError`. This allows + custom error messages to use the ``%(value)s`` placeholder. .. _backwards-incompatible-3.2: diff --git a/tests/forms_tests/tests/test_validators.py b/tests/forms_tests/tests/test_validators.py index 52adbe8c65..2f26bbfbb7 100644 --- a/tests/forms_tests/tests/test_validators.py +++ b/tests/forms_tests/tests/test_validators.py @@ -5,6 +5,7 @@ from unittest import TestCase from django import forms from django.core import validators from django.core.exceptions import ValidationError +from django.core.files.uploadedfile import SimpleUploadedFile class TestFieldWithValidators(TestCase): @@ -68,8 +69,28 @@ class TestFieldWithValidators(TestCase): class ValidatorCustomMessageTests(TestCase): def test_value_placeholder_with_char_field(self): cases = [ + (validators.validate_integer, '-42.5', 'invalid'), + (validators.validate_email, 'a', 'invalid'), + (validators.validate_email, 'a@b\n.com', 'invalid'), + (validators.validate_email, 'a\n@b.com', 'invalid'), + (validators.validate_slug, '你 好', 'invalid'), + (validators.validate_unicode_slug, '你 好', 'invalid'), + (validators.validate_ipv4_address, '256.1.1.1', 'invalid'), + (validators.validate_ipv6_address, '1:2', 'invalid'), + (validators.validate_ipv46_address, '256.1.1.1', 'invalid'), + (validators.validate_comma_separated_integer_list, 'a,b,c', 'invalid'), + (validators.int_list_validator(), '-1,2,3', 'invalid'), (validators.MaxLengthValidator(10), 11 * 'x', 'max_length'), (validators.MinLengthValidator(10), 9 * 'x', 'min_length'), + (validators.URLValidator(), 'no_scheme', 'invalid'), + (validators.URLValidator(), 'http://test[.com', 'invalid'), + (validators.URLValidator(), 'http://[::1:2::3]/', 'invalid'), + ( + validators.URLValidator(), + 'http://' + '.'.join(['a' * 35 for _ in range(9)]), + 'invalid', + ), + (validators.RegexValidator('[0-9]+'), 'xxxxxx', 'invalid'), ] for validator, value, code in cases: if isinstance(validator, types.FunctionType): @@ -87,10 +108,21 @@ class ValidatorCustomMessageTests(TestCase): self.assertIs(form.is_valid(), False) self.assertEqual(form.errors, {'field': [value]}) + def test_value_placeholder_with_null_character(self): + class MyForm(forms.Form): + field = forms.CharField( + error_messages={'null_characters_not_allowed': '%(value)s'}, + ) + + form = MyForm({'field': 'a\0b'}) + self.assertIs(form.is_valid(), False) + self.assertEqual(form.errors, {'field': ['a\x00b']}) + def test_value_placeholder_with_integer_field(self): cases = [ (validators.MaxValueValidator(0), 1, 'max_value'), (validators.MinValueValidator(0), -1, 'min_value'), + (validators.URLValidator(), '1', 'invalid'), ] for validator, value, code in cases: with self.subTest(type(validator).__name__, value=value): @@ -103,3 +135,34 @@ class ValidatorCustomMessageTests(TestCase): form = MyForm({'field': value}) self.assertIs(form.is_valid(), False) self.assertEqual(form.errors, {'field': [str(value)]}) + + def test_value_placeholder_with_decimal_field(self): + cases = [ + ('NaN', 'invalid'), + ('123', 'max_digits'), + ('0.12', 'max_decimal_places'), + ('12', 'max_whole_digits'), + ] + for value, code in cases: + with self.subTest(value=value): + class MyForm(forms.Form): + field = forms.DecimalField( + max_digits=2, + decimal_places=1, + error_messages={code: '%(value)s'}, + ) + + form = MyForm({'field': value}) + self.assertIs(form.is_valid(), False) + self.assertEqual(form.errors, {'field': [value]}) + + def test_value_placeholder_with_file_field(self): + class MyForm(forms.Form): + field = forms.FileField( + validators=[validators.validate_image_file_extension], + error_messages={'invalid_extension': '%(value)s'}, + ) + + form = MyForm(files={'field': SimpleUploadedFile('myfile.txt', b'abc')}) + self.assertIs(form.is_valid(), False) + self.assertEqual(form.errors, {'field': ['myfile.txt']})