changeset 700:e888d627928f

Refactored the processing of image uploads. I suspect I will use this general algorithm in other places (like POTD), so I made it reusable.
author Brian Neal <bgneal@gmail.com>
date Wed, 11 Sep 2013 20:31:23 -0500
parents d33bedc3be74
children 094492e66eb9
files core/functions.py core/image_uploader.py core/s3.py user_photos/forms.py user_photos/images.py user_photos/s3.py
diffstat 6 files changed, 215 insertions(+), 139 deletions(-) [+]
line wrap: on
line diff
--- a/core/functions.py	Mon Sep 09 20:53:08 2013 -0500
+++ b/core/functions.py	Wed Sep 11 20:31:23 2013 -0500
@@ -1,7 +1,9 @@
-"""This file houses various core utility functions for GPP"""
+"""This file houses various core utility functions"""
+from contextlib import contextmanager
 import datetime
+import logging
+import os
 import re
-import logging
 
 from django.contrib.sites.models import Site
 from django.conf import settings
@@ -10,6 +12,17 @@
 import core.tasks
 
 
+@contextmanager
+def temp_open(path, mode):
+    """A context manager for closing and removing temporary files."""
+    fp = open(path, mode)
+    try:
+        yield fp
+    finally:
+        fp.close()
+        os.remove(path)
+
+
 def send_mail(subject, message, from_email, recipient_list, defer=True, **kwargs):
     """
     The main send email function. Use this function to send email from the
@@ -52,7 +65,7 @@
     site = Site.objects.get_current()
     subject = '[%s] %s' % (site.name, subject)
     send_mail(subject,
-            msg,
+            message,
             '%s@%s' % (settings.GPP_NO_REPLY_EMAIL, site.domain),
             [mail_tuple[1] for mail_tuple in settings.MANAGERS])
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/core/image_uploader.py	Wed Sep 11 20:31:23 2013 -0500
@@ -0,0 +1,97 @@
+"""This module contains a function to upload an image file to a S3Bucket.
+
+The image can be resized and a thumbnail can be generated and uploaded as well.
+
+"""
+import logging
+from io import BytesIO
+import os.path
+import tempfile
+import uuid
+
+from PIL import Image
+
+from core.functions import temp_open
+
+
+logger = logging.getLogger(__name__)
+
+
+def upload(fp, bucket, metadata=None, new_size=None, thumb_size=None):
+    """Upload an image file to a given S3Bucket.
+
+    The image can optionally be resized and a thumbnail can be generated and
+    uploaded as well.
+
+    Parameters:
+        fp - The image file to process. This is expected to be an instance of
+             Django's UploadedFile class. The file must have a name attribute and
+             we expect the name to have an extension. This is extension is
+             used for the uploaded image and thumbnail names.
+        bucket - A core.s3.S3Bucket instance to upload to.
+        metadata - If not None, must be a dictionary of metadata to apply to the
+                   uploaded file and thumbnail.
+        new_size - If not None, the image will be resized to the dimensions
+                   specified by new_size, which must be a (width, height) tuple.
+        thumb_size - If not None, a thumbnail image will be created with the
+                     dimensions specified by thumb_size, which must be a (width,
+                     height) tuple. The thumbnail will use the same metadata, if
+                     present, as the image. The thumbnail filename will be the
+                     same basename as the image with a 't' appended. The
+                     extension will be the same as the original image.
+
+    A tuple is returned: (image_url, thumb_url) where thumb_url will be None if
+    a thumbnail was not requested.
+    """
+
+    logger.info('Processing image file: {}'.format(fp.name))
+
+    # Trying to use PIL (or Pillow) on a Django UploadedFile is often
+    # problematic because the file is often an in-memory file if it is under
+    # a certain size. This complicates matters and many of the operations we try
+    # to perform on it fail if this is the case. To get around these issues,
+    # we make a copy of the file on the file system and operate on the copy.
+    # First generate a unique name and temporary file path.
+    unique_key = uuid.uuid4().hex
+    ext = os.path.splitext(fp.name)[1]
+    temp_name = os.path.join(tempfile.gettempdir(), unique_key + ext)
+
+    # Write the UploadedFile to a temporary file on disk
+    with temp_open(temp_name, 'wb') as temp_file:
+        for chunk in fp.chunks():
+            temp_file.write(chunk)
+        temp_file.close()
+
+        # Resize image if necessary
+        if new_size:
+            image = Image.open(temp_name)
+            if image.size > new_size:
+                logger.debug('Resizing from {} to {}'.format(image.size, new_size))
+                image.thumbnail(new_size, Image.ANTIALIAS)
+                image.save(temp_name)
+
+        # Create thumbnail if necessary
+        thumb = None
+        if thumb_size:
+            logger.debug('Creating thumbnail {}'.format(thumb_size))
+            image = Image.open(temp_name)
+            image.thumbnail(thumb_size, Image.ANTIALIAS)
+            thumb = BytesIO()
+            image.save(thumb, format=image.format)
+
+        # Upload images to S3
+        file_key = unique_key + ext
+        logging.debug('Uploading image')
+        image_url = bucket.upload_from_filename(file_key, temp_name, metadata)
+
+    thumb_url = None
+    if thumb:
+        logging.debug('Uploading thumbnail')
+        thumb_key = '{}t{}'.format(unique_key, ext)
+        thumb_url = bucket.upload_from_string(thumb_key,
+                                              thumb.getvalue(),
+                                              metadata)
+
+    logger.info('Completed processing image file: {}'.format(fp.name))
+
+    return (image_url, thumb_url)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/core/s3.py	Wed Sep 11 20:31:23 2013 -0500
@@ -0,0 +1,84 @@
+"""s3.py
+
+This module provides Amazon S3 convenience wrappers.
+
+"""
+from boto.s3.connection import S3Connection
+from boto.s3.key import Key
+
+
+class S3Bucket(object):
+    """This class abstracts an Amazon S3 bucket.
+
+    We currently only support upload functionality.
+
+    """
+    def __init__(self, access_key, secret_key, base_url, bucket_name):
+        self.conn = S3Connection(access_key, secret_key)
+        self.bucket = self.conn.get_bucket(bucket_name, validate=False)
+        self.base_url = base_url
+        if not base_url.endswith('/'):
+            self.base_url += '/'
+        self.name = bucket_name
+
+    def upload_from_file(self, key_name, fp, metadata=None, public=True):
+        """Uploads data from the file object fp to a new key named
+        key_name. metadata, if not None, must be a dict of metadata key / value
+        pairs which will be added to the key.
+
+        If public is True, the key will be made public after the upload.
+
+        Returns the URL to the uploaded file.
+
+        """
+        key = self._make_key(key_name, metadata)
+        key.set_contents_from_file(fp)
+        if public:
+            key.make_public()
+        return '{}{}/{}'.format(self.base_url, self.name, key_name)
+
+    def upload_from_filename(self, key_name, filename, metadata=None,
+            public=True):
+        """Uploads data from the file named by filename to a new key named
+        key_name. metadata, if not None, must be a dict of metadata key / value
+        pairs which will be added to the key.
+
+        If public is True, the key will be made public after the upload.
+
+        Returns the URL to the uploaded file.
+
+        """
+        key = self._make_key(key_name, metadata)
+        key.set_contents_from_filename(filename)
+        if public:
+            key.make_public()
+        return '{}{}/{}'.format(self.base_url, self.name, key_name)
+
+    def upload_from_string(self, key_name, content, metadata=None,
+            public=True):
+        """Creates a new key with the given key_name, and uploads the string
+        content to it. metadata, if not None, must be a dict of metadata key /
+        value pairs which will be added to the key.
+
+        If public is True, the key will be made public after the upload.
+
+        Returns the URL to the uploaded file.
+
+        """
+        key = self._make_key(key_name, metadata)
+        key.set_contents_from_string(content)
+        if public:
+            key.make_public()
+        return '{}{}/{}'.format(self.base_url, self.name, key_name)
+
+    def _make_key(self, key_name, metadata):
+        """Private method to create a key and optionally apply metadata to
+        it.
+
+        """
+        key = Key(self.bucket)
+        key.key = key_name
+        if metadata:
+            for k, v in metadata.iteritems():
+                key.set_metadata(k, v)
+        return key
--- a/user_photos/forms.py	Mon Sep 09 20:53:08 2013 -0500
+++ b/user_photos/forms.py	Wed Sep 11 20:31:23 2013 -0500
@@ -1,10 +1,12 @@
 """Forms for the user_photos application."""
+import datetime
+
 from django import forms
 from django.conf import settings
 
+from core.s3 import S3Bucket
+from core.image_uploader import upload
 from user_photos.models import Photo
-from user_photos.images import process_file
-from user_photos.s3 import S3Bucket
 
 
 class UploadForm(forms.Form):
@@ -21,12 +23,20 @@
         This function should only be called if is_valid() returns True.
 
         """
