From f22d0afc3dfc7478e084d1d6ed6b53f71641dec6 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 23 Sep 2014 14:15:00 +0100 Subject: Tests for field choices --- rest_framework/fields.py | 21 +- rest_framework/serializers.py | 3 + rest_framework/utils/field_mapping.py | 58 +-- tests/test_field_options.py | 55 --- tests/test_field_values.py | 607 ------------------------------ tests/test_fields.py | 674 ++++++++++++++++++++++++++++++++++ tests/test_model_serializer.py | 47 ++- 7 files changed, 756 insertions(+), 709 deletions(-) delete mode 100644 tests/test_field_options.py delete mode 100644 tests/test_field_values.py create mode 100644 tests/test_fields.py diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 48a3e1ab..f5bae734 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -102,6 +102,7 @@ class Field(object): 'null': _('This field may not be null.') } default_validators = [] + default_empty_html = None def __init__(self, read_only=False, write_only=False, required=None, default=empty, initial=None, source=None, @@ -185,6 +186,11 @@ class Field(object): Given the *incoming* primative data, return the value for this field that should be validated and transformed to a native value. """ + if html.is_html_input(dictionary): + # HTML forms will represent empty fields as '', and cannot + # represent None or False values directly. + ret = dictionary.get(self.field_name, '') + return self.default_empty_html if (ret == '') else ret return dictionary.get(self.field_name, empty) def get_attribute(self, instance): @@ -236,9 +242,6 @@ class Field(object): Test the given value against all the validators on the field, and either raise a `ValidationError` or simply return. """ - if value in (None, '', [], (), {}): - return - errors = [] for validator in self.validators: try: @@ -282,16 +285,10 @@ class BooleanField(Field): default_error_messages = { 'invalid': _('`{input}` is not a valid boolean.') } + default_empty_html = False TRUE_VALUES = set(('t', 'T', 'true', 'True', 'TRUE', '1', 1, True)) FALSE_VALUES = set(('f', 'F', 'false', 'False', 'FALSE', '0', 0, 0.0, False)) - def get_value(self, dictionary): - if html.is_html_input(dictionary): - # HTML forms do not send a `False` value on an empty checkbox, - # so we override the default empty value to be False. - return dictionary.get(self.field_name, False) - return dictionary.get(self.field_name, empty) - def to_internal_value(self, data): if data in self.TRUE_VALUES: return True @@ -315,6 +312,7 @@ class CharField(Field): default_error_messages = { 'blank': _('This field may not be blank.') } + default_empty_html = '' def __init__(self, **kwargs): self.allow_blank = kwargs.pop('allow_blank', False) @@ -323,6 +321,9 @@ class CharField(Field): super(CharField, self).__init__(**kwargs) def run_validation(self, data=empty): + # Test for the empty string here so that it does not get validated, + # and so that subclasses do not need to handle it explicitly + # inside the `to_internal_value()` method. if data == '': if not self.allow_blank: self.fail('blank') diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index d9f9c8cb..949f5915 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -411,6 +411,9 @@ class ModelSerializer(Serializer): # `ModelField`, which is used when no other typed field # matched to the model field. kwargs.pop('model_field', None) + if not issubclass(field_cls, CharField): + # `allow_blank` is only valid for textual fields. + kwargs.pop('allow_blank', None) elif field_name in info.relations: # Create forward and reverse relationships. diff --git a/rest_framework/utils/field_mapping.py b/rest_framework/utils/field_mapping.py index be72e444..1c718ccb 100644 --- a/rest_framework/utils/field_mapping.py +++ b/rest_framework/utils/field_mapping.py @@ -49,8 +49,9 @@ def get_field_kwargs(field_name, model_field): kwargs = {} validator_kwarg = model_field.validators - if model_field.null or model_field.blank: - kwargs['required'] = False + # The following will only be used by ModelField classes. + # Gets removed for everything else. + kwargs['model_field'] = model_field if model_field.verbose_name and needs_label(model_field, field_name): kwargs['label'] = capfirst(model_field.verbose_name) @@ -59,23 +60,26 @@ def get_field_kwargs(field_name, model_field): kwargs['help_text'] = model_field.help_text if isinstance(model_field, models.AutoField) or not model_field.editable: + # If this field is read-only, then return early. + # Further keyword arguments are not valid. kwargs['read_only'] = True - # Read only implies that the field is not required. - # We have a cleaner repr on the instance if we don't set it. - kwargs.pop('required', None) + return kwargs if model_field.has_default(): - kwargs['default'] = model_field.get_default() - # Having a default implies that the field is not required. - # We have a cleaner repr on the instance if we don't set it. - kwargs.pop('required', None) + kwargs['required'] = False if model_field.flatchoices: - # If this model field contains choices, then return now, - # any further keyword arguments are not valid. + # If this model field contains choices, then return early. + # Further keyword arguments are not valid. kwargs['choices'] = model_field.flatchoices return kwargs + if model_field.null: + kwargs['allow_null'] = True + + if model_field.blank: + kwargs['allow_blank'] = True + # Ensure that max_length is passed explicitly as a keyword arg, # rather than as a validator. max_length = getattr(model_field, 'max_length', None) @@ -88,7 +92,10 @@ def get_field_kwargs(field_name, model_field): # Ensure that min_length is passed explicitly as a keyword arg, # rather than as a validator. - min_length = getattr(model_field, 'min_length', None) + min_length = next(( + validator.limit_value for validator in validator_kwarg + if isinstance(validator, validators.MinLengthValidator) + ), None) if min_length is not None: kwargs['min_length'] = min_length validator_kwarg = [ @@ -153,20 +160,9 @@ def get_field_kwargs(field_name, model_field): if decimal_places is not None: kwargs['decimal_places'] = decimal_places - if isinstance(model_field, models.BooleanField): - # models.BooleanField has `blank=True`, but *is* actually - # required *unless* a default is provided. - # Also note that Django<1.6 uses `default=False` for - # models.BooleanField, but Django>=1.6 uses `default=None`. - kwargs.pop('required', None) - if validator_kwarg: kwargs['validators'] = validator_kwarg - # The following will only be used by ModelField classes. - # Gets removed for everything else. - kwargs['model_field'] = model_field - return kwargs @@ -188,16 +184,22 @@ def get_relation_kwargs(field_name, relation_info): kwargs.pop('queryset', None) if model_field: - if model_field.null or model_field.blank: - kwargs['required'] = False if model_field.verbose_name and needs_label(model_field, field_name): kwargs['label'] = capfirst(model_field.verbose_name) - if not model_field.editable: - kwargs['read_only'] = True - kwargs.pop('queryset', None) help_text = clean_manytomany_helptext(model_field.help_text) if help_text: kwargs['help_text'] = help_text + if not model_field.editable: + kwargs['read_only'] = True + kwargs.pop('queryset', None) + if kwargs.get('read_only', False): + # If this field is read-only, then return early. + # No further keyword arguments are valid. + return kwargs + if model_field.has_default(): + kwargs['required'] = False + if model_field.null: + kwargs['allow_null'] = True return kwargs diff --git a/tests/test_field_options.py b/tests/test_field_options.py deleted file mode 100644 index 444bd424..00000000 --- a/tests/test_field_options.py +++ /dev/null @@ -1,55 +0,0 @@ -from rest_framework import fields -import pytest - - -class TestFieldOptions: - def test_required(self): - """ - By default a field must be included in the input. - """ - field = fields.IntegerField() - with pytest.raises(fields.ValidationError) as exc_info: - field.run_validation() - assert exc_info.value.messages == ['This field is required.'] - - def test_not_required(self): - """ - If `required=False` then a field may be omitted from the input. - """ - field = fields.IntegerField(required=False) - with pytest.raises(fields.SkipField): - field.run_validation() - - def test_disallow_null(self): - """ - By default `None` is not a valid input. - """ - field = fields.IntegerField() - with pytest.raises(fields.ValidationError) as exc_info: - field.run_validation(None) - assert exc_info.value.messages == ['This field may not be null.'] - - def test_allow_null(self): - """ - If `allow_null=True` then `None` is a valid input. - """ - field = fields.IntegerField(allow_null=True) - output = field.run_validation(None) - assert output is None - - def test_disallow_blank(self): - """ - By default '' is not a valid input. - """ - field = fields.CharField() - with pytest.raises(fields.ValidationError) as exc_info: - field.run_validation('') - assert exc_info.value.messages == ['This field may not be blank.'] - - def test_allow_blank(self): - """ - If `allow_blank=True` then '' is a valid input. - """ - field = fields.CharField(allow_blank=True) - output = field.run_validation('') - assert output is '' diff --git a/tests/test_field_values.py b/tests/test_field_values.py deleted file mode 100644 index bac50f0b..00000000 --- a/tests/test_field_values.py +++ /dev/null @@ -1,607 +0,0 @@ -from decimal import Decimal -from django.utils import timezone -from rest_framework import fields -import datetime -import django -import pytest - - -def get_items(mapping_or_list_of_two_tuples): - # Tests accept either lists of two tuples, or dictionaries. - if isinstance(mapping_or_list_of_two_tuples, dict): - # {value: expected} - return mapping_or_list_of_two_tuples.items() - # [(value, expected), ...] - return mapping_or_list_of_two_tuples - - -class FieldValues: - """ - Base class for testing valid and invalid input values. - """ - def test_valid_inputs(self): - """ - Ensure that valid values return the expected validated data. - """ - for input_value, expected_output in get_items(self.valid_inputs): - assert self.field.run_validation(input_value) == expected_output - - def test_invalid_inputs(self): - """ - Ensure that invalid values raise the expected validation error. - """ - for input_value, expected_failure in get_items(self.invalid_inputs): - with pytest.raises(fields.ValidationError) as exc_info: - self.field.run_validation(input_value) - assert exc_info.value.messages == expected_failure - - def test_outputs(self): - for output_value, expected_output in get_items(self.outputs): - assert self.field.to_representation(output_value) == expected_output - - -# Boolean types... - -class TestBooleanField(FieldValues): - """ - Valid and invalid values for `BooleanField`. - """ - valid_inputs = { - 'true': True, - 'false': False, - '1': True, - '0': False, - 1: True, - 0: False, - True: True, - False: False, - } - invalid_inputs = { - 'foo': ['`foo` is not a valid boolean.'] - } - outputs = { - 'true': True, - 'false': False, - '1': True, - '0': False, - 1: True, - 0: False, - True: True, - False: False, - 'other': True - } - field = fields.BooleanField() - - -# String types... - -class TestCharField(FieldValues): - """ - Valid and invalid values for `CharField`. - """ - valid_inputs = { - 1: '1', - 'abc': 'abc' - } - invalid_inputs = { - '': ['This field may not be blank.'] - } - outputs = { - 1: '1', - 'abc': 'abc' - } - field = fields.CharField() - - -class TestEmailField(FieldValues): - """ - Valid and invalid values for `EmailField`. - """ - valid_inputs = { - 'example@example.com': 'example@example.com', - ' example@example.com ': 'example@example.com', - } - invalid_inputs = { - 'examplecom': ['Enter a valid email address.'] - } - outputs = {} - field = fields.EmailField() - - -class TestRegexField(FieldValues): - """ - Valid and invalid values for `RegexField`. - """ - valid_inputs = { - 'a9': 'a9', - } - invalid_inputs = { - 'A9': ["This value does not match the required pattern."] - } - outputs = {} - field = fields.RegexField(regex='[a-z][0-9]') - - -class TestSlugField(FieldValues): - """ - Valid and invalid values for `SlugField`. - """ - valid_inputs = { - 'slug-99': 'slug-99', - } - invalid_inputs = { - 'slug 99': ["Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens."] - } - outputs = {} - field = fields.SlugField() - - -class TestURLField(FieldValues): - """ - Valid and invalid values for `URLField`. - """ - valid_inputs = { - 'http://example.com': 'http://example.com', - } - invalid_inputs = { - 'example.com': ['Enter a valid URL.'] - } - outputs = {} - field = fields.URLField() - - -# Number types... - -class TestIntegerField(FieldValues): - """ - Valid and invalid values for `IntegerField`. - """ - valid_inputs = { - '1': 1, - '0': 0, - 1: 1, - 0: 0, - 1.0: 1, - 0.0: 0 - } - invalid_inputs = { - 'abc': ['A valid integer is required.'] - } - outputs = { - '1': 1, - '0': 0, - 1: 1, - 0: 0, - 1.0: 1, - 0.0: 0 - } - field = fields.IntegerField() - - -class TestMinMaxIntegerField(FieldValues): - """ - Valid and invalid values for `IntegerField` with min and max limits. - """ - valid_inputs = { - '1': 1, - '3': 3, - 1: 1, - 3: 3, - } - invalid_inputs = { - 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.'], - } - outputs = {} - field = fields.IntegerField(min_value=1, max_value=3) - - -class TestFloatField(FieldValues): - """ - Valid and invalid values for `FloatField`. - """ - valid_inputs = { - '1': 1.0, - '0': 0.0, - 1: 1.0, - 0: 0.0, - 1.0: 1.0, - 0.0: 0.0, - } - invalid_inputs = { - 'abc': ["A valid number is required."] - } - outputs = { - '1': 1.0, - '0': 0.0, - 1: 1.0, - 0: 0.0, - 1.0: 1.0, - 0.0: 0.0, - } - field = fields.FloatField() - - -class TestMinMaxFloatField(FieldValues): - """ - Valid and invalid values for `FloatField` with min and max limits. - """ - valid_inputs = { - '1': 1, - '3': 3, - 1: 1, - 3: 3, - 1.0: 1.0, - 3.0: 3.0, - } - invalid_inputs = { - 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.'], - } - outputs = {} - field = fields.FloatField(min_value=1, max_value=3) - - -class TestDecimalField(FieldValues): - """ - Valid and invalid values for `DecimalField`. - """ - valid_inputs = { - '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_inputs = ( - ('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."]) - ) - outputs = { - '1': '1.0', - '0': '0.0', - '1.09': '1.1', - '0.04': '0.0', - 1: '1.0', - 0: '0.0', - Decimal('1.0'): '1.0', - Decimal('0.0'): '0.0', - Decimal('1.09'): '1.1', - Decimal('0.04'): '0.0', - } - field = fields.DecimalField(max_digits=3, decimal_places=1) - - -class TestMinMaxDecimalField(FieldValues): - """ - Valid and invalid values for `DecimalField` with min and max limits. - """ - valid_inputs = { - '10.0': Decimal('10.0'), - '20.0': Decimal('20.0'), - } - invalid_inputs = { - '9.9': ['Ensure this value is greater than or equal to 10.'], - '20.1': ['Ensure this value is less than or equal to 20.'], - } - outputs = {} - field = fields.DecimalField( - max_digits=3, decimal_places=1, - min_value=10, max_value=20 - ) - - -class TestNoStringCoercionDecimalField(FieldValues): - """ - Output values for `DecimalField` with `coerce_to_string=False`. - """ - valid_inputs = {} - invalid_inputs = {} - outputs = { - 1.09: Decimal('1.1'), - 0.04: Decimal('0.0'), - '1.09': Decimal('1.1'), - '0.04': Decimal('0.0'), - Decimal('1.09'): Decimal('1.1'), - Decimal('0.04'): Decimal('0.0'), - } - field = fields.DecimalField( - max_digits=3, decimal_places=1, - coerce_to_string=False - ) - - -# Date & time fields... - -class TestDateField(FieldValues): - """ - Valid and invalid values for `DateField`. - """ - valid_inputs = { - '2001-01-01': datetime.date(2001, 1, 1), - datetime.date(2001, 1, 1): datetime.date(2001, 1, 1), - } - invalid_inputs = { - '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.'], - } - outputs = { - datetime.date(2001, 1, 1): '2001-01-01', - } - field = fields.DateField() - - -class TestCustomInputFormatDateField(FieldValues): - """ - Valid and invalid values for `DateField` with a cutom input format. - """ - valid_inputs = { - '1 Jan 2001': datetime.date(2001, 1, 1), - } - invalid_inputs = { - '2001-01-01': ['Date has wrong format. Use one of these formats instead: DD [Jan-Dec] YYYY'] - } - outputs = {} - field = fields.DateField(input_formats=['%d %b %Y']) - - -class TestCustomOutputFormatDateField(FieldValues): - """ - Values for `DateField` with a custom output format. - """ - valid_inputs = {} - invalid_inputs = {} - outputs = { - datetime.date(2001, 1, 1): '01 Jan 2001' - } - field = fields.DateField(format='%d %b %Y') - - -class TestNoOutputFormatDateField(FieldValues): - """ - Values for `DateField` with no output format. - """ - valid_inputs = {} - invalid_inputs = {} - outputs = { - datetime.date(2001, 1, 1): datetime.date(2001, 1, 1) - } - field = fields.DateField(format=None) - - -class TestDateTimeField(FieldValues): - """ - Valid and invalid values for `DateTimeField`. - """ - valid_inputs = { - '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()), - 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()), - # Note that 1.4 does not support timezone string parsing. - '2001-01-01T14:00+01:00' if (django.VERSION > (1, 4)) else '2001-01-01T13:00Z': datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()) - } - invalid_inputs = { - '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.'], - } - outputs = { - datetime.datetime(2001, 1, 1, 13, 00): '2001-01-01T13:00:00', - datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()): '2001-01-01T13:00:00Z', - } - field = fields.DateTimeField(default_timezone=timezone.UTC()) - - -class TestCustomInputFormatDateTimeField(FieldValues): - """ - Valid and invalid values for `DateTimeField` with a cutom input format. - """ - valid_inputs = { - '1:35pm, 1 Jan 2001': datetime.datetime(2001, 1, 1, 13, 35, tzinfo=timezone.UTC()), - } - invalid_inputs = { - '2001-01-01T20:50': ['Datetime has wrong format. Use one of these formats instead: hh:mm[AM|PM], DD [Jan-Dec] YYYY'] - } - outputs = {} - field = fields.DateTimeField(default_timezone=timezone.UTC(), input_formats=['%I:%M%p, %d %b %Y']) - - -class TestCustomOutputFormatDateTimeField(FieldValues): - """ - Values for `DateTimeField` with a custom output format. - """ - valid_inputs = {} - invalid_inputs = {} - outputs = { - datetime.datetime(2001, 1, 1, 13, 00): '01:00PM, 01 Jan 2001', - } - field = fields.DateTimeField(format='%I:%M%p, %d %b %Y') - - -class TestNoOutputFormatDateTimeField(FieldValues): - """ - Values for `DateTimeField` with no output format. - """ - valid_inputs = {} - invalid_inputs = {} - outputs = { - datetime.datetime(2001, 1, 1, 13, 00): datetime.datetime(2001, 1, 1, 13, 00), - } - field = fields.DateTimeField(format=None) - - -class TestNaiveDateTimeField(FieldValues): - """ - Valid and invalid values for `DateTimeField` with naive datetimes. - """ - valid_inputs = { - 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_inputs = {} - outputs = {} - field = fields.DateTimeField(default_timezone=None) - - -class TestTimeField(FieldValues): - """ - Valid and invalid values for `TimeField`. - """ - valid_inputs = { - '13:00': datetime.time(13, 00), - datetime.time(13, 00): datetime.time(13, 00), - } - invalid_inputs = { - 'abc': ['Time has wrong format. Use one of these formats instead: hh:mm[:ss[.uuuuuu]]'], - '99:99': ['Time has wrong format. Use one of these formats instead: hh:mm[:ss[.uuuuuu]]'], - } - outputs = { - datetime.time(13, 00): '13:00:00' - } - field = fields.TimeField() - - -class TestCustomInputFormatTimeField(FieldValues): - """ - Valid and invalid values for `TimeField` with a custom input format. - """ - valid_inputs = { - '1:00pm': datetime.time(13, 00), - } - invalid_inputs = { - '13:00': ['Time has wrong format. Use one of these formats instead: hh:mm[AM|PM]'], - } - outputs = {} - field = fields.TimeField(input_formats=['%I:%M%p']) - - -class TestCustomOutputFormatTimeField(FieldValues): - """ - Values for `TimeField` with a custom output format. - """ - valid_inputs = {} - invalid_inputs = {} - outputs = { - datetime.time(13, 00): '01:00PM' - } - field = fields.TimeField(format='%I:%M%p') - - -class TestNoOutputFormatTimeField(FieldValues): - """ - Values for `TimeField` with a no output format. - """ - valid_inputs = {} - invalid_inputs = {} - outputs = { - datetime.time(13, 00): datetime.time(13, 00) - } - field = fields.TimeField(format=None) - - -# Choice types... - -class TestChoiceField(FieldValues): - """ - Valid and invalid values for `ChoiceField`. - """ - valid_inputs = { - 'poor': 'poor', - 'medium': 'medium', - 'good': 'good', - } - invalid_inputs = { - 'amazing': ['`amazing` is not a valid choice.'] - } - outputs = { - 'good': 'good' - } - field = fields.ChoiceField( - choices=[ - ('poor', 'Poor quality'), - ('medium', 'Medium quality'), - ('good', 'Good quality'), - ] - ) - - -class TestChoiceFieldWithType(FieldValues): - """ - Valid and invalid values for a `Choice` field that uses an integer type, - instead of a char type. - """ - valid_inputs = { - '1': 1, - 3: 3, - } - invalid_inputs = { - 5: ['`5` is not a valid choice.'], - 'abc': ['`abc` is not a valid choice.'] - } - outputs = { - '1': 1, - 1: 1 - } - field = fields.ChoiceField( - choices=[ - (1, 'Poor quality'), - (2, 'Medium quality'), - (3, 'Good quality'), - ] - ) - - -class TestChoiceFieldWithListChoices(FieldValues): - """ - 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_inputs = { - 'poor': 'poor', - 'medium': 'medium', - 'good': 'good', - } - invalid_inputs = { - 'awful': ['`awful` is not a valid choice.'] - } - outputs = { - 'good': 'good' - } - field = fields.ChoiceField(choices=('poor', 'medium', 'good')) - - -class TestMultipleChoiceField(FieldValues): - """ - Valid and invalid values for `MultipleChoiceField`. - """ - valid_inputs = { - (): set(), - ('aircon',): set(['aircon']), - ('aircon', 'manual'): set(['aircon', 'manual']), - } - invalid_inputs = { - 'abc': ['Expected a list of items but got type `str`'], - ('aircon', 'incorrect'): ['`incorrect` is not a valid choice.'] - } - outputs = [ - (['aircon', 'manual'], set(['aircon', 'manual'])) - ] - field = fields.MultipleChoiceField( - choices=[ - ('aircon', 'AirCon'), - ('manual', 'Manual drive'), - ('diesel', 'Diesel'), - ] - ) diff --git a/tests/test_fields.py b/tests/test_fields.py new file mode 100644 index 00000000..6bf9aed4 --- /dev/null +++ b/tests/test_fields.py @@ -0,0 +1,674 @@ +from decimal import Decimal +from django.utils import timezone +from rest_framework import fields +import datetime +import django +import pytest + + +# Tests for field keyword arguments and core functionality. +# --------------------------------------------------------- + +class TestFieldOptions: + def test_required(self): + """ + By default a field must be included in the input. + """ + field = fields.IntegerField() + with pytest.raises(fields.ValidationError) as exc_info: + field.run_validation() + assert exc_info.value.messages == ['This field is required.'] + + def test_not_required(self): + """ + If `required=False` then a field may be omitted from the input. + """ + field = fields.IntegerField(required=False) + with pytest.raises(fields.SkipField): + field.run_validation() + + def test_disallow_null(self): + """ + By default `None` is not a valid input. + """ + field = fields.IntegerField() + with pytest.raises(fields.ValidationError) as exc_info: + field.run_validation(None) + assert exc_info.value.messages == ['This field may not be null.'] + + def test_allow_null(self): + """ + If `allow_null=True` then `None` is a valid input. + """ + field = fields.IntegerField(allow_null=True) + output = field.run_validation(None) + assert output is None + + def test_disallow_blank(self): + """ + By default '' is not a valid input. + """ + field = fields.CharField() + with pytest.raises(fields.ValidationError) as exc_info: + field.run_validation('') + assert exc_info.value.messages == ['This field may not be blank.'] + + def test_allow_blank(self): + """ + If `allow_blank=True` then '' is a valid input. + """ + field = fields.CharField(allow_blank=True) + output = field.run_validation('') + assert output is '' + + def test_default(self): + """ + If `default` is set, then omitted values get the default input. + """ + field = fields.IntegerField(default=123) + output = field.run_validation() + assert output is 123 + + +# Tests for field input and output values. +# ---------------------------------------- + +def get_items(mapping_or_list_of_two_tuples): + # Tests accept either lists of two tuples, or dictionaries. + if isinstance(mapping_or_list_of_two_tuples, dict): + # {value: expected} + return mapping_or_list_of_two_tuples.items() + # [(value, expected), ...] + return mapping_or_list_of_two_tuples + + +class FieldValues: + """ + Base class for testing valid and invalid input values. + """ + def test_valid_inputs(self): + """ + Ensure that valid values return the expected validated data. + """ + for input_value, expected_output in get_items(self.valid_inputs): + assert self.field.run_validation(input_value) == expected_output + + def test_invalid_inputs(self): + """ + Ensure that invalid values raise the expected validation error. + """ + for input_value, expected_failure in get_items(self.invalid_inputs): + with pytest.raises(fields.ValidationError) as exc_info: + self.field.run_validation(input_value) + assert exc_info.value.messages == expected_failure + + def test_outputs(self): + for output_value, expected_output in get_items(self.outputs): + assert self.field.to_representation(output_value) == expected_output + + +# Boolean types... + +class TestBooleanField(FieldValues): + """ + Valid and invalid values for `BooleanField`. + """ + valid_inputs = { + 'true': True, + 'false': False, + '1': True, + '0': False, + 1: True, + 0: False, + True: True, + False: False, + } + invalid_inputs = { + 'foo': ['`foo` is not a valid boolean.'] + } + outputs = { + 'true': True, + 'false': False, + '1': True, + '0': False, + 1: True, + 0: False, + True: True, + False: False, + 'other': True + } + field = fields.BooleanField() + + +# String types... + +class TestCharField(FieldValues): + """ + Valid and invalid values for `CharField`. + """ + valid_inputs = { + 1: '1', + 'abc': 'abc' + } + invalid_inputs = { + '': ['This field may not be blank.'] + } + outputs = { + 1: '1', + 'abc': 'abc' + } + field = fields.CharField() + + +class TestEmailField(FieldValues): + """ + Valid and invalid values for `EmailField`. + """ + valid_inputs = { + 'example@example.com': 'example@example.com', + ' example@example.com ': 'example@example.com', + } + invalid_inputs = { + 'examplecom': ['Enter a valid email address.'] + } + outputs = {} + field = fields.EmailField() + + +class TestRegexField(FieldValues): + """ + Valid and invalid values for `RegexField`. + """ + valid_inputs = { + 'a9': 'a9', + } + invalid_inputs = { + 'A9': ["This value does not match the required pattern."] + } + outputs = {} + field = fields.RegexField(regex='[a-z][0-9]') + + +class TestSlugField(FieldValues): + """ + Valid and invalid values for `SlugField`. + """ + valid_inputs = { + 'slug-99': 'slug-99', + } + invalid_inputs = { + 'slug 99': ["Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens."] + } + outputs = {} + field = fields.SlugField() + + +class TestURLField(FieldValues): + """ + Valid and invalid values for `URLField`. + """ + valid_inputs = { + 'http://example.com': 'http://example.com', + } + invalid_inputs = { + 'example.com': ['Enter a valid URL.'] + } + outputs = {} + field = fields.URLField() + + +# Number types... + +class TestIntegerField(FieldValues): + """ + Valid and invalid values for `IntegerField`. + """ + valid_inputs = { + '1': 1, + '0': 0, + 1: 1, + 0: 0, + 1.0: 1, + 0.0: 0 + } + invalid_inputs = { + 'abc': ['A valid integer is required.'] + } + outputs = { + '1': 1, + '0': 0, + 1: 1, + 0: 0, + 1.0: 1, + 0.0: 0 + } + field = fields.IntegerField() + + +class TestMinMaxIntegerField(FieldValues): + """ + Valid and invalid values for `IntegerField` with min and max limits. + """ + valid_inputs = { + '1': 1, + '3': 3, + 1: 1, + 3: 3, + } + invalid_inputs = { + 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.'], + } + outputs = {} + field = fields.IntegerField(min_value=1, max_value=3) + + +class TestFloatField(FieldValues): + """ + Valid and invalid values for `FloatField`. + """ + valid_inputs = { + '1': 1.0, + '0': 0.0, + 1: 1.0, + 0: 0.0, + 1.0: 1.0, + 0.0: 0.0, + } + invalid_inputs = { + 'abc': ["A valid number is required."] + } + outputs = { + '1': 1.0, + '0': 0.0, + 1: 1.0, + 0: 0.0, + 1.0: 1.0, + 0.0: 0.0, + } + field = fields.FloatField() + + +class TestMinMaxFloatField(FieldValues): + """ + Valid and invalid values for `FloatField` with min and max limits. + """ + valid_inputs = { + '1': 1, + '3': 3, + 1: 1, + 3: 3, + 1.0: 1.0, + 3.0: 3.0, + } + invalid_inputs = { + 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.'], + } + outputs = {} + field = fields.FloatField(min_value=1, max_value=3) + + +class TestDecimalField(FieldValues): + """ + Valid and invalid values for `DecimalField`. + """ + valid_inputs = { + '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_inputs = ( + ('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."]) + ) + outputs = { + '1': '1.0', + '0': '0.0', + '1.09': '1.1', + '0.04': '0.0', + 1: '1.0', + 0: '0.0', + Decimal('1.0'): '1.0', + Decimal('0.0'): '0.0', + Decimal('1.09'): '1.1', + Decimal('0.04'): '0.0', + } + field = fields.DecimalField(max_digits=3, decimal_places=1) + + +class TestMinMaxDecimalField(FieldValues): + """ + Valid and invalid values for `DecimalField` with min and max limits. + """ + valid_inputs = { + '10.0': Decimal('10.0'), + '20.0': Decimal('20.0'), + } + invalid_inputs = { + '9.9': ['Ensure this value is greater than or equal to 10.'], + '20.1': ['Ensure this value is less than or equal to 20.'], + } + outputs = {} + field = fields.DecimalField( + max_digits=3, decimal_places=1, + min_value=10, max_value=20 + ) + + +class TestNoStringCoercionDecimalField(FieldValues): + """ + Output values for `DecimalField` with `coerce_to_string=False`. + """ + valid_inputs = {} + invalid_inputs = {} + outputs = { + 1.09: Decimal('1.1'), + 0.04: Decimal('0.0'), + '1.09': Decimal('1.1'), + '0.04': Decimal('0.0'), + Decimal('1.09'): Decimal('1.1'), + Decimal('0.04'): Decimal('0.0'), + } + field = fields.DecimalField( + max_digits=3, decimal_places=1, + coerce_to_string=False + ) + + +# Date & time fields... + +class TestDateField(FieldValues): + """ + Valid and invalid values for `DateField`. + """ + valid_inputs = { + '2001-01-01': datetime.date(2001, 1, 1), + datetime.date(2001, 1, 1): datetime.date(2001, 1, 1), + } + invalid_inputs = { + '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.'], + } + outputs = { + datetime.date(2001, 1, 1): '2001-01-01', + } + field = fields.DateField() + + +class TestCustomInputFormatDateField(FieldValues): + """ + Valid and invalid values for `DateField` with a cutom input format. + """ + valid_inputs = { + '1 Jan 2001': datetime.date(2001, 1, 1), + } + invalid_inputs = { + '2001-01-01': ['Date has wrong format. Use one of these formats instead: DD [Jan-Dec] YYYY'] + } + outputs = {} + field = fields.DateField(input_formats=['%d %b %Y']) + + +class TestCustomOutputFormatDateField(FieldValues): + """ + Values for `DateField` with a custom output format. + """ + valid_inputs = {} + invalid_inputs = {} + outputs = { + datetime.date(2001, 1, 1): '01 Jan 2001' + } + field = fields.DateField(format='%d %b %Y') + + +class TestNoOutputFormatDateField(FieldValues): + """ + Values for `DateField` with no output format. + """ + valid_inputs = {} + invalid_inputs = {} + outputs = { + datetime.date(2001, 1, 1): datetime.date(2001, 1, 1) + } + field = fields.DateField(format=None) + + +class TestDateTimeField(FieldValues): + """ + Valid and invalid values for `DateTimeField`. + """ + valid_inputs = { + '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()), + 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()), + # Note that 1.4 does not support timezone string parsing. + '2001-01-01T14:00+01:00' if (django.VERSION > (1, 4)) else '2001-01-01T13:00Z': datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()) + } + invalid_inputs = { + '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.'], + } + outputs = { + datetime.datetime(2001, 1, 1, 13, 00): '2001-01-01T13:00:00', + datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()): '2001-01-01T13:00:00Z', + } + field = fields.DateTimeField(default_timezone=timezone.UTC()) + + +class TestCustomInputFormatDateTimeField(FieldValues): + """ + Valid and invalid values for `DateTimeField` with a cutom input format. + """ + valid_inputs = { + '1:35pm, 1 Jan 2001': datetime.datetime(2001, 1, 1, 13, 35, tzinfo=timezone.UTC()), + } + invalid_inputs = { + '2001-01-01T20:50': ['Datetime has wrong format. Use one of these formats instead: hh:mm[AM|PM], DD [Jan-Dec] YYYY'] + } + outputs = {} + field = fields.DateTimeField(default_timezone=timezone.UTC(), input_formats=['%I:%M%p, %d %b %Y']) + + +class TestCustomOutputFormatDateTimeField(FieldValues): + """ + Values for `DateTimeField` with a custom output format. + """ + valid_inputs = {} + invalid_inputs = {} + outputs = { + datetime.datetime(2001, 1, 1, 13, 00): '01:00PM, 01 Jan 2001', + } + field = fields.DateTimeField(format='%I:%M%p, %d %b %Y') + + +class TestNoOutputFormatDateTimeField(FieldValues): + """ + Values for `DateTimeField` with no output format. + """ + valid_inputs = {} + invalid_inputs = {} + outputs = { + datetime.datetime(2001, 1, 1, 13, 00): datetime.datetime(2001, 1, 1, 13, 00), + } + field = fields.DateTimeField(format=None) + + +class TestNaiveDateTimeField(FieldValues): + """ + Valid and invalid values for `DateTimeField` with naive datetimes. + """ + valid_inputs = { + 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_inputs = {} + outputs = {} + field = fields.DateTimeField(default_timezone=None) + + +class TestTimeField(FieldValues): + """ + Valid and invalid values for `TimeField`. + """ + valid_inputs = { + '13:00': datetime.time(13, 00), + datetime.time(13, 00): datetime.time(13, 00), + } + invalid_inputs = { + 'abc': ['Time has wrong format. Use one of these formats instead: hh:mm[:ss[.uuuuuu]]'], + '99:99': ['Time has wrong format. Use one of these formats instead: hh:mm[:ss[.uuuuuu]]'], + } + outputs = { + datetime.time(13, 00): '13:00:00' + } + field = fields.TimeField() + + +class TestCustomInputFormatTimeField(FieldValues): + """ + Valid and invalid values for `TimeField` with a custom input format. + """ + valid_inputs = { + '1:00pm': datetime.time(13, 00), + } + invalid_inputs = { + '13:00': ['Time has wrong format. Use one of these formats instead: hh:mm[AM|PM]'], + } + outputs = {} + field = fields.TimeField(input_formats=['%I:%M%p']) + + +class TestCustomOutputFormatTimeField(FieldValues): + """ + Values for `TimeField` with a custom output format. + """ + valid_inputs = {} + invalid_inputs = {} + outputs = { + datetime.time(13, 00): '01:00PM' + } + field = fields.TimeField(format='%I:%M%p') + + +class TestNoOutputFormatTimeField(FieldValues): + """ + Values for `TimeField` with a no output format. + """ + valid_inputs = {} + invalid_inputs = {} + outputs = { + datetime.time(13, 00): datetime.time(13, 00) + } + field = fields.TimeField(format=None) + + +# Choice types... + +class TestChoiceField(FieldValues): + """ + Valid and invalid values for `ChoiceField`. + """ + valid_inputs = { + 'poor': 'poor', + 'medium': 'medium', + 'good': 'good', + } + invalid_inputs = { + 'amazing': ['`amazing` is not a valid choice.'] + } + outputs = { + 'good': 'good' + } + field = fields.ChoiceField( + choices=[ + ('poor', 'Poor quality'), + ('medium', 'Medium quality'), + ('good', 'Good quality'), + ] + ) + + +class TestChoiceFieldWithType(FieldValues): + """ + Valid and invalid values for a `Choice` field that uses an integer type, + instead of a char type. + """ + valid_inputs = { + '1': 1, + 3: 3, + } + invalid_inputs = { + 5: ['`5` is not a valid choice.'], + 'abc': ['`abc` is not a valid choice.'] + } + outputs = { + '1': 1, + 1: 1 + } + field = fields.ChoiceField( + choices=[ + (1, 'Poor quality'), + (2, 'Medium quality'), + (3, 'Good quality'), + ] + ) + + +class TestChoiceFieldWithListChoices(FieldValues): + """ + 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_inputs = { + 'poor': 'poor', + 'medium': 'medium', + 'good': 'good', + } + invalid_inputs = { + 'awful': ['`awful` is not a valid choice.'] + } + outputs = { + 'good': 'good' + } + field = fields.ChoiceField(choices=('poor', 'medium', 'good')) + + +class TestMultipleChoiceField(FieldValues): + """ + Valid and invalid values for `MultipleChoiceField`. + """ + valid_inputs = { + (): set(), + ('aircon',): set(['aircon']), + ('aircon', 'manual'): set(['aircon', 'manual']), + } + invalid_inputs = { + 'abc': ['Expected a list of items but got type `str`'], + ('aircon', 'incorrect'): ['`incorrect` is not a valid choice.'] + } + outputs = [ + (['aircon', 'manual'], set(['aircon', 'manual'])) + ] + field = fields.MultipleChoiceField( + choices=[ + ('aircon', 'AirCon'), + ('manual', 'Manual drive'), + ('diesel', 'Diesel'), + ] + ) diff --git a/tests/test_model_serializer.py b/tests/test_model_serializer.py index d9f9efbe..731ed2fb 100644 --- a/tests/test_model_serializer.py +++ b/tests/test_model_serializer.py @@ -6,6 +6,7 @@ These tests deal with ensuring that we correctly map the model fields onto an appropriate set of serializer fields for each case. """ from django.core.exceptions import ImproperlyConfigured +from django.core.validators import MaxValueValidator, MinValueValidator, MinLengthValidator from django.db import models from django.test import TestCase from rest_framework import serializers @@ -15,7 +16,8 @@ def dedent(blocktext): return '\n'.join([line[12:] for line in blocktext.splitlines()[1:-1]]) -# Testing regular field mappings +# Tests for regular field mappings. +# --------------------------------- class CustomField(models.Field): """ @@ -24,9 +26,6 @@ class CustomField(models.Field): pass -COLOR_CHOICES = (('red', 'Red'), ('blue', 'Blue'), ('green', 'Green')) - - class RegularFieldsModel(models.Model): """ A model class for testing regular flat fields. @@ -35,7 +34,6 @@ class RegularFieldsModel(models.Model): big_integer_field = models.BigIntegerField() boolean_field = models.BooleanField(default=False) char_field = models.CharField(max_length=100) - choices_field = models.CharField(max_length=100, choices=COLOR_CHOICES) comma_seperated_integer_field = models.CommaSeparatedIntegerField(max_length=100) date_field = models.DateField() datetime_field = models.DateTimeField() @@ -57,6 +55,19 @@ class RegularFieldsModel(models.Model): return 'method' +COLOR_CHOICES = (('red', 'Red'), ('blue', 'Blue'), ('green', 'Green')) + + +class FieldOptionsModel(models.Model): + value_limit_field = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)]) + length_limit_field = models.CharField(validators=[MinLengthValidator(3)], max_length=12) + blank_field = models.CharField(blank=True, max_length=10) + null_field = models.IntegerField(null=True) + default_field = models.IntegerField(default=0) + descriptive_field = models.IntegerField(help_text='Some help text', verbose_name='A label') + choices_field = models.CharField(max_length=100, choices=COLOR_CHOICES) + + class TestRegularFieldMappings(TestCase): def test_regular_fields(self): """ @@ -70,9 +81,8 @@ class TestRegularFieldMappings(TestCase): TestSerializer(): auto_field = IntegerField(read_only=True) big_integer_field = IntegerField() - boolean_field = BooleanField(default=False) + boolean_field = BooleanField(required=False) char_field = CharField(max_length=100) - choices_field = ChoiceField(choices=[('red', 'Red'), ('blue', 'Blue'), ('green', 'Green')]) comma_seperated_integer_field = CharField(max_length=100, validators=[]) date_field = DateField() datetime_field = DateTimeField() @@ -80,7 +90,7 @@ class TestRegularFieldMappings(TestCase): email_field = EmailField(max_length=100) float_field = FloatField() integer_field = IntegerField() - null_boolean_field = BooleanField(required=False) + null_boolean_field = BooleanField(allow_null=True) positive_integer_field = IntegerField() positive_small_integer_field = IntegerField() slug_field = SlugField(max_length=100) @@ -92,6 +102,24 @@ class TestRegularFieldMappings(TestCase): """) self.assertEqual(repr(TestSerializer()), expected) + def test_field_options(self): + class TestSerializer(serializers.ModelSerializer): + class Meta: + model = FieldOptionsModel + + expected = dedent(""" + TestSerializer(): + id = IntegerField(label='ID', read_only=True) + value_limit_field = IntegerField(max_value=10, min_value=1) + length_limit_field = CharField(max_length=12, min_length=3) + blank_field = CharField(allow_blank=True, max_length=10) + null_field = IntegerField(allow_null=True) + default_field = IntegerField(required=False) + descriptive_field = IntegerField(help_text='Some help text', label='A label') + choices_field = ChoiceField(choices=[('red', 'Red'), ('blue', 'Blue'), ('green', 'Green')]) + """) + self.assertEqual(repr(TestSerializer()), expected) + def test_method_field(self): """ Properties and methods on the model should be allowed as `Meta.fields` @@ -178,7 +206,8 @@ class TestRegularFieldMappings(TestCase): assert str(excinfo.exception) == expected -# Testing relational field mappings +# Tests for relational field mappings. +# ------------------------------------ class ForeignKeyTargetModel(models.Model): name = models.CharField(max_length=100) -- cgit v1.2.3