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 :