diff options
| -rw-r--r-- | rest_framework/fields.py | 64 | ||||
| -rw-r--r-- | tests/test_fields.py | 334 |
2 files changed, 365 insertions, 33 deletions
diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 0c78b3fb..db75ddf9 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -12,7 +12,6 @@ from rest_framework.utils import html, representation, humanize_datetime import datetime import decimal import inspect -import warnings class empty: @@ -395,7 +394,7 @@ class IntegerField(Field): class FloatField(Field): default_error_messages = { - 'invalid': _("'%s' value must be a float."), + 'invalid': _("A valid number is required."), } def __init__(self, **kwargs): @@ -410,20 +409,20 @@ class FloatField(Field): def to_internal_value(self, value): if value is None: return None - return float(value) + try: + return float(value) + except (TypeError, ValueError): + self.fail('invalid') def to_representation(self, value): if value is None: return None - try: - return float(value) - except (TypeError, ValueError): - self.fail('invalid', value=value) + return float(value) class DecimalField(Field): default_error_messages = { - 'invalid': _('Enter a number.'), + '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_digits': _('Ensure that there are no more than {max_digits} digits in total.'), @@ -485,7 +484,7 @@ class DecimalField(Field): if self.decimal_places is not None and decimals > self.decimal_places: self.fail('max_decimal_places', max_decimal_places=self.decimal_places) if self.max_digits is not None and self.decimal_places is not None and whole_digits > (self.max_digits - self.decimal_places): - self.fail('max_whole_digits', max_while_digits=self.max_digits - self.decimal_places) + self.fail('max_whole_digits', max_whole_digits=self.max_digits - self.decimal_places) return value @@ -511,6 +510,7 @@ class DecimalField(Field): class DateField(Field): default_error_messages = { 'invalid': _('Date has wrong format. Use one of these formats instead: {format}'), + 'datetime': _('Expected a date but got a datetime.'), } format = api_settings.DATE_FORMAT input_formats = api_settings.DATE_INPUT_FORMATS @@ -525,12 +525,7 @@ class DateField(Field): return None if isinstance(value, datetime.datetime): - if timezone and settings.USE_TZ and timezone.is_aware(value): - # Convert aware datetimes to the default time zone - # before casting them to dates (#17742). - default_timezone = timezone.get_default_timezone() - value = timezone.make_naive(value, default_timezone) - return value.date() + self.fail('datetime') if isinstance(value, datetime.date): return value @@ -570,35 +565,38 @@ class DateField(Field): class DateTimeField(Field): default_error_messages = { 'invalid': _('Datetime has wrong format. Use one of these formats instead: {format}'), + 'date': _('Expected a datetime but got a date.'), } format = api_settings.DATETIME_FORMAT input_formats = api_settings.DATETIME_INPUT_FORMATS + default_timezone = timezone.get_default_timezone() if settings.USE_TZ else None - def __init__(self, format=None, input_formats=None, *args, **kwargs): + def __init__(self, format=None, input_formats=None, default_timezone=None, *args, **kwargs): self.format = format if format is not None else self.format self.input_formats = input_formats if input_formats is not None else self.input_formats + self.default_timezone = default_timezone if default_timezone is not None else self.default_timezone super(DateTimeField, self).__init__(*args, **kwargs) + def enforce_timezone(self, value): + """ + When `self.default_timezone` is `None`, always return naive datetimes. + When `self.default_timezone` is not `None`, always return aware datetimes. + """ + if (self.default_timezone is not None) and not timezone.is_aware(value): + return timezone.make_aware(value, self.default_timezone) + elif (self.default_timezone is None) and timezone.is_aware(value): + return timezone.make_naive(value, timezone.UTC()) + return value + def to_internal_value(self, value): if value in (None, ''): return None - if isinstance(value, datetime.datetime): - return value + if isinstance(value, datetime.date) and not isinstance(value, datetime.datetime): + self.fail('date') - if isinstance(value, datetime.date): - value = datetime.datetime(value.year, value.month, value.day) - if settings.USE_TZ: - # For backwards compatibility, interpret naive datetimes in - # local time. This won't work during DST change, but we can't - # do much about it, so we let the exceptions percolate up the - # call stack. - warnings.warn("DateTimeField received a naive datetime (%s)" - " while time zone support is active." % value, - RuntimeWarning) - default_timezone = timezone.get_default_timezone() - value = timezone.make_aware(value, default_timezone) - return value + if isinstance(value, datetime.datetime): + return self.enforce_timezone(value) for format in self.input_formats: if format.lower() == ISO_8601: @@ -608,14 +606,14 @@ class DateTimeField(Field): pass else: if parsed is not None: - return parsed + return self.enforce_timezone(parsed) else: try: parsed = datetime.datetime.strptime(value, format) except (ValueError, TypeError): pass else: - return parsed + return self.enforce_timezone(parsed) humanized_format = humanize_datetime.datetime_formats(self.input_formats) self.fail('invalid', format=humanized_format) diff --git a/tests/test_fields.py b/tests/test_fields.py index a92fafbc..6ec18041 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,3 +1,337 @@ +from decimal import Decimal +from django.utils import timezone +from rest_framework import fields +import datetime +import pytest + + +class ValidAndInvalidValues: + """ + Base class for testing valid and invalid field values. + """ + def test_valid_values(self): + """ + Ensure that valid values return the expected validated data. + """ + for input_value, expected_output in self.valid_mappings.items(): + assert self.field.run_validation(input_value) == expected_output + + def test_invalid_values(self): + """ + Ensure that invalid values raise the expected validation error. + """ + for input_value, expected_failure in self.invalid_mappings.items(): + with pytest.raises(fields.ValidationError) as exc_info: + self.field.run_validation(input_value) + assert exc_info.value.messages == expected_failure + + +class TestCharField(ValidAndInvalidValues): + valid_mappings = { + 1: '1', + 'abc': 'abc' + } + invalid_mappings = { + '': ['This field may not be blank.'] + } + field = fields.CharField() + + +class TestBooleanField(ValidAndInvalidValues): + valid_mappings = { + 'true': True, + 'false': False, + '1': True, + '0': False, + 1: True, + 0: False, + True: True, + False: False, + } + invalid_mappings = { + 'foo': ['`foo` is not a valid boolean.'] + } + field = fields.BooleanField() + + +# Number types... + +class TestIntegerField(ValidAndInvalidValues): + """ + Valid and invalid values for `IntegerField`. + """ + valid_mappings = { + '1': 1, + '0': 0, + 1: 1, + 0: 0, + 1.0: 1, + 0.0: 0 + } + invalid_mappings = { + 'abc': ['A valid integer is required.'] + } + field = fields.IntegerField() + + +class TestMinMaxIntegerField(ValidAndInvalidValues): + """ + Valid and invalid values for `IntegerField` with min and max limits. + """ + valid_mappings = { + '1': 1, + '3': 3, + 1: 1, + 3: 3, + } + invalid_mappings = { + 0: ['Ensure this value is greater than or equal to 1.'], + 4: ['Ensure this value is less than or equal to 3.'], + '0': ['Ensure this value is greater than or equal to 1.'], + '4': ['Ensure this value is less than or equal to 3.'], + } + field = fields.IntegerField(min_value=1, max_value=3) + + +class TestFloatField(ValidAndInvalidValues): + """ + Valid and invalid values for `FloatField`. + """ + valid_mappings = { + '1': 1.0, + '0': 0.0, + 1: 1.0, + 0: 0.0, + 1.0: 1.0, + 0.0: 0.0, + } + invalid_mappings = { + 'abc': ["A valid number is required."] + } + field = fields.FloatField() + + +class TestMinMaxFloatField(ValidAndInvalidValues): + """ + Valid and invalid values for `FloatField` with min and max limits. + """ + valid_mappings = { + '1': 1, + '3': 3, + 1: 1, + 3: 3, + 1.0: 1.0, + 3.0: 3.0, + } + invalid_mappings = { + 0.9: ['Ensure this value is greater than or equal to 1.'], + 3.1: ['Ensure this value is less than or equal to 3.'], + '0.0': ['Ensure this value is greater than or equal to 1.'], + '3.1': ['Ensure this value is less than or equal to 3.'], + } + field = fields.FloatField(min_value=1, max_value=3) + + +class TestDecimalField(ValidAndInvalidValues): + """ + Valid and invalid values for `DecimalField`. + """ + valid_mappings = { + '12.3': Decimal('12.3'), + '0.1': Decimal('0.1'), + 10: Decimal('10'), + 0: Decimal('0'), + 12.3: Decimal('12.3'), + 0.1: Decimal('0.1'), + } + invalid_mappings = { + 'abc': ["A valid number is required."], + Decimal('Nan'): ["A valid number is required."], + Decimal('Inf'): ["A valid number is required."], + '12.345': ["Ensure that there are no more than 3 digits in total."], + '0.01': ["Ensure that there are no more than 1 decimal places."], + 123: ["Ensure that there are no more than 2 digits before the decimal point."] + } + field = fields.DecimalField(max_digits=3, decimal_places=1) + + +class TestMinMaxDecimalField(ValidAndInvalidValues): + """ + Valid and invalid values for `DecimalField` with min and max limits. + """ + valid_mappings = { + '10.0': 10.0, + '20.0': 20.0, + } + invalid_mappings = { + '9.9': ['Ensure this value is greater than or equal to 10.'], + '20.1': ['Ensure this value is less than or equal to 20.'], + } + field = fields.DecimalField( + max_digits=3, decimal_places=1, + min_value=10, max_value=20 + ) + + +# Date & time fields... + +class TestDateField(ValidAndInvalidValues): + """ + Valid and invalid values for `DateField`. + """ + valid_mappings = { + '2001-01-01': datetime.date(2001, 1, 1), + datetime.date(2001, 1, 1): datetime.date(2001, 1, 1), + } + invalid_mappings = { + 'abc': ['Date has wrong format. Use one of these formats instead: YYYY[-MM[-DD]]'], + '2001-99-99': ['Date has wrong format. Use one of these formats instead: YYYY[-MM[-DD]]'], + datetime.datetime(2001, 1, 1, 12, 00): ['Expected a date but got a datetime.'], + } + field = fields.DateField() + + +class TestCustomInputFormatDateField(ValidAndInvalidValues): + """ + Valid and invalid values for `DateField` with a cutom input format. + """ + valid_mappings = { + '1 Jan 2001': datetime.date(2001, 1, 1), + } + invalid_mappings = { + '2001-01-01': ['Date has wrong format. Use one of these formats instead: DD [Jan-Dec] YYYY'] + } + field = fields.DateField(input_formats=['%d %b %Y']) + + +class TestDateTimeField(ValidAndInvalidValues): + """ + Valid and invalid values for `DateTimeField`. + """ + valid_mappings = { + '2001-01-01 13:00': datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()), + '2001-01-01T13:00': datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()), + '2001-01-01T13:00Z': datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()), + '2001-01-01T14:00+0100': datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()), + datetime.datetime(2001, 1, 1, 13, 00): datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()), + datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()): datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()), + } + invalid_mappings = { + 'abc': ['Datetime has wrong format. Use one of these formats instead: YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]'], + '2001-99-99T99:00': ['Datetime has wrong format. Use one of these formats instead: YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]'], + datetime.date(2001, 1, 1): ['Expected a datetime but got a date.'], + } + field = fields.DateTimeField(default_timezone=timezone.UTC()) + + +class TestCustomInputFormatDateTimeField(ValidAndInvalidValues): + """ + Valid and invalid values for `DateTimeField` with a cutom input format. + """ + valid_mappings = { + '1:35pm, 1 Jan 2001': datetime.datetime(2001, 1, 1, 13, 35, tzinfo=timezone.UTC()), + } + invalid_mappings = { + '2001-01-01T20:50': ['Datetime has wrong format. Use one of these formats instead: hh:mm[AM|PM], DD [Jan-Dec] YYYY'] + } + field = fields.DateTimeField(default_timezone=timezone.UTC(), input_formats=['%I:%M%p, %d %b %Y']) + + +class TestNaiveDateTimeField(ValidAndInvalidValues): + """ + Valid and invalid values for `DateTimeField` with naive datetimes. + """ + valid_mappings = { + datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()): datetime.datetime(2001, 1, 1, 13, 00), + '2001-01-01 13:00': datetime.datetime(2001, 1, 1, 13, 00), + } + invalid_mappings = {} + field = fields.DateTimeField(default_timezone=None) + + +# Choice types... + +class TestChoiceField(ValidAndInvalidValues): + """ + Valid and invalid values for `ChoiceField`. + """ + valid_mappings = { + 'poor': 'poor', + 'medium': 'medium', + 'good': 'good', + } + invalid_mappings = { + 'awful': ['`awful` is not a valid choice.'] + } + field = fields.ChoiceField( + choices=[ + ('poor', 'Poor quality'), + ('medium', 'Medium quality'), + ('good', 'Good quality'), + ] + ) + + +class TestChoiceFieldWithType(ValidAndInvalidValues): + """ + Valid and invalid values for a `Choice` field that uses an integer type, + instead of a char type. + """ + valid_mappings = { + '1': 1, + 3: 3, + } + invalid_mappings = { + 5: ['`5` is not a valid choice.'], + 'abc': ['`abc` is not a valid choice.'] + } + field = fields.ChoiceField( + choices=[ + (1, 'Poor quality'), + (2, 'Medium quality'), + (3, 'Good quality'), + ] + ) + + +class TestChoiceFieldWithListChoices(ValidAndInvalidValues): + """ + Valid and invalid values for a `Choice` field that uses a flat list for the + choices, rather than a list of pairs of (`value`, `description`). + """ + valid_mappings = { + 'poor': 'poor', + 'medium': 'medium', + 'good': 'good', + } + invalid_mappings = { + 'awful': ['`awful` is not a valid choice.'] + } + field = fields.ChoiceField(choices=('poor', 'medium', 'good')) + + +class TestMultipleChoiceField(ValidAndInvalidValues): + """ + Valid and invalid values for `MultipleChoiceField`. + """ + valid_mappings = { + (): set(), + ('aircon',): set(['aircon']), + ('aircon', 'manual'): set(['aircon', 'manual']), + } + invalid_mappings = { + 'abc': ['Expected a list of items but got type `str`'], + ('aircon', 'incorrect'): ['`incorrect` is not a valid choice.'] + } + field = fields.MultipleChoiceField( + choices=[ + ('aircon', 'AirCon'), + ('manual', 'Manual drive'), + ('diesel', 'Diesel'), + ] + ) + + # """ # General serializer field tests. # """ |
