Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tracking PR - A conversation starter #10

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions armstrong/apps/images/fields.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block isn't strictly necessary—we can refactor and discuss—but it's been useful in two commons cases. In debug mode when the image can't be found, this substitutes a (in our case) 1 pixel gray image and attaches the path of the intended-but-missing file along with a flag. The template (we have a single shared generic image display template) uses the defined version dimensions to set the height and width <img> attributes resulting in a placeholder grey box appearing exactly how the real image would appear and thus not breaking layouts. A little JavaScript warns in the console.

{% if file.missing_file %}
  <script>window.console && console.warn("broken image: {{ file.actual_path }}");</script>
{% endif %}

This has been really helpful when running on a development database (incorrect image paths), when using a development S3 bucket (no actual images) and when working without Internet (like on a train). We also use a USE_REMOTE_FILE_STORAGE setting for "train development" that short circuits having to wait for the self.field.storage.exists() timeout.

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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The screenshot in the PR description uses this field.

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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use this field for user profile images where the full-feature VersionedImageField isn't necessary. (The user uploads anything and we just auto-crop 'em a small square.)

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
Empty file.
41 changes: 41 additions & 0 deletions armstrong/apps/images/forms/fields.py
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions armstrong/apps/images/forms/widgets.py
Original file line number Diff line number Diff line change
@@ -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)