diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index a7ad4f70c..4b566d37e 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -49,7 +49,9 @@ Defaults to `False` ### `default` -If set, this gives the default value that will be used for the field if no input value is supplied. If not set the default behavior is to not populate the attribute at all. +If set, this gives the default value that will be used for the field if no input value is supplied. If not set the default behaviour is to not populate the attribute at all. + +The `default` is not applied during partial update operations. In the partial update case only fields that are provided in the incoming data will have a validated value returned. May be set to a function or other callable, in which case the value will be evaluated each time it is used. When called, it will receive no arguments. If the callable has a `set_context` method, that will be called each time before getting the value with the field instance as only argument. This works the same way as for [validators](validators.md#using-set_context). diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index 8664dcc8a..0ccd51dd3 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -241,7 +241,6 @@ For more details on using filter sets see the [django-filter documentation][djan * By default filtering is not enabled. If you want to use `DjangoFilterBackend` remember to make sure it is installed by using the `'DEFAULT_FILTER_BACKENDS'` setting. * When using boolean fields, you should use the values `True` and `False` in the URL query parameters, rather than `0`, `1`, `true` or `false`. (The allowed boolean values are currently hardwired in Django's [NullBooleanSelect implementation][nullbooleanselect].) * `django-filter` supports filtering across relationships, using Django's double-underscore syntax. -* For Django 1.3 support, make sure to install `django-filter` version 0.5.4, as later versions drop support for 1.3. --- diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 46d7ed09b..704dbaf3f 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -435,7 +435,8 @@ class Field(object): return `empty`, indicating that no value should be set in the validated data for this field. """ - if self.default is empty: + if self.default is empty or getattr(self.root, 'partial', False): + # No default, or this is a partial update. raise SkipField() if callable(self.default): if hasattr(self.default, 'set_context'): @@ -804,7 +805,10 @@ class IPAddressField(CharField): self.validators.extend(validators) def to_internal_value(self, data): - if data and ':' in data: + if not isinstance(data, six.string_types): + self.fail('invalid', value=data) + + if ':' in data: try: if self.protocol in ('both', 'ipv6'): return clean_ipv6_address(data, self.unpack_ipv4) @@ -952,7 +956,7 @@ class DecimalField(Field): if value in (decimal.Decimal('Inf'), decimal.Decimal('-Inf')): self.fail('invalid') - return self.validate_precision(value) + return self.quantize(self.validate_precision(value)) def validate_precision(self, value): """ @@ -1015,7 +1019,8 @@ class DecimalField(Field): context.prec = self.max_digits return value.quantize( decimal.Decimal('.1') ** self.decimal_places, - context=context) + context=context + ) # Date & time fields... diff --git a/rest_framework/parsers.py b/rest_framework/parsers.py index ab74a6e58..238382364 100644 --- a/rest_framework/parsers.py +++ b/rest_framework/parsers.py @@ -118,6 +118,10 @@ class FileUploadParser(BaseParser): Parser for file upload data. """ media_type = '*/*' + errors = { + 'unhandled': 'FileUpload parse error - none of upload handlers can handle the stream', + 'no_filename': 'Missing filename. Request should include a Content-Disposition header with a filename parameter.', + } def parse(self, stream, media_type=None, parser_context=None): """ @@ -134,6 +138,9 @@ class FileUploadParser(BaseParser): upload_handlers = request.upload_handlers filename = self.get_filename(stream, media_type, parser_context) + if not filename: + raise ParseError(self.errors['no_filename']) + # Note that this code is extracted from Django's handling of # file uploads in MultiPartParser. content_type = meta.get('HTTP_CONTENT_TYPE', @@ -146,7 +153,7 @@ class FileUploadParser(BaseParser): # See if the handler will want to take care of the parsing. for handler in upload_handlers: - result = handler.handle_raw_input(None, + result = handler.handle_raw_input(stream, meta, content_length, None, @@ -178,10 +185,10 @@ class FileUploadParser(BaseParser): for index, handler in enumerate(upload_handlers): file_obj = handler.file_complete(counters[index]) - if file_obj: + if file_obj is not None: return DataAndFiles({}, {'file': file_obj}) - raise ParseError("FileUpload parse error - " - "none of upload handlers can handle the stream") + + raise ParseError(self.errors['unhandled']) def get_filename(self, stream, media_type, parser_context): """ diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index e313998d1..ef7747eaf 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -166,13 +166,14 @@ class TemplateHTMLRenderer(BaseRenderer): template_names = self.get_template_names(response, view) template = self.resolve_template(template_names) - context = self.resolve_context(data, request, response) + context = self.get_template_context(data, renderer_context) return template_render(template, context, request=request) def resolve_template(self, template_names): return loader.select_template(template_names) - def resolve_context(self, data, request, response): + def get_template_context(self, data, renderer_context): + response = renderer_context['response'] if response.exception: data['status_code'] = response.status_code return data diff --git a/rest_framework/schemas.py b/rest_framework/schemas.py index 8d2788012..f92e02554 100644 --- a/rest_framework/schemas.py +++ b/rest_framework/schemas.py @@ -206,6 +206,9 @@ class SchemaGenerator(object): else: encoding = None + if self.url and path.startswith('/'): + path = path[1:] + return coreapi.Link( url=urlparse.urljoin(self.url, path), action=method.lower(), diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index d5e6b66ed..27c8cc229 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -1324,9 +1324,8 @@ class ModelSerializer(Serializer): # Update `extra_kwargs` with any new options. for key, value in uniqueness_extra_kwargs.items(): if key in extra_kwargs: - extra_kwargs[key].update(value) - else: - extra_kwargs[key] = value + value.update(extra_kwargs[key]) + extra_kwargs[key] = value return extra_kwargs, hidden_fields diff --git a/rest_framework/utils/formatting.py b/rest_framework/utils/formatting.py index 67aabd3f1..ca5b33c5e 100644 --- a/rest_framework/utils/formatting.py +++ b/rest_framework/utils/formatting.py @@ -32,13 +32,22 @@ def dedent(content): unindented text on the initial line. """ content = force_text(content) - whitespace_counts = [len(line) - len(line.lstrip(' ')) - for line in content.splitlines()[1:] if line.lstrip()] + whitespace_counts = [ + len(line) - len(line.lstrip(' ')) + for line in content.splitlines()[1:] if line.lstrip() + ] + tab_counts = [ + len(line) - len(line.lstrip('\t')) + for line in content.splitlines()[1:] if line.lstrip() + ] # unindent the content if needed if whitespace_counts: whitespace_pattern = '^' + (' ' * min(whitespace_counts)) content = re.sub(re.compile(whitespace_pattern, re.MULTILINE), '', content) + elif tab_counts: + whitespace_pattern = '^' + ('\t' * min(whitespace_counts)) + content = re.sub(re.compile(whitespace_pattern, re.MULTILINE), '', content) return content.strip() diff --git a/tests/test_description.py b/tests/test_description.py index 1683e106b..fcb88287b 100644 --- a/tests/test_description.py +++ b/tests/test_description.py @@ -6,6 +6,7 @@ from django.test import TestCase from django.utils.encoding import python_2_unicode_compatible from rest_framework.compat import apply_markdown +from rest_framework.utils.formatting import dedent from rest_framework.views import APIView @@ -120,3 +121,7 @@ class TestViewNamesAndDescriptions(TestCase): gte_21_match = apply_markdown(DESCRIPTION) == MARKED_DOWN_gte_21 lt_21_match = apply_markdown(DESCRIPTION) == MARKED_DOWN_lt_21 self.assertTrue(gte_21_match or lt_21_match) + + +def test_dedent_tabs(): + assert dedent("\tfirst string\n\n\tsecond string") == 'first string\n\n\tsecond string' diff --git a/tests/test_fields.py b/tests/test_fields.py index 1149cc4b3..92f4548e5 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -663,6 +663,7 @@ class TestIPAddressField(FieldValues): '127.122.111.2231': ['Enter a valid IPv4 or IPv6 address.'], '2001:::9652': ['Enter a valid IPv4 or IPv6 address.'], '2001:0db8:85a3:0042:1000:8a2e:0370:73341': ['Enter a valid IPv4 or IPv6 address.'], + 1000: ['Enter a valid IPv4 or IPv6 address.'], } outputs = {} field = serializers.IPAddressField() @@ -911,6 +912,26 @@ class TestLocalizedDecimalField(TestCase): self.assertTrue(isinstance(field.to_representation(Decimal('1.1')), six.string_types)) +class TestQuantizedValueForDecimal(TestCase): + def test_int_quantized_value_for_decimal(self): + field = serializers.DecimalField(max_digits=4, decimal_places=2) + value = field.to_internal_value(12).as_tuple() + expected_digit_tuple = (0, (1, 2, 0, 0), -2) + self.assertEqual(value, expected_digit_tuple) + + def test_string_quantized_value_for_decimal(self): + field = serializers.DecimalField(max_digits=4, decimal_places=2) + value = field.to_internal_value('12').as_tuple() + expected_digit_tuple = (0, (1, 2, 0, 0), -2) + self.assertEqual(value, expected_digit_tuple) + + def test_part_precision_string_quantized_value_for_decimal(self): + field = serializers.DecimalField(max_digits=4, decimal_places=2) + value = field.to_internal_value('12.0').as_tuple() + expected_digit_tuple = (0, (1, 2, 0, 0), -2) + self.assertEqual(value, expected_digit_tuple) + + class TestNoDecimalPlaces(FieldValues): valid_inputs = { '0.12345': Decimal('0.12345'), @@ -1573,6 +1594,29 @@ class TestDictField(FieldValues): "Remove `source=` from the field declaration." ) + def test_allow_null(self): + """ + If `allow_null=True` then `None` is a valid input. + """ + field = serializers.DictField(allow_null=True) + output = field.run_validation(None) + assert output is None + + +class TestDictFieldWithNullChild(FieldValues): + """ + Values for `ListField` with allow_null CharField as child. + """ + valid_inputs = [ + ({'a': None, 'b': '2', 3: 3}, {'a': None, 'b': '2', '3': '3'}), + ] + invalid_inputs = [ + ] + outputs = [ + ({'a': None, 'b': '2', 3: 3}, {'a': None, 'b': '2', '3': '3'}), + ] + field = serializers.DictField(child=serializers.CharField(allow_null=True)) + class TestUnvalidatedDictField(FieldValues): """ diff --git a/tests/test_model_serializer.py b/tests/test_model_serializer.py index 2cf6cb04c..b2d336d84 100644 --- a/tests/test_model_serializer.py +++ b/tests/test_model_serializer.py @@ -976,3 +976,22 @@ class TestModelFieldValues(TestCase): source = OneToOneSourceTestModel(target=target) serializer = ExampleSerializer(source) self.assertEqual(serializer.data, {'target': 1}) + + +class TestUniquenessOverride(TestCase): + def test_required_not_overwritten(self): + class TestModel(models.Model): + field_1 = models.IntegerField(null=True) + field_2 = models.IntegerField() + + class Meta: + unique_together = (('field_1', 'field_2'),) + + class TestSerializer(serializers.ModelSerializer): + class Meta: + model = TestModel + extra_kwargs = {'field_1': {'required': False}} + + fields = TestSerializer().fields + self.assertFalse(fields['field_1'].required) + self.assertTrue(fields['field_2'].required) diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 1e0f2e17f..f3af6817f 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -2,8 +2,11 @@ from __future__ import unicode_literals +import pytest from django import forms -from django.core.files.uploadhandler import MemoryFileUploadHandler +from django.core.files.uploadhandler import ( + MemoryFileUploadHandler, TemporaryFileUploadHandler +) from django.test import TestCase from django.utils.six.moves import StringIO @@ -63,8 +66,9 @@ class TestFileUploadParser(TestCase): parser = FileUploadParser() self.stream.seek(0) self.parser_context['request'].META['HTTP_CONTENT_DISPOSITION'] = '' - with self.assertRaises(ParseError): + with pytest.raises(ParseError) as excinfo: parser.parse(self.stream, None, self.parser_context) + assert str(excinfo.value) == 'Missing filename. Request should include a Content-Disposition header with a filename parameter.' def test_parse_missing_filename_multiple_upload_handlers(self): """ @@ -78,8 +82,23 @@ class TestFileUploadParser(TestCase): MemoryFileUploadHandler() ) self.parser_context['request'].META['HTTP_CONTENT_DISPOSITION'] = '' - with self.assertRaises(ParseError): + with pytest.raises(ParseError) as excinfo: parser.parse(self.stream, None, self.parser_context) + assert str(excinfo.value) == 'Missing filename. Request should include a Content-Disposition header with a filename parameter.' + + def test_parse_missing_filename_large_file(self): + """ + Parse raw file upload when filename is missing with TemporaryFileUploadHandler. + """ + parser = FileUploadParser() + self.stream.seek(0) + self.parser_context['request'].upload_handlers = ( + TemporaryFileUploadHandler(), + ) + self.parser_context['request'].META['HTTP_CONTENT_DISPOSITION'] = '' + with pytest.raises(ParseError) as excinfo: + parser.parse(self.stream, None, self.parser_context) + assert str(excinfo.value) == 'Missing filename. Request should include a Content-Disposition header with a filename parameter.' def test_get_filename(self): parser = FileUploadParser() diff --git a/tests/test_serializer.py b/tests/test_serializer.py index 741c6ab17..4e9080909 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -309,3 +309,31 @@ class TestCacheSerializerData: pickled = pickle.dumps(serializer.data) data = pickle.loads(pickled) assert data == {'field1': 'a', 'field2': 'b'} + + +class TestDefaultInclusions: + def setup(self): + class ExampleSerializer(serializers.Serializer): + char = serializers.CharField(read_only=True, default='abc') + integer = serializers.IntegerField() + self.Serializer = ExampleSerializer + + def test_default_should_included_on_create(self): + serializer = self.Serializer(data={'integer': 456}) + assert serializer.is_valid() + assert serializer.validated_data == {'char': 'abc', 'integer': 456} + assert serializer.errors == {} + + def test_default_should_be_included_on_update(self): + instance = MockObject(char='def', integer=123) + serializer = self.Serializer(instance, data={'integer': 456}) + assert serializer.is_valid() + assert serializer.validated_data == {'char': 'abc', 'integer': 456} + assert serializer.errors == {} + + def test_default_should_not_be_included_on_partial_update(self): + instance = MockObject(char='def', integer=123) + serializer = self.Serializer(instance, data={'integer': 456}, partial=True) + assert serializer.is_valid() + assert serializer.validated_data == {'integer': 456} + assert serializer.errors == {} diff --git a/tox.ini b/tox.ini index 89655aee2..1e8a7e5c4 100644 --- a/tox.ini +++ b/tox.ini @@ -16,8 +16,8 @@ setenv = PYTHONWARNINGS=once deps = django18: Django==1.8.14 - django19: Django==1.9.8 - django110: Django==1.10rc1 + django19: Django==1.9.9 + django110: Django==1.10 djangomaster: https://github.com/django/django/archive/master.tar.gz -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt