diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index fb3f78d..92a25d3 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -1,11 +1,12 @@ import os from django.db import models -from .files import ProcessedImageFieldFile -from .utils import ImageSpecFileDescriptor, ImageKitMeta +from ..files import ProcessedImageFieldFile +from .utils import ImageSpecFileDescriptor from ..receivers import configure_receivers from ...utils import suggest_extension -from ...specs import SpecHost +from ...specs import SpecHost, spec_registry +from ...specs.sources import ImageFieldSpecSource class ImageSpecField(SpecHost): @@ -40,19 +41,6 @@ class ImageSpecField(SpecHost): def contribute_to_class(self, cls, name): setattr(cls, name, ImageSpecFileDescriptor(self, name)) - try: - # Make sure we don't modify an inherited ImageKitMeta instance - ik = cls.__dict__['ik'] - except KeyError: - try: - base = getattr(cls, '_ik') - except AttributeError: - ik = ImageKitMeta() - else: - # Inherit all the spec fields. - ik = ImageKitMeta(base.spec_fields) - setattr(cls, '_ik', ik) - ik.spec_fields.append(name) # Generate a spec_id to register the spec with. The default spec id is # ":_" @@ -64,6 +52,10 @@ class ImageSpecField(SpecHost): # later, from outside of the model definition. self.set_spec_id(self.spec_id) + # Register the model and field as a source for this spec id + spec_registry.add_source(self.spec_id, + ImageFieldSpecSource(cls, self.image_field)) + class ProcessedImageField(models.ImageField, SpecHost): """ diff --git a/imagekit/models/fields/utils.py b/imagekit/models/fields/utils.py index 1b3ccaa..3014b53 100644 --- a/imagekit/models/fields/utils.py +++ b/imagekit/models/fields/utils.py @@ -1,29 +1,6 @@ from .files import ImageSpecFieldFile -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 = list(spec_fields) if spec_fields else [] - - 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 ImageSpecFileDescriptor(object): def __init__(self, field, attname): self.attname = attname diff --git a/imagekit/models/receivers.py b/imagekit/models/receivers.py deleted file mode 100644 index 67960f7..0000000 --- a/imagekit/models/receivers.py +++ /dev/null @@ -1,51 +0,0 @@ -from django.db.models.signals import post_init, post_save, post_delete -from ..utils import ik_model_receiver - - -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 - - -@ik_model_receiver -def post_save_receiver(sender, instance=None, created=False, raw=False, **kwargs): - if not raw: - old_hashes = instance._ik._source_hashes.copy() - new_hashes = update_source_hashes(instance) - for attname in instance._ik.spec_fields: - file = getattr(instance, attname) - if created: - file.field.spec.image_cache_strategy.invoke_callback('source_create', file) - elif old_hashes[attname] != new_hashes[attname]: - file.field.spec.image_cache_strategy.invoke_callback('source_change', file) - - -@ik_model_receiver -def post_delete_receiver(sender, instance=None, **kwargs): - for spec_file in instance._ik.spec_files: - spec_file.field.spec.image_cache_strategy.invoke_callback('source_delete', spec_file) - - -@ik_model_receiver -def post_init_receiver(sender, instance, **kwargs): - update_source_hashes(instance) - - -def configure_receivers(): - # Connect the signals. We have to listen to every model (not just those - # with IK fields) and filter in our receivers because of a Django issue with - # abstract base models. - # Related: - # https://github.com/jdriscoll/django-imagekit/issues/126 - # https://code.djangoproject.com/ticket/9318 - uid = 'ik_spec_field_receivers' - post_init.connect(post_init_receiver, dispatch_uid=uid) - post_save.connect(post_save_receiver, dispatch_uid=uid) - post_delete.connect(post_delete_receiver, dispatch_uid=uid) diff --git a/imagekit/specs.py b/imagekit/specs/__init__.py similarity index 91% rename from imagekit/specs.py rename to imagekit/specs/__init__.py index 2d4b400..160612f 100644 --- a/imagekit/specs.py +++ b/imagekit/specs/__init__.py @@ -1,3 +1,4 @@ +from collections import defaultdict from django.conf import settings from hashlib import md5 import os @@ -7,6 +8,7 @@ from .imagecache.backends import get_default_image_cache_backend from .imagecache.strategies import StrategyWrapper from .lib import StringIO from .processors import ProcessorPipeline +from .signals import source_created, source_changed, source_deleted from .utils import (open_image, extension_to_format, IKContentFile, img_to_fobj, suggest_extension) @@ -14,12 +16,29 @@ from .utils import (open_image, extension_to_format, IKContentFile, img_to_fobj, class SpecRegistry(object): def __init__(self): self._specs = {} + self._sources = defaultdict(list) def register(self, id, spec): if id in self._specs: raise AlreadyRegistered('The spec with id %s is already registered' % id) self._specs[id] = spec + def add_source(self, id, source): + self._sources[id].append(source) + source_created.connect(receiver, sender, weak, dispatch_uid) + source_changed.connect(receiver, sender, weak, dispatch_uid) + source_deleted.connect(receiver, sender, weak, dispatch_uid) + + def source_receiver(self, source, source_file): + # Get a list of specs that use this source. + ids = (k for k, v in self._sources.items() if source in v) + specs = (self.get_spec(id) for id in ids) + for spec in specs: + spec.image_cache_strategy.invoke_callback(..., source_file) + + def get_sources(self, id): + return self._sources[id] + def unregister(self, id, spec): try: del self._specs[id] diff --git a/imagekit/specs/signals.py b/imagekit/specs/signals.py new file mode 100644 index 0000000..7c0888c --- /dev/null +++ b/imagekit/specs/signals.py @@ -0,0 +1,5 @@ +from django.dispatch import Signal + +source_created = Signal(providing_args=[]) +source_changed = Signal() +source_deleted = Signal() diff --git a/imagekit/specs/sources.py b/imagekit/specs/sources.py new file mode 100644 index 0000000..88e6156 --- /dev/null +++ b/imagekit/specs/sources.py @@ -0,0 +1,107 @@ +from django.db.models.signals import post_init, post_save, post_delete +from django.utils.functional import wraps +from .signals import source_created, source_changed, source_deleted + + +def ik_model_receiver(fn): + """ + A method decorator that filters out signals coming from models that don't + have fields that function as ImageFieldSpecSources + + """ + @wraps(fn) + def receiver(self, sender, **kwargs): + if sender in (src.model_class for src in self._sources): + fn(sender, **kwargs) + return receiver + + +class ModelSignalRouter(object): + def __init__(self): + self._sources = [] + uid = 'ik_spec_field_receivers' + post_init.connect(self.post_init_receiver, dispatch_uid=uid) + post_save.connect(self.post_save_receiver, dispatch_uid=uid) + post_delete.connect(self.post_delete_receiver, dispatch_uid=uid) + + def add(self, source): + self._sources.append(source) + + def init_instance(self, instance): + instance._ik = getattr(instance, '_ik', {}) + + def update_source_hashes(self, 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). + + """ + self.init_instance(instance) + instance._ik['source_hashes'] = dict((k, hash(v.source_file)) + for k, v in self.get_field_dict(instance).items()) + return instance._ik['source_hashes'] + + def get_field_dict(self, instance): + """ + Returns the source fields for the given instance, in a dictionary whose + keys are the field names and values are the fields themselves. + + """ + return dict((src.image_field, getattr(instance, src.image_field)) for + src in self._sources if src.model_class is instance.__class__) + + @ik_model_receiver + def post_save_receiver(self, sender, instance=None, created=False, raw=False, **kwargs): + if not raw: + self.init_instance(instance) + old_hashes = instance._ik.get('source_hashes', {}).copy() + new_hashes = self.update_source_hashes(instance) + for attname, file in self.get_field_dict(instance).items(): + if created: + self.dispatch_signal(source_created, sender, file) + elif old_hashes[attname] != new_hashes[attname]: + self.dispatch_signal(source_changed, sender, file) + + @ik_model_receiver + def post_delete_receiver(self, sender, instance=None, **kwargs): + for attname, file in self.get_field_dict(instance): + self.dispatch_signal(source_deleted, sender, file) + + @classmethod + @ik_model_receiver + def post_init_receiver(self, sender, instance, **kwargs): + self.update_source_hashes(instance) + + def dispatch_signal(self, signal, model_class, file): + """ + Dispatch the signal for each of the matching sources. Note that more + than one source can have the same model and image_field; it's important + that we dispatch the signal for each. + + """ + for source in self._sources: + if source.model_class is model_class and source.image_field == file.attname: + signal.send(sender=source, source_file=file) + + +class ImageFieldSpecSource(object): + def __init__(self, model_class, image_field): + """ + Good design would dictate that this instance would be responsible for + watching for changes for the provided field. However, due to a bug in + Django, we can't do that without leaving abstract base models (which + don't trigger signals) in the lurch. So instead, we do all signal + handling through the signal router. + + Related: + https://github.com/jdriscoll/django-imagekit/issues/126 + https://code.djangoproject.com/ticket/9318 + + """ + self.model_class = model_class + self.image_field = image_field + signal_router.add(self) + + +signal_router = ModelSignalRouter() diff --git a/imagekit/utils.py b/imagekit/utils.py index fb60035..07f05e5 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -399,14 +399,6 @@ def get_singleton(class_path, desc): return instance -def ik_model_receiver(fn): - @wraps(fn) - def receiver(sender, **kwargs): - if getattr(sender, '_ik', None): - fn(sender, **kwargs) - return receiver - - def autodiscover(): """ Auto-discover INSTALLED_APPS imagespecs.py modules and fail silently when