From fbd1a6277e9cc04a953a242c45d216685afbf873 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 6 Aug 2007 13:58:56 +0000 Subject: [PATCH] Fixed #3297 -- Implemented FileField and ImageField for newforms. Thanks to the many users that contributed to and tested this patch. git-svn-id: http://code.djangoproject.com/svn/django/trunk@5819 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/db/models/fields/__init__.py | 27 +++++++++ django/db/models/fields/related.py | 3 + django/newforms/extras/widgets.py | 2 +- django/newforms/fields.py | 55 ++++++++++++++++- django/newforms/forms.py | 13 ++-- django/newforms/models.py | 4 +- django/newforms/widgets.py | 19 ++++-- docs/newforms.txt | 89 +++++++++++++++++++++++++++- tests/regressiontests/forms/tests.py | 81 ++++++++++++++++++++++--- 9 files changed, 266 insertions(+), 27 deletions(-) diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index d0cc657c4a..b705eac512 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -380,6 +380,9 @@ class Field(object): return self._choices choices = property(_get_choices) + def save_form_data(self, instance, data): + setattr(instance, self.name, data) + def formfield(self, form_class=forms.CharField, **kwargs): "Returns a django.newforms.Field instance for this database Field." defaults = {'required': not self.blank, 'label': capfirst(self.verbose_name), 'help_text': self.help_text} @@ -696,6 +699,13 @@ class FileField(Field): self.upload_to = upload_to Field.__init__(self, verbose_name, name, **kwargs) + def get_db_prep_save(self, value): + "Returns field's value prepared for saving into a database." + # Need to convert UploadedFile objects provided via a form to unicode for database insertion + if value is None: + return None + return unicode(value) + def get_manipulator_fields(self, opts, manipulator, change, name_prefix='', rel=False, follow=True): field_list = Field.get_manipulator_fields(self, opts, manipulator, change, name_prefix, rel, follow) if not self.blank: @@ -772,6 +782,19 @@ class FileField(Field): f = os.path.join(self.get_directory_name(), get_valid_filename(os.path.basename(filename))) return os.path.normpath(f) + def save_form_data(self, instance, data): + if data: + getattr(instance, "save_%s_file" % self.name)(os.path.join(self.upload_to, data.filename), data.content, save=False) + + def formfield(self, **kwargs): + defaults = {'form_class': forms.FileField} + # If a file has been provided previously, then the form doesn't require + # that a new file is provided this time. + if 'initial' in kwargs: + defaults['required'] = False + defaults.update(kwargs) + return super(FileField, self).formfield(**defaults) + class FilePathField(Field): def __init__(self, verbose_name=None, name=None, path='', match=None, recursive=False, **kwargs): self.path, self.match, self.recursive = path, match, recursive @@ -820,6 +843,10 @@ class ImageField(FileField): setattr(new_object, self.height_field, getattr(original_object, self.height_field)) new_object.save() + def formfield(self, **kwargs): + defaults = {'form_class': forms.ImageField} + return super(ImageField, self).formfield(**defaults) + class IntegerField(Field): empty_strings_allowed = False def get_manipulator_field_objs(self): diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index c4284f04b1..d3603b0016 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -756,6 +756,9 @@ class ManyToManyField(RelatedField, Field): "Returns the value of this field in the given model instance." return getattr(obj, self.attname).all() + def save_form_data(self, instance, data): + setattr(instance, self.attname, data) + def formfield(self, **kwargs): defaults = {'form_class': forms.ModelMultipleChoiceField, 'queryset': self.rel.to._default_manager.all()} defaults.update(kwargs) diff --git a/django/newforms/extras/widgets.py b/django/newforms/extras/widgets.py index 724dcd9b50..96b1c7244d 100644 --- a/django/newforms/extras/widgets.py +++ b/django/newforms/extras/widgets.py @@ -53,7 +53,7 @@ class SelectDateWidget(Widget): return u'\n'.join(output) - def value_from_datadict(self, data, name): + def value_from_datadict(self, data, files, name): y, m, d = data.get(self.year_field % name), data.get(self.month_field % name), data.get(self.day_field % name) if y and m and d: return '%s-%s-%s' % (y, m, d) diff --git a/django/newforms/fields.py b/django/newforms/fields.py index 79e90da5fb..e9f50ad4ec 100644 --- a/django/newforms/fields.py +++ b/django/newforms/fields.py @@ -7,10 +7,10 @@ import re import time from django.utils.translation import ugettext -from django.utils.encoding import smart_unicode +from django.utils.encoding import StrAndUnicode, smart_unicode from util import ErrorList, ValidationError -from widgets import TextInput, PasswordInput, HiddenInput, MultipleHiddenInput, CheckboxInput, Select, NullBooleanSelect, SelectMultiple +from widgets import TextInput, PasswordInput, HiddenInput, MultipleHiddenInput, FileInput, CheckboxInput, Select, NullBooleanSelect, SelectMultiple try: from decimal import Decimal, DecimalException @@ -22,7 +22,7 @@ __all__ = ( 'DEFAULT_DATE_INPUT_FORMATS', 'DateField', 'DEFAULT_TIME_INPUT_FORMATS', 'TimeField', 'DEFAULT_DATETIME_INPUT_FORMATS', 'DateTimeField', - 'RegexField', 'EmailField', 'URLField', 'BooleanField', + 'RegexField', 'EmailField', 'FileField', 'ImageField', 'URLField', 'BooleanField', 'ChoiceField', 'NullBooleanField', 'MultipleChoiceField', 'ComboField', 'MultiValueField', 'FloatField', 'DecimalField', 'SplitDateTimeField', @@ -348,6 +348,55 @@ except ImportError: # It's OK if Django settings aren't configured. URL_VALIDATOR_USER_AGENT = 'Django (http://www.djangoproject.com/)' +class UploadedFile(StrAndUnicode): + "A wrapper for files uploaded in a FileField" + def __init__(self, filename, content): + self.filename = filename + self.content = content + + def __unicode__(self): + """ + The unicode representation is the filename, so that the pre-database-insertion + logic can use UploadedFile objects + """ + return self.filename + +class FileField(Field): + widget = FileInput + def __init__(self, *args, **kwargs): + super(FileField, self).__init__(*args, **kwargs) + + def clean(self, data): + super(FileField, self).clean(data) + if not self.required and data in EMPTY_VALUES: + return None + try: + f = UploadedFile(data['filename'], data['content']) + except TypeError: + raise ValidationError(ugettext(u"No file was submitted. Check the encoding type on the form.")) + except KeyError: + raise ValidationError(ugettext(u"No file was submitted.")) + if not f.content: + raise ValidationError(ugettext(u"The submitted file is empty.")) + return f + +class ImageField(FileField): + def clean(self, data): + """ + Checks that the file-upload field data contains a valid image (GIF, JPG, + PNG, possibly others -- whatever the Python Imaging Library supports). + """ + f = super(ImageField, self).clean(data) + if f is None: + return None + from PIL import Image + from cStringIO import StringIO + try: + Image.open(StringIO(f.content)) + except IOError: # Python Imaging Library doesn't recognize it as an image + raise ValidationError(ugettext(u"Upload a valid image. The file you uploaded was either not an image or a corrupted image.")) + return f + class URLField(RegexField): def __init__(self, max_length=None, min_length=None, verify_exists=False, validator_user_agent=URL_VALIDATOR_USER_AGENT, *args, **kwargs): diff --git a/django/newforms/forms.py b/django/newforms/forms.py index 5da85a69c4..906978c86f 100644 --- a/django/newforms/forms.py +++ b/django/newforms/forms.py @@ -57,9 +57,10 @@ class BaseForm(StrAndUnicode): # class is different than Form. See the comments by the Form class for more # information. Any improvements to the form API should be made to *this* # class, not to the Form class. - def __init__(self, data=None, auto_id='id_%s', prefix=None, initial=None): - self.is_bound = data is not None + def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, initial=None): + self.is_bound = data is not None or files is not None self.data = data or {} + self.files = files or {} self.auto_id = auto_id self.prefix = prefix self.initial = initial or {} @@ -88,7 +89,7 @@ class BaseForm(StrAndUnicode): return BoundField(self, field, name) def _get_errors(self): - "Returns an ErrorDict for self.data" + "Returns an ErrorDict for the data provided for the form" if self._errors is None: self.full_clean() return self._errors @@ -179,10 +180,10 @@ class BaseForm(StrAndUnicode): return self.cleaned_data = {} for name, field in self.fields.items(): - # value_from_datadict() gets the data from the dictionary. + # value_from_datadict() gets the data from the data dictionaries. # Each widget type knows how to retrieve its own data, because some # widgets split data over several HTML fields. - value = field.widget.value_from_datadict(self.data, self.add_prefix(name)) + value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name)) try: value = field.clean(value) self.cleaned_data[name] = value @@ -283,7 +284,7 @@ class BoundField(StrAndUnicode): """ Returns the data for this BoundField, or None if it wasn't given. """ - return self.field.widget.value_from_datadict(self.form.data, self.html_name) + return self.field.widget.value_from_datadict(self.form.data, self.form.files, self.html_name) data = property(_data) def label_tag(self, contents=None, attrs=None): diff --git a/django/newforms/models.py b/django/newforms/models.py index 7a86e30b1d..247a0eea6b 100644 --- a/django/newforms/models.py +++ b/django/newforms/models.py @@ -34,7 +34,7 @@ def save_instance(form, instance, fields=None, fail_message='saved', commit=True continue if fields and f.name not in fields: continue - setattr(instance, f.name, cleaned_data[f.name]) + f.save_form_data(instance, cleaned_data[f.name]) # Wrap up the saving of m2m data as a function def save_m2m(): opts = instance.__class__._meta @@ -43,7 +43,7 @@ def save_instance(form, instance, fields=None, fail_message='saved', commit=True if fields and f.name not in fields: continue if f.name in cleaned_data: - setattr(instance, f.attname, cleaned_data[f.name]) + f.save_form_data(instance, cleaned_data[f.name]) if commit: # If we are committing, save the instance and the m2m data immediately instance.save() diff --git a/django/newforms/widgets.py b/django/newforms/widgets.py index e9b9b55470..f985124389 100644 --- a/django/newforms/widgets.py +++ b/django/newforms/widgets.py @@ -47,7 +47,7 @@ class Widget(object): attrs.update(extra_attrs) return attrs - def value_from_datadict(self, data, name): + def value_from_datadict(self, data, files, name): """ Given a dictionary of data and this widget's name, returns the value of this widget. Returns None if it's not provided. @@ -113,7 +113,7 @@ class MultipleHiddenInput(HiddenInput): final_attrs = self.build_attrs(attrs, type=self.input_type, name=name) return u'\n'.join([(u'' % flatatt(dict(value=force_unicode(v), **final_attrs))) for v in value]) - def value_from_datadict(self, data, name): + def value_from_datadict(self, data, files, name): if isinstance(data, MultiValueDict): return data.getlist(name) return data.get(name, None) @@ -121,6 +121,13 @@ class MultipleHiddenInput(HiddenInput): class FileInput(Input): input_type = 'file' + def render(self, name, value, attrs=None): + return super(FileInput, self).render(name, None, attrs=attrs) + + def value_from_datadict(self, data, files, name): + "File widgets take data from FILES, not POST" + return files.get(name, None) + class Textarea(Widget): def __init__(self, attrs=None): # The 'rows' and 'cols' attributes are required for HTML correctness. @@ -188,7 +195,7 @@ class NullBooleanSelect(Select): value = u'1' return super(NullBooleanSelect, self).render(name, value, attrs, choices) - def value_from_datadict(self, data, name): + def value_from_datadict(self, data, files, name): value = data.get(name, None) return {u'2': True, u'3': False, True: True, False: False}.get(value, None) @@ -210,7 +217,7 @@ class SelectMultiple(Widget): output.append(u'') return u'\n'.join(output) - def value_from_datadict(self, data, name): + def value_from_datadict(self, data, files, name): if isinstance(data, MultiValueDict): return data.getlist(name) return data.get(name, None) @@ -377,8 +384,8 @@ class MultiWidget(Widget): return id_ id_for_label = classmethod(id_for_label) - def value_from_datadict(self, data, name): - return [widget.value_from_datadict(data, name + '_%s' % i) for i, widget in enumerate(self.widgets)] + def value_from_datadict(self, data, files, name): + return [widget.value_from_datadict(data, files, name + '_%s' % i) for i, widget in enumerate(self.widgets)] def format_output(self, rendered_widgets): """ diff --git a/docs/newforms.txt b/docs/newforms.txt index dae145434b..5d1da96128 100644 --- a/docs/newforms.txt +++ b/docs/newforms.txt @@ -710,6 +710,47 @@ For example:: +Binding uploaded files to a form +-------------------------------- + +Dealing with forms that have ``FileField`` and ``ImageField`` fields +is a little more complicated than a normal form. + +Firstly, in order to upload files, you'll need to make sure that your +``
`` element correctly defines the ``enctype`` as +``"multipart/form-data"``:: + + + +Secondly, when you use the form, you need to bind the file data. File +data is handled separately to normal form data, so when your form +contains a ``FileField`` and ``ImageField``, you will need to specify +a second argument when you bind your form. So if we extend our +ContactForm to include an ``ImageField`` called ``mugshot``, we +need to bind the file data containing the mugshot image:: + + # Bound form with an image field + >>> data = {'subject': 'hello', + ... 'message': 'Hi there', + ... 'sender': 'foo@example.com', + ... 'cc_myself': True} + >>> file_data = {'mugshot': {'filename':'face.jpg' + ... 'content': }} + >>> f = ContactFormWithMugshot(data, file_data) + +In practice, you will usually specify ``request.FILES`` as the source +of file data (just like you use ``request.POST`` as the source of +form data):: + + # Bound form with an image field, data from the request + >>> f = ContactFormWithMugshot(request.POST, request.FILES) + +Constructing an unbound form is the same as always -- just omit both +form data *and* file data: + + # Unbound form with a image field + >>> f = ContactFormWithMugshot() + Subclassing forms ----------------- @@ -1099,6 +1140,50 @@ Has two optional arguments for validation, ``max_length`` and ``min_length``. If provided, these arguments ensure that the string is at most or at least the given length. +``FileField`` +~~~~~~~~~~~~~ + + * Default widget: ``FileInput`` + * Empty value: ``None`` + * Normalizes to: An ``UploadedFile`` object that wraps the file content + and file name into a single object. + * Validates that non-empty file data has been bound to the form. + +An ``UploadedFile`` object has two attributes: + + ====================== ===================================================== + Argument Description + ====================== ===================================================== + ``filename`` The name of the file, provided by the uploading + client. + ``content`` The array of bytes comprising the file content. + ====================== ===================================================== + +The string representation of an ``UploadedFile`` is the same as the filename +attribute. + +When you use a ``FileField`` on a form, you must also remember to +`bind the file data to the form`_. + +.. _`bind the file data to the form`: `Binding uploaded files to a form`_ + +``ImageField`` +~~~~~~~~~~~~~~ + + * Default widget: ``FileInput`` + * Empty value: ``None`` + * Normalizes to: An ``UploadedFile`` object that wraps the file content + and file name into a single object. + * Validates that file data has been bound to the form, and that the + file is of an image format understood by PIL. + +Using an ImageField requires that the `Python Imaging Library`_ is installed. + +When you use a ``FileField`` on a form, you must also remember to +`bind the file data to the form`_. + +.. _Python Imaging Library: http://www.pythonware.com/products/pil/ + ``IntegerField`` ~~~~~~~~~~~~~~~~ @@ -1378,11 +1463,11 @@ the full list of conversions: ``DateTimeField`` ``DateTimeField`` ``DecimalField`` ``DecimalField`` ``EmailField`` ``EmailField`` - ``FileField`` ``CharField`` + ``FileField`` ``FileField`` ``FilePathField`` ``CharField`` ``FloatField`` ``FloatField`` ``ForeignKey`` ``ModelChoiceField`` (see below) - ``ImageField`` ``CharField`` + ``ImageField`` ``ImageField`` ``IntegerField`` ``IntegerField`` ``IPAddressField`` ``CharField`` ``ManyToManyField`` ``ModelMultipleChoiceField`` (see diff --git a/tests/regressiontests/forms/tests.py b/tests/regressiontests/forms/tests.py index 52cc9ccfa5..2386a7f8b1 100644 --- a/tests/regressiontests/forms/tests.py +++ b/tests/regressiontests/forms/tests.py @@ -173,27 +173,29 @@ u'' # FileInput Widget ############################################################ +FileInput widgets don't ever show the value, because the old value is of no use +if you are updating the form or if the provided file generated an error. >>> w = FileInput() >>> w.render('email', '') u'' >>> w.render('email', None) u'' >>> w.render('email', 'test@example.com') -u'' +u'' >>> w.render('email', 'some "quoted" & ampersanded value') -u'' +u'' >>> w.render('email', 'test@example.com', attrs={'class': 'fun'}) -u'' +u'' You can also pass 'attrs' to the constructor: >>> w = FileInput(attrs={'class': 'fun'}) >>> w.render('email', '') u'' >>> w.render('email', 'foo@example.com') -u'' +u'' >>> w.render('email', 'ŠĐĆŽćžšđ', attrs={'class': 'fun'}) -u'' +u'' # Textarea Widget ############################################################# @@ -1532,6 +1534,42 @@ Traceback (most recent call last): ... ValidationError: [u'Ensure this value has at most 15 characters (it has 20).'] +# FileField ################################################################## + +>>> f = FileField() +>>> f.clean('') +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] + +>>> f.clean(None) +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] + +>>> f.clean({}) +Traceback (most recent call last): +... +ValidationError: [u'No file was submitted.'] + +>>> f.clean('some content that is not a file') +Traceback (most recent call last): +... +ValidationError: [u'No file was submitted. Check the encoding type on the form.'] + +>>> f.clean({'filename': 'name', 'content':None}) +Traceback (most recent call last): +... +ValidationError: [u'The submitted file is empty.'] + +>>> f.clean({'filename': 'name', 'content':''}) +Traceback (most recent call last): +... +ValidationError: [u'The submitted file is empty.'] + +>>> type(f.clean({'filename': 'name', 'content':'Some File Content'})) + + # URLField ################################################################## >>> f = URLField() @@ -2573,7 +2611,7 @@ Instances of a dynamic Form do not persist fields from one Form instance to the next. >>> class MyForm(Form): ... def __init__(self, data=None, auto_id=False, field_list=[]): -... Form.__init__(self, data, auto_id) +... Form.__init__(self, data, auto_id=auto_id) ... for field in field_list: ... self.fields[field[0]] = field[1] >>> field_list = [('field1', CharField()), ('field2', CharField())] @@ -2591,7 +2629,7 @@ the next. ... default_field_1 = CharField() ... default_field_2 = CharField() ... def __init__(self, data=None, auto_id=False, field_list=[]): -... Form.__init__(self, data, auto_id) +... Form.__init__(self, data, auto_id=auto_id) ... for field in field_list: ... self.fields[field[0]] = field[1] >>> field_list = [('field1', CharField()), ('field2', CharField())] @@ -3246,6 +3284,35 @@ is different than its data. This is handled transparently, though. +# Forms with FileFields ################################################ + +FileFields are a special case because they take their data from the request.FILES, +not request.POST. + +>>> class FileForm(Form): +... file1 = FileField() +>>> f = FileForm(auto_id=False) +>>> print f +File1: + +>>> f = FileForm(data={}, files={}, auto_id=False) +>>> print f +File1:
  • This field is required.
+ +>>> f = FileForm(data={}, files={'file1': {'filename': 'name', 'content':''}}, auto_id=False) +>>> print f +File1:
  • The submitted file is empty.
+ +>>> f = FileForm(data={}, files={'file1': 'something that is not a file'}, auto_id=False) +>>> print f +File1:
  • No file was submitted. Check the encoding type on the form.
+ +>>> f = FileForm(data={}, files={'file1': {'filename': 'name', 'content':'some content'}}, auto_id=False) +>>> print f +File1: +>>> f.is_valid() +True + # Basic form processing in a view ############################################# >>> from django.template import Template, Context