From 6baf12676254bf0124911b3a036043b072977e59 Mon Sep 17 00:00:00 2001 From: Sergio Soria Date: Thu, 18 Apr 2013 17:55:00 -0700 Subject: [PATCH] Adding custom image field and supporting widget files --- armstrong/apps/images/fields.py | 185 ++++++++++++++++++++++++ armstrong/apps/images/forms/__init__.py | 0 armstrong/apps/images/forms/fields.py | 41 ++++++ armstrong/apps/images/forms/widgets.py | 61 ++++++++ 4 files changed, 287 insertions(+) create mode 100644 armstrong/apps/images/fields.py create mode 100644 armstrong/apps/images/forms/__init__.py create mode 100644 armstrong/apps/images/forms/fields.py create mode 100644 armstrong/apps/images/forms/widgets.py diff --git a/armstrong/apps/images/fields.py b/armstrong/apps/images/fields.py new file mode 100644 index 0000000..12f8d08 --- /dev/null +++ b/armstrong/apps/images/fields.py @@ -0,0 +1,185 @@ +import os.path +from PIL import Image +import StringIO + +from django.conf import settings +from django.db import models +from django.core.files.storage import FileSystemStorage +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.db.models.fields.files import ImageFileDescriptor + +from .forms.fields import VersionedImageField as CropperFormField + + +PLACEHOLDER_PATH = 'image/debug_placeholder.png' +STATIC_STORAGE = FileSystemStorage(location=settings.STATIC_ROOT, base_url=settings.STATIC_URL) + + +class ImageVersionSet(object): + def __init__(self, field, model_instance, filename): + self.field = field + self.filename = filename + self.model_instance = model_instance + + def __getattr__(self, name): + try: + filepath = self.field.versions[name]['upload_to'](self.model_instance, self.filename) + except (AttributeError, LookupError): + return None + + if filepath and self.field.storage.exists(filepath): + field_file_object = self.field.attr_class(self.model_instance, self.field, filepath) + elif getattr(settings, "DEBUG", False): + field_file_object = self.field.attr_class(self.model_instance, self.field, PLACEHOLDER_PATH) + field_file_object.storage = STATIC_STORAGE # don't use django-storages + + # attach debug information + field_file_object.actual_path = filepath + field_file_object.missing_file = True + else: + return None + + return field_file_object + + +class VersionedImageFileDescriptor(ImageFileDescriptor): + def __get__(self, instance=None, owner=None): + val = super(VersionedImageFileDescriptor, self).__get__(instance, owner) + if not getattr(val, 'versions', None): + setattr(val, 'versions', ImageVersionSet(self.field, instance, val.name)) + return val + + +class BaseVersionedImageField(models.ImageField): + descriptor_class = VersionedImageFileDescriptor + + def __init__(self, versions=None, upload_to='', use_field_name_as_file_name=False, *args, **kwargs): + self.versions = versions + self.use_field_name_as_file_name = use_field_name_as_file_name + + # In order to use the field name as the file name, we need to change how `upload_to` is used + # We save it for use later in our custom generate_filename(); do not pass into parent constructor + if use_field_name_as_file_name and callable(upload_to): + self._upload_to = upload_to + + #KLUDGE Django Model Validation requires FileFields to provide a non-empty `upload_to` + # we give it True to pass validation, but it's not callable() so it won't be used + # https://github.com/django/django/blob/28a4d039a2725ad51d23b92aa89e5b59e98e4a9b/django/db/models/fields/files.py#L223 + return super(BaseVersionedImageField, self).__init__(upload_to=True, *args, **kwargs) + + return super(BaseVersionedImageField, self).__init__(upload_to=upload_to, *args, **kwargs) + + def pre_save(self, model_instance, add): + """ + Skip parent pre_save()s because we want to delete the file before save() + and save() doesn't override existing files + + Note: + 1. This will not delete files off the disk when the file value is cleared. + This is standard Django behavior. + + 2. This will not delete prior files that have a different extension. + eg: Replacing a JPG file by uploading a PNG will result in a second + set of files: 'profile_pic.original.jpg' and 'profile_pic.original.png' + """ + file = getattr(model_instance, self.attname) # normally happens in django.db.models.fields.__init__.pr->Field.pre_save() + if file and not file._committed: # normally happens in django.db.models.fields.files.py->FileField.pre_save() + # filename is created in FieldFile.save() so we need to replicate what it will be + on_disk_filename = self.generate_filename(model_instance, file.name) + + # now delete the file that we expect to save + self.storage.delete(on_disk_filename) + + # Commit the file to storage prior to saving the model + file.save(file.name, file, save=False) # normally happens in django.db.models.fields.files.py->FileField.pre_save() + return file + + def get_filename(self, filename): + # Set the name of the Field as the filename but keep original extension + custom_name = "%s%s" % (self.name, os.path.splitext(filename)[1]) if self.use_field_name_as_file_name else None + return os.path.normpath(self.storage.get_valid_name(custom_name or filename)) + + def generate_filename(self, instance, filename): + """Use upload_to() if provided. This follows Django's normal paradigm.""" + + custom_name = self.get_filename(filename) + if hasattr(self, '_upload_to'): + return self._upload_to(instance, custom_name) + return super(BaseVersionedImageField, self).generate_filename(instance, custom_name) + + def _file_save(self, new_pil_obj, version, format, model_instance, file): + """Save our version files to disk""" + + img_version_buffer = StringIO.StringIO() + new_pil_obj.save(img_version_buffer, format) + + img_version_buffer = InMemoryUploadedFile( + file=img_version_buffer, + field_name=None, + name='foo', + content_type='image/%s' % format, + size=img_version_buffer.len, + charset=None) + img_version_buffer.open() # reopen just in case, which sets Django's File-like object to seek=0 + + filename = version['upload_to'](model_instance, file.name) + + # delete first to prevent save() from possibily creating a new uniquely named file + # we want to replace the file, which save() may not do natively + file.field.storage.delete(filename) + file.field.storage.save(filename, img_version_buffer) + + +class VersionedImageField(BaseVersionedImageField): + def formfield(self, **kwargs): + """Change the default Form Field this Model Field uses""" + + defaults = {'form_class': CropperFormField} + defaults.update(kwargs) + return super(VersionedImageField, self).formfield(**defaults) + + def pre_save(self, model_instance, add): + """Create our image versions given transform data provided by the Form Field""" + file = super(VersionedImageField, self).pre_save(model_instance, add) + if file and hasattr(file, 'version_transform_data'): + try: + img_pil_org = Image.open(file) + except: + file.open() # reopen which sets Django's File-like object to seek=0 + img_pil_org = Image.open(file) + + for version_id, version_data in file.version_transform_data.items(): + version = file.field.versions[version_id] + crop = img_pil_org.crop((version_data['x'], version_data['y'], version_data['x2'], version_data['y2'])) + if version['width'] != (version_data['x2'] - version_data['x']): + crop = crop.resize((version['width'], version['height']), Image.ANTIALIAS) + self._file_save(crop, version, img_pil_org.format, model_instance, file) + delattr(file, 'version_transform_data') + return file + + +class SquareAutoCropVersionedImageField(BaseVersionedImageField): + def pre_save(self, model_instance, add): + """Create our autocropped image versions""" + + file = super(SquareAutoCropVersionedImageField, self).pre_save(model_instance, add) + if file and (getattr(file, '_file') or hasattr(model_instance, '_porting_images_flag')): # _file will be None except on new uploads + fp = file._file + if getattr(model_instance, '_porting_images_flag', False): # HACK for profile image porting; remove after launch + fp = getattr(model_instance, self.attname) + + fp.open() # reopen just in case, which sets Django's File-like object to seek=0 + img_pil_org = Image.open(fp) + + # Crop: find largest square that fits in the image + org_w, org_h = img_pil_org.size + minsize = min(org_w, org_h) # get the smaller dimension + w_offset = int((org_w - minsize) / 2) + h_offset = int((org_h - minsize) / 2) + square = img_pil_org.crop((w_offset, h_offset, w_offset + minsize, h_offset + minsize)) + + # Resize: create all the different file versions + for _, version in self.versions.items(): + new = square.resize((version['width'], version['width']), Image.ANTIALIAS) # width both times just to be certain + self._file_save(new, version, img_pil_org.format, model_instance, file) + return file diff --git a/armstrong/apps/images/forms/__init__.py b/armstrong/apps/images/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/armstrong/apps/images/forms/fields.py b/armstrong/apps/images/forms/fields.py new file mode 100644 index 0000000..fdfebb3 --- /dev/null +++ b/armstrong/apps/images/forms/fields.py @@ -0,0 +1,41 @@ +from django import forms +from baycitizen.bc_images.forms.widgets import VerisonedImageCropperInput + + +class VersionedImageField(forms.ImageField): + widget = VerisonedImageCropperInput + + def clean(self, data, initial=None): + """ + Receives output from VerisonedImageCropperInput Widget + where `data` is expected to be (original, versions) but + it can also handle output from a normal File Widget. In + the second case, the file will not have crop data and the + versions will not be created so the field will essentially + be incomplete. + + """ + try: # VerisonedImageCropperInput widget output + image_file = data[0] + crop_data = data[1] + except (KeyError, TypeError): # standard File widget output + image_file = data + crop_data = {} + + original = super(VersionedImageField, self).clean(image_file, initial) + vtd = {} + for version_id, version_data in crop_data.items(): + vtd[version_id] = {} + for coord in ['x', 'y', 'x2', 'y2']: + if version_data[coord]: + try: + vtd[version_id][coord] = int(float(version_data[coord])) + except (OverflowError, ValueError): + del vtd[version_id] + break + else: + del vtd[version_id] + break + if original and vtd: + original.version_transform_data = vtd + return original diff --git a/armstrong/apps/images/forms/widgets.py b/armstrong/apps/images/forms/widgets.py new file mode 100644 index 0000000..1b6a977 --- /dev/null +++ b/armstrong/apps/images/forms/widgets.py @@ -0,0 +1,61 @@ +import simplejson as json + +from django.forms.widgets import ClearableFileInput +from django.template.loader import render_to_string +from django.utils.safestring import mark_safe + + +class VerisonedImageCropperInput(ClearableFileInput): + x_field = '_x' + y_field = '_y' + x2_field = '_x2' + y2_field = '_y2' + + def render(self, name, value, attrs=None): + file_browser = super(VerisonedImageCropperInput, self).render(name, value, attrs) + crop_fields = "" + if value and hasattr(value, 'field'): + for version_id, version in value.field.versions.items(): + prefix = self.getFieldPrefix(name) + version_id + cropfield_map = { + 'x': prefix + self.x_field, + 'y': prefix + self.y_field, + 'x2': prefix + self.x2_field, + 'y2': prefix + self.y2_field, + } + version_params = { + 'name': prefix, + 'label': version['label'], + 'original': {'width': value.width, 'height': value.height, 'url': value.url}, + 'version': getattr(value.versions, version_id), + 'hidden_inputs': cropfield_map.values(), + 'cropfield_map': json.dumps(cropfield_map), + 'width': version['width'], + 'height': version['height'], + } + crop_fields += render_to_string('widgets/imageversion.html', version_params) + + return mark_safe(file_browser + crop_fields) + + def getFieldPrefix(self, name): + return name + "_imageversion_" + + def value_from_datadict(self, data, files, name): + prefix = self.getFieldPrefix(name) + + versions = {} + #(TODO)Do with with JSON object instead.... + for field_name, field_value in data.items(): + if field_name.startswith(prefix): + field_suffix = field_name[len(prefix):] + if field_suffix.endswith(self.x_field): + versions.setdefault(field_suffix[:-len(self.x_field)], {})['x'] = field_value + elif field_suffix.endswith(self.y_field): + versions.setdefault(field_suffix[:-len(self.y_field)], {})['y'] = field_value + elif field_suffix.endswith(self.x2_field): + versions.setdefault(field_suffix[:-len(self.x2_field)], {})['x2'] = field_value + elif field_suffix.endswith(self.y2_field): + versions.setdefault(field_suffix[:-len(self.y2_field)], {})['y2'] = field_value + #Need to add some checking + original = super(VerisonedImageCropperInput, self).value_from_datadict(data, files, name) + return (original, versions)