2007-05-13

Django : newforms image upload with forms.form_for_instance and Monkey Patching

Please don't use this code, Django hackers have added the necessary functions to newforms

In this document you will see an example of how I upload an image with Django newforms and how I monkey patch an auto-generated Form class with forms.form_for_instance. Imagine this simple user profile model :

class UserProfile(models.Model):
    user = models.ForeignKey(User, unique=True)
    avatar = models.ImageField(upload_to="avatar/", \
        blank=True, null=True)
    birthdate = models.DateField(null=True, blank=True)
    blog = models.URLField(blank=True)
    about = models.TextField(blank=True)

The user create is account and then, is redirected to his profile page to complete his personnal informations described in UserProfile.

There is the simple way to do it : create a form object inheriting from forms.Form. It will be something like this :

class UserProfileForm(forms.Form):
    avatar = forms.ImageField(upload_to="avatar/", \
        required=False)
    birthdate = forms.DateField(initial='2000-01-01', \
        widget=SelectDateWidget(years=range(2010, 1940, -1)))
    blog = forms.URLField(required=False)
    about = forms.CharField(widget=forms.Textarea, required=False)

I don't like it very much because It's seems not DRY. But there is another way to do it with forms.form_for_instance. This function provide a forms.Form class filled with initial data of the object. You get this class that way :

def edit(request):
    user = request.user
    profile = user.get_profile()
    ProfileEdit = forms.form_for_instance(profile)

Now if I add a field in UserProfile, maybe I will not need to change my code.

Before I instanciate this class, I need to modify it according my needs : I remove unwanted fields and tweak some widgets rendering :

    del ProfileEdit.base_fields['user']
    ProfileEdit.base_fields['birthdate'].widget = \ 
        SelectDateWidget(years = range(2010, 1940, -1))
    ProfileEdit.base_fields['avatar'].widget = forms.FileInput()
    ProfileEdit.base_fields['birthdate'].initial = str(profile.birthdate)

After that, I want to take control on the image validation. For that I create a validation method :

def clean_avatar(self, value):
    # avatar error handling
    from StringIO import StringIO
    from PIL import Image
    if 'content-type' in value:
        main, sub = value['content-type'].split('/')
        if not (main == 'image' and sub in ['jpeg', 'gif', 'png']):
            raise forms.ValidationError(_('JPEG, PNG, GIF only.'))
    try:
        img = Image.open(StringIO(value['content']))
        x, y = img.size 
    except:
        raise forms.ValidationError(_('Upload a valid image. The file you \
        uploaded was either not an image or a corrupted image.'))
    if y < 160 or x < 160:
        raise forms.ValidationError(_('Upload a valid image. \
            This one is too small in size.'))
    return value

Now the monkey patching comes into play. I overload the clean method of avatar field this way :

    import new
    ProfileEdit.base_fields['avatar'].clean = new.instancemethod(clean_avatar, \
        ProfileEdit.base_fields['avatar'], \
        ProfileEdit.base_fields['avatar'].__class__)

Finaly I save the avatar image this way :

    if(request.POST):
        
        # prevent avatar deletion if not posted
        if not 'avatar' in request.FILES:
            del ProfileEdit.base_fields['avatar']
            profile_form = ProfileEdit(request.POST)
        else:
            # request.FILES must be passed to the form
            profile_form = ProfileEdit(request.POST)
            new_data = request.POST.copy()
            new_data.update(request.FILES)
            profile_form = ProfileEdit(new_data)
       
        if profile_form.is_valid():
        
            if 'avatar' in ProfileEdit.base_fields:
                from StringIO import StringIO
                from PIL import Image
                avatar = Image.open(StringIO(request.FILES['avatar']['content']))
                # needed to save GIF format to jpeg
                avatar = avatar.convert("RGB")
                # Save the avatar as a jpeg
                avatar_path = '%s%s/avatar.jpg' % (settings.MEDIA_ROOT, self.user.id)
                avatar.save(avatar_path, 'jpeg')
                # Save avatar path in profile
                profile_form.clean_data['avatar'] = avatar_path

            # finaly save the profile
            profile_form.save()
        else:
            # handle errors

The avatar picture is saved full size. If you need to generate thumbnails, I recommand this powerfull template filter :

import os
import Image
from django.template import Library
from django.conf import settings

register = Library()

def thumbnail(original_image_path, size='48x48'):
    if not original_image_path:
        return
    # defining the size
    x, y = [int(x) for x in size.split('x')]
    # defining the filename and the miniature filename
    basename, format = original_image_path.rsplit('.', 1)
    basename, name = basename.rsplit('/', 1)
    miniature = basename + '/thumbnails/' + name + '_' + size + '.' +  format
    if not os.path.exists(basename + '/thumbnails/'):
        os.mkdir(basename + '/thumbnails/')
    miniature_filename = os.path.join(settings.MEDIA_ROOT, miniature)
    miniature_url = os.path.join(settings.MEDIA_URL, miniature)
    # if the image wasn't already resized, resize it
    if not os.path.exists(miniature_filename) \
        or os.path.getmtime(original_image_path) > os.path.getmtime(miniature_filename) :
        filename = os.path.join(settings.MEDIA_ROOT, original_image_path)
        image = Image.open(filename)
        image_x, image_y = image.size
        crop_ratio = x / float(y)
        image_ratio = image_x / float(image_y)
        if crop_ratio < image_ratio:
            # x needs to shrink
            top = 0
            bottom = image_y
            crop_width = int(image_y * crop_ratio)
            left = (image_x - crop_width) // 2
            right = left + crop_width
        else:
            # y needs to shrink 
            left = 0
            right = image_x
            crop_height = int(image_x * crop_ratio)
            top = (image_y - crop_height) // 2
            bottom = top + crop_height 
            
        image = image.crop((left, top, right, bottom)).resize((x,y), Image.ANTIALIAS)
        image.save(miniature_filename, image.format)
        start, end = miniature_filename.rsplit('/media/', 1)
        return '/media/'+end

register.filter(thumbnail)

Credits

This little article could not have been written without these useful sources :