Skip to content

Add localization support for all number fields (Issue #4584) #4586

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 66 additions & 60 deletions rest_framework/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
)
from django.utils.duration import duration_string
from django.utils.encoding import is_protected_type, smart_text
from django.utils.formats import localize_input, sanitize_separators
from django.utils.formats import (
localize_input, number_format, sanitize_separators
)
from django.utils.functional import cached_property
from django.utils.ipv6 import clean_ipv6_address
from django.utils.timezone import utc
Expand Down Expand Up @@ -873,20 +875,28 @@ def to_internal_value(self, data):

# Number types...

class IntegerField(Field):

class NumberField(Field):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change footprint of this pull request is fairly large given that it introduces a new base class here. I'd be interested in seeing the equivalent pull request without introducing a new base class. We could then consider if we want a NumberField as a separate feature request, since that's really a different issue to numeric localisation support.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I clearly understand what you mean... You'll prefer that I just patch the FloatField (which is the starting point of the localization problem) to work like the DecimalField with possible code duplication than using a new base class? I agree that will have a lower impact and it could be a less "risky" change. Do I understand right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right, yup. We could consider a new base class, but that's a separate issue.


default_error_messages = {
'invalid': _('A valid integer is required.'),
'invalid': _('A valid number is required.'),
'max_value': _('Ensure this value is less than or equal to {max_value}.'),
'min_value': _('Ensure this value is greater than or equal to {min_value}.'),
'max_string_length': _('String value too large.')
}

MAX_STRING_LENGTH = 1000 # Guard against malicious string inputs.
re_decimal = re.compile(r'\.0*\s*$') # allow e.g. '1.0' as an int, but not '1.2'

def __init__(self, **kwargs):
self.max_value = kwargs.pop('max_value', None)
self.min_value = kwargs.pop('min_value', None)
super(IntegerField, self).__init__(**kwargs)
self.localize = kwargs.pop('localize', api_settings.LOCALIZE_NUMBER_FIELDS)

super(NumberField, self).__init__(**kwargs)

if self.localize:
self.coerce_to_string = True

if self.max_value is not None:
message = self.error_messages['max_value'].format(max_value=self.max_value)
self.validators.append(MaxValueValidator(self.max_value, message=message))
Expand All @@ -898,103 +908,100 @@ def to_internal_value(self, data):
if isinstance(data, six.text_type) and len(data) > self.MAX_STRING_LENGTH:
self.fail('max_string_length')

data = smart_text(data).strip()

if self.localize:
data = sanitize_separators(data)

if len(data) > self.MAX_STRING_LENGTH:
self.fail('max_string_length')

return data

def to_representation(self, value):
return super(NumberField, self).to_representation(value)


class IntegerField(NumberField):
default_error_messages = NumberField.default_error_messages.copy()
default_error_messages.update({
'invalid': _('A valid integer is required.'),
})

re_decimal = re.compile(r'\.0*\s*$') # allow e.g. '1.0' as an int, but not '1.2'

def __init__(self, **kwargs):
self.coerce_to_string = kwargs.pop('coerce_to_string', api_settings.COERCE_INTEGER_TO_STRING)
super(IntegerField, self).__init__(**kwargs)

def to_internal_value(self, data):

data = super(IntegerField, self).to_internal_value(data)

try:
data = int(self.re_decimal.sub('', str(data)))
except (ValueError, TypeError):
self.fail('invalid')
return data

def to_representation(self, value):
if self.localize:
return number_format(value)
if self.coerce_to_string:
return str(int(value))
return int(value)


class FloatField(Field):
default_error_messages = {
'invalid': _('A valid number is required.'),
'max_value': _('Ensure this value is less than or equal to {max_value}.'),
'min_value': _('Ensure this value is greater than or equal to {min_value}.'),
'max_string_length': _('String value too large.')
}
MAX_STRING_LENGTH = 1000 # Guard against malicious string inputs.
class FloatField(NumberField):