-        bucket = S3Bucket(settings.USER_PHOTOS_ACCESS_KEY,
-                          settings.USER_PHOTOS_SECRET_KEY,
-                          settings.USER_PHOTOS_BUCKET)
-        url, thumb_url = process_file(self.cleaned_data['image_file'],
-                                      self.user,
-                                      bucket)
+        bucket = S3Bucket(access_key=settings.USER_PHOTOS_ACCESS_KEY,
+                          secret_key=settings.USER_PHOTOS_SECRET_KEY,
+                          base_url=settings.USER_PHOTOS_BASE_URL,
+                          bucket_name=settings.USER_PHOTOS_BUCKET)
+
+        now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+        metadata = {'user': self.user.username, 'date': now}
+
+        url, thumb_url = upload(fp=self.cleaned_data['image_file'],
+                                bucket=bucket,
+                                metadata=metadata,
+                                new_size=settings.USER_PHOTOS_MAX_SIZE,
+                                thumb_size=settings.USER_PHOTOS_THUMB_SIZE)
+
         photo = Photo(user=self.user, url=url, thumb_url=thumb_url)
         photo.save()
         return photo
--- a/user_photos/images.py	Mon Sep 09 20:53:08 2013 -0500
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,69 +0,0 @@
-"""images.py
-
-This module contains image processing routines for the user_photos application.
-
-"""
-import datetime
-import logging
-from io import BytesIO
-import os.path
-import uuid
-
-from django.conf import settings
-from PIL import Image
-
-
-logger = logging.getLogger(__name__)
-
-
-def process_file(f, user, bucket):
-    """Perform processing on the given uploaded image file:
-
-    * The image is resized if necessary
-    * A thumbnail version is created
-    * The image and thumbnail are uploaded to an S3 bucket
-    * The image and thumbnail URLs are returned as a tuple
-
-    """
-    logger.info('Processing image file for {}: {}'.format(user.username, f.name))
-
-    unique_key = uuid.uuid4().hex
-    ext = os.path.splitext(f.name)[1]
-    filename = '/tmp/' + unique_key + ext
-    with open(filename, 'wb') as fp:
-        for chunk in f.chunks():
-            fp.write(chunk)
-
-    # Resize image if necessary
-    image = Image.open(filename)
-    if image.size > settings.USER_PHOTOS_MAX_SIZE:
-        logger.debug('Resizing from {} to {}'.format(image.size, settings.USER_PHOTOS_MAX_SIZE))
-        image.thumbnail(settings.USER_PHOTOS_MAX_SIZE, Image.ANTIALIAS)
-        image.save(filename)
-
-    # Create thumbnail
-    logger.debug('Creating thumbnail')
-    image = Image.open(filename)
-    image.thumbnail(settings.USER_PHOTOS_THUMB_SIZE, Image.ANTIALIAS)
-    thumb = BytesIO()
-    image.save(thumb, format=image.format)
-
-    # Upload both images to S3
-    logging.debug('Uploading image')
-    now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
-    metadata = {'user': user.username, 'date': now}
-    file_key = unique_key + ext
-    bucket.upload_from_filename(file_key, filename, metadata)
-
-    logging.debug('Uploading thumbnail')
-    thumb_key = '{}t{}'.format(unique_key, ext)
-    bucket.upload_from_string(thumb_key, thumb.getvalue())
-
-    os.remove(filename)
-
-    logger.info('Completed processing image file for {}: {}'.format(user.username, f.name))
-
-    url_base = '{}/{}/'.format(settings.USER_PHOTOS_BASE_URL,
-                               settings.USER_PHOTOS_BUCKET)
-
-    return (url_base + file_key, url_base + thumb_key)
--- a/user_photos/s3.py	Mon Sep 09 20:53:08 2013 -0500
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,59 +0,0 @@
-"""s3.py
-
-This module provides the necessary S3 upload functionality for the user_photos
-application.
-
-"""
-from boto.s3.connection import S3Connection
-from boto.s3.key import Key
-
-
-class S3Bucket(object):
-    """This class abstracts an Amazon S3 bucket.
-
-    We currently only need upload functionality.
-
-    """
-    def __init__(self, access_key, secret_key, bucket_name):
-        self.conn = S3Connection(access_key, secret_key)
-        self.bucket = self.conn.get_bucket(bucket_name, validate=False)
-
-    def upload_from_filename(self, key_name, filename, metadata=None,
-            public=True):
-        """Uploads data from the file named by filename to a new key named
-        key_name. metadata, if not None, must be a dict of metadata key / value
-        pairs which will be added to the key.
-
-        If public is True, the key will be made public after the upload.
-
-        """
-        key = self._make_key(key_name, metadata)
-        key.set_contents_from_filename(filename)
-        if public:
-            key.make_public()
-
-    def upload_from_string(self, key_name, content, metadata=None,
-            public=True):
-        """Creates a new key with the given key_name, and uploads the string
-        content to it. metadata, if not None, must be a dict of metadata key /
-        value pairs which will be added to the key.
-
-        If public is True, the key will be made public after the upload.
-
-        """
-        key = self._make_key(key_name, metadata)
-        key.set_contents_from_string(content)
-        if public:
-            key.make_public()
-
-    def _make_key(self, key_name, metadata):
-        """Private method to create a key and optionally apply metadata to
-        it.
-
-        """
-        key = Key(self.bucket)
-        key.key = key_name
-        if metadata:
-            for k, v in metadata.iteritems():
-                key.set_metadata(k, v)
-        return key