From dee3f78cb688b1bee892ef78d6eec23ccf67a80e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 26 Sep 2014 17:06:20 +0100 Subject: [PATCH] FileField and ImageField --- requirements-test.txt | 1 - rest_framework/compat.py | 9 ---- rest_framework/fields.py | 82 +++++++++++++++++++++++++-------- rest_framework/settings.py | 3 +- tests/test_fields.py | 93 ++++++++++++++++++++++++++++++++++++-- tox.ini | 16 ------- 6 files changed, 156 insertions(+), 48 deletions(-) diff --git a/requirements-test.txt b/requirements-test.txt index d6ee5c6fd..06c8849a8 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -13,4 +13,3 @@ django-filter>=0.5.4 django-oauth-plus>=2.2.1 oauth2>=1.5.211 django-oauth2-provider>=0.2.4 -Pillow==2.3.0 diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 7303c32a6..89af9b485 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -84,15 +84,6 @@ except ImportError: from collections import UserDict from collections import MutableMapping as DictMixin -# Try to import PIL in either of the two ways it can end up installed. -try: - from PIL import Image -except ImportError: - try: - import Image - except ImportError: - Image = None - def get_model_name(model_cls): try: diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 4c49aabaa..f4b53279a 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -1,3 +1,4 @@ +from django import forms from django.conf import settings from django.core import validators from django.core.exceptions import ValidationError @@ -427,8 +428,6 @@ class CharField(Field): return str(data) def to_representation(self, value): - if value is None: - return None return str(value) @@ -446,8 +445,6 @@ class EmailField(CharField): return str(data).strip() def to_representation(self, value): - if value is None: - return None return str(value).strip() @@ -513,8 +510,6 @@ class IntegerField(Field): return data def to_representation(self, value): - if value is None: - return None return int(value) @@ -543,8 +538,6 @@ class FloatField(Field): self.fail('invalid') def to_representation(self, value): - if value is None: - return None return float(value) @@ -616,9 +609,6 @@ class DecimalField(Field): return value def to_representation(self, value): - if value in (None, ''): - return None - if not isinstance(value, decimal.Decimal): value = decimal.Decimal(str(value).strip()) @@ -689,7 +679,7 @@ class DateTimeField(Field): self.fail('invalid', format=humanized_format) def to_representation(self, value): - if value is None or self.format is None: + if self.format is None: return value if self.format.lower() == ISO_8601: @@ -741,7 +731,7 @@ class DateField(Field): self.fail('invalid', format=humanized_format) def to_representation(self, value): - if value is None or self.format is None: + if self.format is None: return value # Applying a `DateField` to a datetime value is almost always @@ -795,7 +785,7 @@ class TimeField(Field): self.fail('invalid', format=humanized_format) def to_representation(self, value): - if value is None or self.format is None: + if self.format is None: return value # Applying a `TimeField` to a datetime value is almost always @@ -875,14 +865,68 @@ class MultipleChoiceField(ChoiceField): # File types... class FileField(Field): - pass # TODO + default_error_messages = { + 'required': _("No file was submitted."), + 'invalid': _("The submitted data was not a file. Check the encoding type on the form."), + 'no_name': _("No filename could be determined."), + 'empty': _("The submitted file is empty."), + 'max_length': _('Ensure this filename has at most {max_length} characters (it has {length}).'), + } + use_url = api_settings.UPLOADED_FILES_USE_URL + + def __init__(self, *args, **kwargs): + self.max_length = kwargs.pop('max_length', None) + self.allow_empty_file = kwargs.pop('allow_empty_file', False) + self.use_url = kwargs.pop('use_url', self.use_url) + super(FileField, self).__init__(*args, **kwargs) + + def to_internal_value(self, data): + try: + # `UploadedFile` objects should have name and size attributes. + file_name = data.name + file_size = data.size + except AttributeError: + self.fail('invalid') + + if not file_name: + self.fail('no_name') + if not self.allow_empty_file and not file_size: + self.fail('empty') + if self.max_length and len(file_name) > self.max_length: + self.fail('max_length', max_length=self.max_length, length=len(file_name)) + + return data + + def to_representation(self, value): + if self.use_url: + return settings.MEDIA_URL + value.url + return value.name -class ImageField(Field): - pass # TODO +class ImageField(FileField): + default_error_messages = { + 'invalid_image': _( + 'Upload a valid image. The file you uploaded was either not an ' + 'image or a corrupted image.' + ), + } + + def __init__(self, *args, **kwargs): + self._DjangoImageField = kwargs.pop('_DjangoImageField', forms.ImageField) + super(ImageField, self).__init__(*args, **kwargs) + + def to_internal_value(self, data): + # Image validation is a bit grungy, so we'll just outright + # defer to Django's implementation so we don't need to + # consider it, or treat PIL as a test dependancy. + file_object = super(ImageField, self).to_internal_value(data) + django_field = self._DjangoImageField() + django_field.error_messages = self.error_messages + django_field.to_python(file_object) + return file_object -# Advanced field types... +# Composite field types... class ListField(Field): child = None @@ -922,6 +966,8 @@ class ListField(Field): return [self.child.to_representation(item) for item in data] +# Miscellaneous field types... + class ReadOnlyField(Field): """ A read-only field that simply returns the field value. diff --git a/rest_framework/settings.py b/rest_framework/settings.py index d7fb0a436..1e8c27fc3 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -110,7 +110,8 @@ DEFAULTS = { # Encoding 'UNICODE_JSON': True, 'COMPACT_JSON': True, - 'COERCE_DECIMAL_TO_STRING': True + 'COERCE_DECIMAL_TO_STRING': True, + 'UPLOADED_FILES_USE_URL': True } diff --git a/tests/test_fields.py b/tests/test_fields.py index 342ae1927..aa8c3a68c 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,4 +1,5 @@ from decimal import Decimal +from django.core.exceptions import ValidationError from django.utils import timezone from rest_framework import fields, serializers import datetime @@ -516,7 +517,7 @@ class TestDecimalField(FieldValues): Decimal('1.0'): '1.0', Decimal('0.0'): '0.0', Decimal('1.09'): '1.1', - Decimal('0.04'): '0.0', + Decimal('0.04'): '0.0' } field = fields.DecimalField(max_digits=3, decimal_places=1) @@ -576,7 +577,7 @@ class TestDateField(FieldValues): datetime.datetime(2001, 1, 1, 12, 00): ['Expected a date but got a datetime.'], } outputs = { - datetime.date(2001, 1, 1): '2001-01-01', + datetime.date(2001, 1, 1): '2001-01-01' } field = fields.DateField() @@ -639,7 +640,7 @@ class TestDateTimeField(FieldValues): } 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', + datetime.datetime(2001, 1, 1, 13, 00, tzinfo=timezone.UTC()): '2001-01-01T13:00:00Z' } field = fields.DateTimeField(default_timezone=timezone.UTC()) @@ -847,6 +848,92 @@ class TestMultipleChoiceField(FieldValues): ) +# File fields... + +class MockFile: + def __init__(self, name='', size=0, url=''): + self.name = name + self.size = size + self.url = url + + def __eq__(self, other): + return ( + isinstance(other, MockFile) and + self.name == other.name and + self.size == other.size and + self.url == other.url + ) + + +class TestFileField(FieldValues): + """ + Values for `FileField`. + """ + valid_inputs = [ + (MockFile(name='example', size=10), MockFile(name='example', size=10)) + ] + invalid_inputs = [ + ('invalid', ['The submitted data was not a file. Check the encoding type on the form.']), + (MockFile(name='example.txt', size=0), ['The submitted file is empty.']), + (MockFile(name='', size=10), ['No filename could be determined.']), + (MockFile(name='x' * 100, size=10), ['Ensure this filename has at most 10 characters (it has 100).']) + ] + outputs = [ + (MockFile(name='example.txt', url='/example.txt'), '/example.txt') + ] + field = fields.FileField(max_length=10) + + +class TestFieldFieldWithName(FieldValues): + """ + Values for `FileField` with a filename output instead of URLs. + """ + valid_inputs = {} + invalid_inputs = {} + outputs = [ + (MockFile(name='example.txt', url='/example.txt'), 'example.txt') + ] + field = fields.FileField(use_url=False) + + +# Stub out mock Django `forms.ImageField` class so we don't *actually* +# call into it's regular validation, or require PIL for testing. +class FailImageValidation(object): + def to_python(self, value): + raise ValidationError(self.error_messages['invalid_image']) + + +class PassImageValidation(object): + def to_python(self, value): + return value + + +class TestInvalidImageField(FieldValues): + """ + Values for an invalid `ImageField`. + """ + valid_inputs = {} + invalid_inputs = [ + (MockFile(name='example.txt', size=10), ['Upload a valid image. The file you uploaded was either not an image or a corrupted image.']) + ] + outputs = {} + field = fields.ImageField(_DjangoImageField=FailImageValidation) + + +class TestValidImageField(FieldValues): + """ + Values for an valid `ImageField`. + """ + valid_inputs = [ + (MockFile(name='example.txt', size=10), MockFile(name='example.txt', size=10)) + ] + invalid_inputs = {} + outputs = {} + field = fields.ImageField(_DjangoImageField=PassImageValidation) + + +# Composite fields... + class TestListField(FieldValues): """ Values for `ListField`. diff --git a/tox.ini b/tox.ini index d40a70799..5b9a0ffef 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,6 @@ basepython = python3.4 deps = Django==1.7 django-filter==0.7 defusedxml==0.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py3.3-django1.7] @@ -29,7 +28,6 @@ basepython = python3.3 deps = Django==1.7 django-filter==0.7 defusedxml==0.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py3.2-django1.7] @@ -37,7 +35,6 @@ basepython = python3.2 deps = Django==1.7 django-filter==0.7 defusedxml==0.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py2.7-django1.7] @@ -49,7 +46,6 @@ deps = Django==1.7 # oauth2==1.5.211 # django-oauth2-provider==0.2.4 django-guardian==1.2.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py3.4-django1.6] @@ -57,7 +53,6 @@ basepython = python3.4 deps = Django==1.6.3 django-filter==0.7 defusedxml==0.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py3.3-django1.6] @@ -65,7 +60,6 @@ basepython = python3.3 deps = Django==1.6.3 django-filter==0.7 defusedxml==0.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py3.2-django1.6] @@ -73,7 +67,6 @@ basepython = python3.2 deps = Django==1.6.3 django-filter==0.7 defusedxml==0.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py2.7-django1.6] @@ -85,7 +78,6 @@ deps = Django==1.6.3 oauth2==1.5.211 django-oauth2-provider==0.2.4 django-guardian==1.2.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py2.6-django1.6] @@ -97,7 +89,6 @@ deps = Django==1.6.3 oauth2==1.5.211 django-oauth2-provider==0.2.4 django-guardian==1.2.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py3.4-django1.5] @@ -105,7 +96,6 @@ basepython = python3.4 deps = django==1.5.6 django-filter==0.7 defusedxml==0.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py3.3-django1.5] @@ -113,7 +103,6 @@ basepython = python3.3 deps = django==1.5.6 django-filter==0.7 defusedxml==0.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py3.2-django1.5] @@ -121,7 +110,6 @@ basepython = python3.2 deps = django==1.5.6 django-filter==0.7 defusedxml==0.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py2.7-django1.5] @@ -133,7 +121,6 @@ deps = django==1.5.6 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.2.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py2.6-django1.5] @@ -145,7 +132,6 @@ deps = django==1.5.6 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.2.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py2.7-django1.4] @@ -157,7 +143,6 @@ deps = django==1.4.11 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.2.3 - Pillow==2.3.0 pytest-django==2.6.1 [testenv:py2.6-django1.4] @@ -169,5 +154,4 @@ deps = django==1.4.11 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.2.3 - Pillow==2.3.0 pytest-django==2.6.1