def __init__(self, **kwargs):
self.max_value = kwargs.pop('max_value', None)
self.min_value = kwargs.pop('min_value', None)
self.coerce_to_string = kwargs.pop('coerce_to_string', api_settings.COERCE_FLOAT_TO_STRING)
super(FloatField, self).__init__(**kwargs)
if self.max_value is not None:
message = self.error_messages['max_value'].format(max_value=self.max_value)
self.validators.append(MaxValueValidator(self.max_value, message=message))
if self.min_value is not None:
message = self.error_messages['min_value'].format(min_value=self.min_value)
self.validators.append(MinValueValidator(self.min_value, message=message))

def to_internal_value(self, data):

if isinstance(data, six.text_type) and len(data) > self.MAX_STRING_LENGTH:
self.fail('max_string_length')
data = super(FloatField, self).to_internal_value(data)

try:
return float(data)
except (TypeError, ValueError):
self.fail('invalid')

def to_representation(self, value):
if self.localize:
return number_format(value)
if self.coerce_to_string:
return str(float(value))
return float(value)


class DecimalField(Field):
default_error_messages = {
'invalid': _('A valid number is required.'),
'max_value': _('Ensure this value is less than or equal to {max_value}.'),
'min_value': _('Ensure this value is greater than or equal to {min_value}.'),
class DecimalField(NumberField):
default_error_messages = NumberField.default_error_messages.copy()
default_error_messages.update({
'max_digits': _('Ensure that there are no more than {max_digits} digits in total.'),
'max_decimal_places': _('Ensure that there are no more than {max_decimal_places} decimal places.'),
'max_whole_digits': _('Ensure that there are no more than {max_whole_digits} digits before the decimal point.'),
'max_string_length': _('String value too large.')
}
MAX_STRING_LENGTH = 1000 # Guard against malicious string inputs.
})

def __init__(self, max_digits, decimal_places, **kwargs):
self.coerce_to_string = kwargs.pop('coerce_to_string', api_settings.COERCE_DECIMAL_TO_STRING)
super(DecimalField, self).__init__(**kwargs)

def __init__(self, max_digits, decimal_places, coerce_to_string=None, max_value=None, min_value=None,
localize=False, **kwargs):
self.max_digits = max_digits
self.decimal_places = decimal_places
self.localize = localize
if coerce_to_string is not None:
self.coerce_to_string = coerce_to_string
if self.localize:
self.coerce_to_string = True

self.max_value = max_value
self.min_value = min_value

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:
self.max_whole_digits = None

super(DecimalField, self).__init__(**kwargs)

if self.max_value is not None:
message = self.error_messages['max_value'].format(max_value=self.max_value)
self.validators.append(MaxValueValidator(self.max_value, message=message))
if self.min_value is not None:
message = self.error_messages['min_value'].format(min_value=self.min_value)
self.validators.append(MinValueValidator(self.min_value, message=message))

def to_internal_value(self, data):
"""
Validate that the input is a decimal number and return a Decimal
instance.
"""

data = smart_text(data).strip()

if self.localize:
data = sanitize_separators(data)

if len(data) > self.MAX_STRING_LENGTH:
self.fail('max_string_length')

data = super(DecimalField, self).to_internal_value(data)
try:
value = decimal.Decimal(data)
except decimal.DecimalException:
Expand Down Expand Up @@ -1047,14 +1054,13 @@ def validate_precision(self, value):
return value

def to_representation(self, value):
coerce_to_string = getattr(self, 'coerce_to_string', api_settings.COERCE_DECIMAL_TO_STRING)

if not isinstance(value, decimal.Decimal):
value = decimal.Decimal(six.text_type(value).strip())

quantized = self.quantize(value)

if not coerce_to_string:
if not self.coerce_to_string:
return quantized
if self.localize:
return localize_input(quantized)
Expand Down
4 changes: 2 additions & 2 deletions rest_framework/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@
BooleanField, CharField, ChoiceField, DateField, DateTimeField, DecimalField,
DictField, DurationField, EmailField, Field, FileField, FilePathField, FloatField,
HiddenField, IPAddressField, ImageField, IntegerField, JSONField, ListField,
ModelField, MultipleChoiceField, NullBooleanField, ReadOnlyField, RegexField,
SerializerMethodField, SlugField, TimeField, URLField, UUIDField,
ModelField, MultipleChoiceField, NullBooleanField, NumberField, ReadOnlyField,
RegexField, SerializerMethodField, SlugField, TimeField, URLField, UUIDField,
)
from rest_framework.relations import ( # NOQA # isort:skip
HyperlinkedIdentityField, HyperlinkedRelatedField, ManyRelatedField,
Expand Down
5 changes: 5 additions & 0 deletions rest_framework/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,13 @@
'UNICODE_JSON': True,
'COMPACT_JSON': True,
'COERCE_DECIMAL_TO_STRING': True,
'COERCE_FLOAT_TO_STRING': False,
'COERCE_INTEGER_TO_STRING': False,
'UPLOADED_FILES_USE_URL': True,

# Number fields localization
'LOCALIZE_NUMBER_FIELDS': False,

# Browseable API
'HTML_SELECT_CUTOFF': 1000,
'HTML_SELECT_CUTOFF_TEXT': "More than {count} items...",
Expand Down
61 changes: 61 additions & 0 deletions tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,37 @@ class TestMinMaxIntegerField(FieldValues):
field = serializers.IntegerField(min_value=1, max_value=3)


class TestCoerceToStringIntegerField(FieldValues):
valid_inputs = {}
invalid_inputs = {}
outputs = {
'1': '1',
'0': '0',
1: '1',
0: '0',
1.0: '1',
0.0: '0',
}
field = serializers.IntegerField(coerce_to_string=True)


class TestLocalizedIntegerField(TestCase):
@override_settings(USE_L10N=True, LANGUAGE_CODE='it')
def test_to_internal_value(self):
field = serializers.IntegerField(localize=True)
self.assertEqual(field.to_internal_value('1,0'), 1)

@override_settings(USE_L10N=True, LANGUAGE_CODE=None, DECIMAL_SEPARATOR=',', THOUSAND_SEPARATOR='\'',
NUMBER_GROUPING=3, USE_THOUSAND_SEPARATOR=True)
def test_to_representation(self):
field = serializers.IntegerField(localize=True)
self.assertEqual(field.to_representation(1000), '1\'000')

def test_localize_forces_coerce_to_string(self):
field = serializers.IntegerField(localize=True)
self.assertTrue(isinstance(field.to_representation(3), six.string_types))


class TestFloatField(FieldValues):
"""
Valid and invalid values for `FloatField`.
Expand Down Expand Up @@ -922,6 +953,36 @@ class TestMinMaxFloatField(FieldValues):
field = serializers.FloatField(min_value=1, max_value=3)


class TestCoerceToStringFloatField(FieldValues):
valid_inputs = {}
invalid_inputs = {}
outputs = {
'1': str(1.0),
'0': str(0.0),
1: str(1.0),
0: str(0.0),
1.5: str(1.5),
}
field = serializers.FloatField(coerce_to_string=True)


class TestLocalizedFloatField(TestCase):
@override_settings(USE_L10N=True, LANGUAGE_CODE='it')
def test_to_internal_value(self):
field = serializers.FloatField(localize=True)
self.assertEqual(field.to_internal_value('1,5'), 1.5)

@override_settings(USE_L10N=True, LANGUAGE_CODE=None, DECIMAL_SEPARATOR=',', THOUSAND_SEPARATOR='\'',
NUMBER_GROUPING=3, USE_THOUSAND_SEPARATOR=True)
def test_to_representation(self):
field = serializers.FloatField(localize=True)
self.assertEqual(field.to_representation(1000.75), '1\'000,75')

def test_localize_forces_coerce_to_string(self):
field = serializers.FloatField(localize=True)
self.assertTrue(isinstance(field.to_representation(3), six.string_types))


class TestDecimalField(FieldValues):
"""
Valid and invalid values for `DecimalField`.
Expand Down