images.py 5.77 KB
Newer Older
1 2 3 4
"""
Image file manipulation functions related to profile images.
"""
from cStringIO import StringIO
5
from collections import namedtuple
6 7 8 9 10 11 12 13 14

from django.conf import settings
from django.core.files.base import ContentFile
from django.utils.translation import ugettext as _, ugettext_noop as _noop
from PIL import Image

from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage


15 16
ImageType = namedtuple('ImageType', ('extensions', 'mimetypes', 'magic'))

17
IMAGE_TYPES = {
18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
    'jpeg': ImageType(
        extensions=['.jpeg', '.jpg'],
        mimetypes=['image/jpeg', 'image/pjpeg'],
        magic=['ffd8'],
    ),
    'png': ImageType(
        extensions=[".png"],
        mimetypes=['image/png'],
        magic=["89504e470d0a1a0a"],
    ),
    'gif': ImageType(
        extensions=[".gif"],
        mimetypes=['image/gif'],
        magic=["474946383961", "474946383761"],
    ),
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
}


def user_friendly_size(size):
    """
    Convert size in bytes to user friendly size.

    Arguments:
        size (int): size in bytes

    Returns:
        user friendly size
    """
    units = [_('bytes'), _('KB'), _('MB')]
    i = 0
    while size >= 1024:
        size /= 1024
        i += 1
    return u'{} {}'.format(size, units[i])


def get_valid_file_types():
    """
    Return comma separated string of valid file types.
    """
58
    return ', '.join([', '.join(IMAGE_TYPES[ft].extensions) for ft in IMAGE_TYPES.keys()])
59 60 61 62 63 64 65


FILE_UPLOAD_TOO_LARGE = _noop(u'The file must be smaller than {image_max_size} in size.'.format(image_max_size=user_friendly_size(settings.PROFILE_IMAGE_MAX_BYTES)))  # pylint: disable=line-too-long
FILE_UPLOAD_TOO_SMALL = _noop(u'The file must be at least {image_min_size} in size.'.format(image_min_size=user_friendly_size(settings.PROFILE_IMAGE_MIN_BYTES)))  # pylint: disable=line-too-long
FILE_UPLOAD_BAD_TYPE = _noop(u'The file must be one of the following types: {valid_file_types}.'.format(valid_file_types=get_valid_file_types()))  # pylint: disable=line-too-long
FILE_UPLOAD_BAD_EXT = _noop(u'The file name extension for this file does not match the file data. The file may be corrupted.')  # pylint: disable=line-too-long
FILE_UPLOAD_BAD_MIMETYPE = _noop(u'The Content-Type header for this file does not match the file data. The file may be corrupted.')  # pylint: disable=line-too-long
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86


class ImageValidationError(Exception):
    """
    Exception to use when the system rejects a user-supplied source image.
    """
    @property
    def user_message(self):
        """
        Translate the developer-facing exception message for API clients.
        """
        # pylint: disable=translation-of-non-string
        return _(self.message)


def validate_uploaded_image(uploaded_file):
    """
    Raises ImageValidationError if the server should refuse to use this
    uploaded file as the source image for a user's profile image.  Otherwise,
    returns nothing.
    """
87

88 89 90 91 92 93 94 95 96 97 98
    # validation code by @pmitros,
    # adapted from https://github.com/pmitros/ProfileXBlock
    # see also: http://en.wikipedia.org/wiki/Magic_number_%28programming%29

    if uploaded_file.size > settings.PROFILE_IMAGE_MAX_BYTES:
        raise ImageValidationError(FILE_UPLOAD_TOO_LARGE)
    elif uploaded_file.size < settings.PROFILE_IMAGE_MIN_BYTES:
        raise ImageValidationError(FILE_UPLOAD_TOO_SMALL)

    # check the file extension looks acceptable
    filename = unicode(uploaded_file.name).lower()
99
    filetype = [ft for ft in IMAGE_TYPES if any(filename.endswith(ext) for ext in IMAGE_TYPES[ft].extensions)]
100 101 102 103 104
    if not filetype:
        raise ImageValidationError(FILE_UPLOAD_BAD_TYPE)
    filetype = filetype[0]

    # check mimetype matches expected file type
105
    if uploaded_file.content_type not in IMAGE_TYPES[filetype].mimetypes:
106 107 108
        raise ImageValidationError(FILE_UPLOAD_BAD_MIMETYPE)

    # check magic number matches expected file type
109
    headers = IMAGE_TYPES[filetype].magic
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163
    if uploaded_file.read(len(headers[0]) / 2).encode('hex') not in headers:
        raise ImageValidationError(FILE_UPLOAD_BAD_EXT)
    # avoid unexpected errors from subsequent modules expecting the fp to be at 0
    uploaded_file.seek(0)


def _get_scaled_image_file(image_obj, size):
    """
    Given a PIL.Image object, get a resized copy using `size` (square) and
    return a file-like object containing the data saved as a JPEG.

    Note that the file object returned is a django ContentFile which holds
    data in memory (not on disk).
    """
    if image_obj.mode != "RGB":
        image_obj = image_obj.convert("RGB")
    scaled = image_obj.resize((size, size), Image.ANTIALIAS)
    string_io = StringIO()
    scaled.save(string_io, format='JPEG')
    image_file = ContentFile(string_io.getvalue())
    return image_file


def create_profile_images(image_file, profile_image_names):
    """
    Generates a set of image files based on image_file and
    stores them according to the sizes and filenames specified
    in `profile_image_names`.
    """
    image_obj = Image.open(image_file)

    # first center-crop the image if needed (but no scaling yet).
    width, height = image_obj.size
    if width != height:
        side = width if width < height else height
        image_obj = image_obj.crop(((width - side) / 2, (height - side) / 2, (width + side) / 2, (height + side) / 2))

    storage = get_profile_image_storage()
    for size, name in profile_image_names.items():
        scaled_image_file = _get_scaled_image_file(image_obj, size)
        # Store the file.
        try:
            storage.save(name, scaled_image_file)
        finally:
            scaled_image_file.close()


def remove_profile_images(profile_image_names):
    """
    Physically remove the image files specified in `profile_image_names`
    """
    storage = get_profile_image_storage()
    for name in profile_image_names.values():
        storage.delete(name)