-
Notifications
You must be signed in to change notification settings - Fork 9
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We use this field for user profile images where the full-feature |
||
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 |
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 |
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) |
There was a problem hiding this comment.
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.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 theself.field.storage.exists()
timeout.