django-imagekit/imagekit/models/fields.py
2012-02-12 17:23:39 -05:00

482 lines
18 KiB
Python

import os
import datetime
from StringIO import StringIO
from django.core.files.base import ContentFile
from django.db import models
from django.db.models.fields.files import ImageFieldFile
from django.db.models.signals import post_init, post_save, post_delete
from django.utils.encoding import force_unicode, smart_str
from imagekit.utils import img_to_fobj, open_image, \
format_to_extension, extension_to_format, UnknownFormatError, \
UnknownExtensionError
from imagekit.processors import ProcessorPipeline, AutoConvert
from ..imagecache import get_default_image_cache_backend
class SpecFileGenerator(object):
def __init__(self, processors=None, format=None, options={},
autoconvert=True, storage=None,
cache_state_backend=None):
self.processors = processors
self.format = format
self.options = options
self.autoconvert = autoconvert
self.storage = storage
self.cache_state_backend = cache_state_backend or get_default_cache_state_backend()
def process(self, image, file, instance):
processors = self.processors
if callable(processors):
processors = processors(instance=instance, file=file)
processors = ProcessorPipeline(processors or [])
return processors.process(image.copy())
def generate_content(self, filename, content, model=None):
img = open_image(content)
original_format = img.format
img = self.process(img, self, model)
options = dict(self.options or {})
# Determine the format.
format = self.format
if not format:
# Try to guess the format from the extension.
extension = os.path.splitext(filename)[1].lower()
if extension:
try:
format = extension_to_format(extension)
except UnknownExtensionError:
pass
format = format or img.format or original_format or 'JPEG'
# Run the AutoConvert processor
if self.autoconvert:
autoconvert_processor = AutoConvert(format)
img = autoconvert_processor.process(img)
options = dict(autoconvert_processor.save_kwargs.items() + \
options.items())
imgfile = img_to_fobj(img, format, **options)
content = ContentFile(imgfile.read())
return img, content
def suggest_extension(self, name):
original_extension = os.path.splitext(name)[1]
try:
suggested_extension = format_to_extension(self.format)
except UnknownFormatError:
extension = original_extension
else:
if suggested_extension.lower() == original_extension.lower():
extension = original_extension
else:
try:
original_format = extension_to_format(original_extension)
except UnknownExtensionError:
extension = suggested_extension
else:
# If the formats match, give precedence to the original extension.
if self.format.lower() == original_format.lower():
extension = original_extension
else:
extension = suggested_extension
return extension
def generate_file(self, filename, source_file, save=True):
"""
Generates a new image file by processing the source file and returns
the content of the result, ready for saving.
"""
if source_file: # TODO: Should we error here or something if the source_file doesn't exist?
# Process the original image file.
try:
fp = source_file.storage.open(source_file.name)
except IOError:
return
fp.seek(0)
fp = StringIO(fp.read())
img, content = self.generate_content(filename, fp,
getattr(source_file, 'instance', None))
if save:
storage = self.storage or source_file.storage
storage.save(filename, content)
return content
def invalidate(self, file):
return self.cache_state_backend.invalidate(file)
def validate(self, file):
return self.cache_state_backend.validate(file)
def clear(self, file):
return self.cache_state_backend.clear(file)
class BoundImageKitMeta(object):
def __init__(self, instance, spec_fields):
self.instance = instance
self.spec_fields = spec_fields
@property
def spec_files(self):
return [getattr(self.instance, n) for n in self.spec_fields]
class ImageKitMeta(object):
def __init__(self, spec_fields=None):
self.spec_fields = spec_fields or []
def __get__(self, instance, owner):
if instance is None:
return self
else:
ik = BoundImageKitMeta(instance, self.spec_fields)
setattr(instance, '_ik', ik)
return ik
class ImageSpecField(object):
"""
The heart and soul of the ImageKit library, ImageSpecField allows you to add
variants of uploaded images to your models.
"""
def __init__(self, processors=None, format=None, options={},
image_field=None, pre_cache=None, storage=None, cache_to=None,
autoconvert=True, image_cache_backend=None):
"""
:param processors: A list of processors to run on the original image.
:param format: The format of the output file. If not provided,
ImageSpecField will try to guess the appropriate format based on the
extension of the filename and the format of the input image.
:param options: A dictionary that will be passed to PIL's
``Image.save()`` method as keyword arguments. Valid options vary
between formats, but some examples include ``quality``,
``optimize``, and ``progressive`` for JPEGs. See the PIL
documentation for others.
:param image_field: The name of the model property that contains the
original image.
:param storage: A Django storage system to use to save the generated
image.
:param cache_to: Specifies the filename to use when saving the image
cache file. This is modeled after ImageField's ``upload_to`` and
can be either a string (that specifies a directory) or a
callable (that returns a filepath). Callable values should
accept the following arguments:
- instance -- The model instance this spec belongs to
- path -- The path of the original image
- specname -- the property name that the spec is bound to on
the model instance
- extension -- A recommended extension. If the format of the
spec is set explicitly, this suggestion will be
based on that format. if not, the extension of the
original file will be passed. You do not have to use
this extension, it's only a recommendation.
:param autoconvert: Specifies whether the AutoConvert processor
should be run before saving.
:param image_cache_backend: An object responsible for managing the state
of cached files. Defaults to an instance of
IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND
"""
if pre_cache is not None:
raise Exception('The pre_cache argument has been removed in favor'
' of cache state backends.')
self.generator = SpecFileGenerator(processors, format=format,
options=options, autoconvert=autoconvert, image_cache_backend=image_cache_backend)
self.image_field = image_field
self.storage = storage
self.cache_to = cache_to
def contribute_to_class(self, cls, name):
setattr(cls, name, _ImageSpecFieldDescriptor(self, name))
try:
ik = getattr(cls, '_ik')
except AttributeError:
ik = ImageKitMeta()
setattr(cls, '_ik', ik)
ik.spec_fields.append(name)
# Connect to the signals only once for this class.
uid = '%s.%s' % (cls.__module__, cls.__name__)
post_init.connect(ImageSpecField._post_init_receiver, sender=cls,
dispatch_uid=uid)
post_save.connect(ImageSpecField._post_save_receiver, sender=cls,
dispatch_uid=uid)
post_delete.connect(ImageSpecField._post_delete_receiver, sender=cls,
dispatch_uid=uid)
# Register the field with the image_cache_backend
try:
self.image_cache_backend.register_field(cls, self, name)
except AttributeError:
pass
@staticmethod
def _post_save_receiver(sender, instance=None, created=False, raw=False, **kwargs):
if not raw:
old_hashes = instance._ik._source_hashes.copy()
new_hashes = ImageSpecField._update_source_hashes(instance)
for attname in instance._ik.spec_fields:
if old_hashes[attname] != new_hashes[attname]:
getattr(instance, attname).invalidate()
@staticmethod
def _update_source_hashes(instance):
"""
Stores hashes of the source image files so that they can be compared
later to see whether the source image has changed (and therefore whether
the spec file needs to be regenerated).
"""
instance._ik._source_hashes = dict((f.attname, hash(f.source_file)) \
for f in instance._ik.spec_files)
return instance._ik._source_hashes
@staticmethod
def _post_delete_receiver(sender, instance=None, **kwargs):
for spec_file in instance._ik.spec_files:
spec_file.clear()
@staticmethod
def _post_init_receiver(sender, instance, **kwargs):
ImageSpecField._update_source_hashes(instance)
class ImageSpecFieldFile(ImageFieldFile):
def __init__(self, instance, field, attname):
ImageFieldFile.__init__(self, instance, field, None)
self.generator = field.generator
self.attname = attname
self.storage = self.field.storage or self.source_file.storage
@property
def source_file(self):
field_name = getattr(self.field, 'image_field', None)
if field_name:
field_file = getattr(self.instance, field_name)
else:
image_fields = [getattr(self.instance, f.attname) for f in \
self.instance.__class__._meta.fields if \
isinstance(f, models.ImageField)]
if len(image_fields) == 0:
raise Exception('%s does not define any ImageFields, so your' \
' %s ImageSpecField has no image to act on.' % \
(self.instance.__class__.__name__, self.attname))
elif len(image_fields) > 1:
raise Exception('%s defines multiple ImageFields, but you' \
' have not specified an image_field for your %s' \
' ImageSpecField.' % (self.instance.__class__.__name__,
self.attname))
else:
field_file = image_fields[0]
return field_file
def _require_file(self):
if not self.source_file:
raise ValueError("The '%s' attribute's image_field has no file associated with it." % self.attname)
def _get_file(self):
self.validate()
return super(ImageFieldFile, self).file
file = property(_get_file, ImageFieldFile._set_file, ImageFieldFile._del_file)
def clear(self):
return self.generator.clear(self)
def invalidate(self):
return self.generator.invalidate(self)
def validate(self):
return self.generator.validate(self)
def generate(self, save=True):
"""
Generates a new image file by processing the source file and returns
the content of the result, ready for saving.
"""
return self.generator.generate_file(self.name, self.source_file, save)
@property
def url(self):
self.validate()
return super(ImageFieldFile, self).url
def delete(self, save=False):
"""
Pulled almost verbatim from ``ImageFieldFile.delete()`` and
``FieldFile.delete()`` but with the attempts to reset the instance
property removed.
"""
# Clear the image dimensions cache
if hasattr(self, '_dimensions_cache'):
del self._dimensions_cache
# Only close the file if it's already open, which we know by the
# presence of self._file.
if hasattr(self, '_file'):
self.close()
del self.file
if self.name and self.storage.exists(self.name):
try:
self.storage.delete(self.name)
except NotImplementedError:
pass
# Delete the filesize cache.
if hasattr(self, '_size'):
del self._size
self._committed = False
if save:
self.instance.save()
def _default_cache_to(self, instance, path, specname, extension):
"""
Determines the filename to use for the transformed image. Can be
overridden on a per-spec basis by setting the cache_to property on
the spec.
"""
filepath, basename = os.path.split(path)
filename = os.path.splitext(basename)[0]
new_name = '%s_%s%s' % (filename, specname, extension)
return os.path.join(os.path.join('cache', filepath), new_name)
@property
def name(self):
"""
Specifies the filename that the cached image will use. The user can
control this by providing a `cache_to` method to the ImageSpecField.
"""
name = getattr(self, '_name', None)
if not name:
filename = self.source_file.name
new_filename = None
if filename:
cache_to = self.field.cache_to or self._default_cache_to
if not cache_to:
raise Exception('No cache_to or default_cache_to value specified')
if callable(cache_to):
suggested_extension = self.generator.suggest_extension(
self.source_file.name)
new_filename = force_unicode(datetime.datetime.now().strftime( \
smart_str(cache_to(self.instance, self.source_file.name,
self.attname, suggested_extension))))
else:
dir_name = os.path.normpath(force_unicode(datetime.datetime.now().strftime(smart_str(cache_to))))
filename = os.path.normpath(os.path.basename(filename))
new_filename = os.path.join(dir_name, filename)
self._name = new_filename
return self._name
@name.setter
def name(self, value):
# TODO: Figure out a better way to handle this. We really don't want
# to allow anybody to set the name, but ``File.__init__`` (which is
# called by ``ImageSpecFieldFile.__init__``) does, so we have to allow
# it at least that one time.
pass
class _ImageSpecFieldDescriptor(object):
def __init__(self, field, attname):
self.attname = attname
self.field = field
def __get__(self, instance, owner):
if instance is None:
return self.field
else:
img_spec_file = ImageSpecFieldFile(instance, self.field,
self.attname)
setattr(instance, self.attname, img_spec_file)
return img_spec_file
def _post_save_handler(sender, instance=None, created=False, raw=False, **kwargs):
if raw:
return
spec_files = get_spec_files(instance)
for spec_file in spec_files:
if not created:
spec_file.delete(save=False)
if spec_file.field.pre_cache:
spec_file.generate(False)
def _post_delete_handler(sender, instance=None, **kwargs):
assert instance._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % (instance._meta.object_name, instance._meta.pk.attname)
spec_files = get_spec_files(instance)
for spec_file in spec_files:
spec_file.delete(save=False)
class ProcessedImageFieldFile(ImageFieldFile):
def save(self, name, content, save=True):
new_filename = self.field.generate_filename(self.instance, name)
img, content = self.field.generator.generate_content(new_filename, content)
return super(ProcessedImageFieldFile, self).save(name, content, save)
class ProcessedImageField(models.ImageField):
"""
ProcessedImageField is an ImageField that runs processors on the uploaded
image *before* saving it to storage. This is in contrast to specs, which
maintain the original. Useful for coercing fileformats or keeping images
within a reasonable size.
"""
attr_class = ProcessedImageFieldFile
def __init__(self, processors=None, format=None, options={},
verbose_name=None, name=None, width_field=None, height_field=None,
autoconvert=True, **kwargs):
"""
The ProcessedImageField constructor accepts all of the arguments that
the :class:`django.db.models.ImageField` constructor accepts, as well
as the ``processors``, ``format``, and ``options`` arguments of
:class:`imagekit.models.fields.ImageSpecField`.
"""
if 'quality' in kwargs:
raise Exception('The "quality" keyword argument has been'
""" deprecated. Use `options={'quality': %s}` instead.""" \
% kwargs['quality'])
models.ImageField.__init__(self, verbose_name, name, width_field,
height_field, **kwargs)
self.generator = SpecFileGenerator(processors, format=format,
options=options, autoconvert=autoconvert)
def get_filename(self, filename):
filename = os.path.normpath(self.storage.get_valid_name(os.path.basename(filename)))
name, ext = os.path.splitext(filename)
ext = self.generator.suggested_extension(filename, self.format)
return '%s%s' % (name, ext)
try:
from south.modelsinspector import add_introspection_rules
except ImportError:
pass
else:
add_introspection_rules([], [r'^imagekit\.models\.fields\.ProcessedImageField$'])