diff --git a/README.md b/README.md index 541cb80..268b245 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,38 @@ The default processor is `pictures.tasks.process_picture`. It takes a single argument, the `PictureFileFile` instance. You can use this to override the processor, should you need to do some custom processing. +### Signals + +The async image processing emits a signal when the task is complete. +You can use that to store when the pictures have been processed, +so that a placeholder could be rendered in the meantime. + +```python +# models.py +from django.db import models +from pictures.models import PictureField + + +class Profile(models.Model): + title = models.CharField(max_length=255) + picture = PictureField(upload_to="avatars") + picture_processed = models.BooleanField(editable=False, null=True) + + +# signals.py +from django.dispatch import receiver +from pictures import signals + +from .models import Profile + + +@receiver(signals.picture_processed, sender=Profile._meta.get_field("picture")) +def picture_processed_handler(*, sender, file_name, **__): + sender.model.objects.filter(**{sender.name: file_name}).update( + picture_processed=True + ) +``` + ### Validators The library ships with validators to restrain image dimensions: diff --git a/pictures/models.py b/pictures/models.py index 6413c2d..128fcb4 100644 --- a/pictures/models.py +++ b/pictures/models.py @@ -156,6 +156,7 @@ def delete_all(self): import_string(conf.get_settings().PROCESSOR)( self.storage.deconstruct(), self.name, + self.sender, [], [i.deconstruct() for i in self.get_picture_files_list()], ) @@ -170,10 +171,19 @@ def update_all(self, other: PictureFieldFile | None = None): import_string(conf.get_settings().PROCESSOR)( self.storage.deconstruct(), self.name, + self.sender, [i.deconstruct() for i in new], [i.deconstruct() for i in old], ) + @property + def sender(self): + return ( + self.instance._meta.app_label, + self.instance._meta.model_name, + self.field.name, + ) + @property def width(self): self._require_file() diff --git a/pictures/signals.py b/pictures/signals.py new file mode 100644 index 0000000..25e3835 --- /dev/null +++ b/pictures/signals.py @@ -0,0 +1,3 @@ +import django.dispatch + +picture_processed = django.dispatch.Signal() diff --git a/pictures/tasks.py b/pictures/tasks.py index a2bf1ba..f236a2d 100644 --- a/pictures/tasks.py +++ b/pictures/tasks.py @@ -2,10 +2,11 @@ from typing import Protocol +from django.apps import apps from django.db import transaction from PIL import Image -from pictures import conf, utils +from pictures import conf, signals, utils def noop(*args, **kwargs) -> None: @@ -17,6 +18,7 @@ def __call__( self, storage: tuple[str, list, dict], file_name: str, + sender: tuple[str, str, str], new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, ) -> None: ... @@ -25,6 +27,7 @@ def __call__( def _process_picture( storage: tuple[str, list, dict], file_name: str, + sender: tuple[str, str, str], new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, ) -> None: @@ -41,6 +44,17 @@ def _process_picture( picture = utils.reconstruct(*picture) picture.delete() + app_label, model_name, field_name = sender + model = apps.get_model(app_label=app_label, model_name=model_name) + field = model._meta.get_field(field_name) + + signals.picture_processed.send( + sender=field, + file_name=file_name, + new=new, + old=old, + ) + process_picture: PictureProcessor = _process_picture @@ -55,14 +69,16 @@ def _process_picture( def process_picture_with_dramatiq( storage: tuple[str, list, dict], file_name: str, + sender: tuple[str, str, str], new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, ) -> None: - _process_picture(storage, file_name, new, old) + _process_picture(storage, file_name, sender, new, old) def process_picture( # noqa: F811 storage: tuple[str, list, dict], file_name: str, + sender: tuple[str, str, str], new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, ) -> None: @@ -70,6 +86,7 @@ def process_picture( # noqa: F811 lambda: process_picture_with_dramatiq.send( storage=storage, file_name=file_name, + sender=sender, new=new, old=old, ) @@ -89,14 +106,16 @@ def process_picture( # noqa: F811 def process_picture_with_celery( storage: tuple[str, list, dict], file_name: str, + sender: tuple[str, str, str], new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, ) -> None: - _process_picture(storage, file_name, new, old) + _process_picture(storage, file_name, sender, new, old) def process_picture( # noqa: F811 storage: tuple[str, list, dict], file_name: str, + sender: tuple[str, str, str], new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, ) -> None: @@ -105,6 +124,7 @@ def process_picture( # noqa: F811 kwargs=dict( storage=storage, file_name=file_name, + sender=sender, new=new, old=old, ), @@ -123,14 +143,16 @@ def process_picture( # noqa: F811 def process_picture_with_django_rq( storage: tuple[str, list, dict], file_name: str, + sender: tuple[str, str, str], new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, ) -> None: - _process_picture(storage, file_name, new, old) + _process_picture(storage, file_name, sender, new, old) def process_picture( # noqa: F811 storage: tuple[str, list, dict], file_name: str, + sender: tuple[str, str, str], new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, ) -> None: @@ -138,6 +160,7 @@ def process_picture( # noqa: F811 lambda: process_picture_with_django_rq.delay( storage=storage, file_name=file_name, + sender=sender, new=new, old=old, ) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 1263d3c..3a0efc8 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -4,6 +4,7 @@ from django.core.management import call_command from django.db import models from django.db.models.fields.files import ImageFieldFile +from django.test.utils import isolate_apps from pictures import migrations from pictures.models import PictureField @@ -117,6 +118,7 @@ class Meta: assert not migration.to_picture_field.called @pytest.mark.django_db + @isolate_apps def test_update_pictures(self, request, stub_worker, image_upload_file): class ToModel(models.Model): name = models.CharField(max_length=100) @@ -172,6 +174,7 @@ class Meta: assert not luke.picture @pytest.mark.django_db + @isolate_apps def test_update_pictures__with_empty_pictures( self, request, stub_worker, image_upload_file ): diff --git a/tests/test_signals.py b/tests/test_signals.py new file mode 100644 index 0000000..f64de12 --- /dev/null +++ b/tests/test_signals.py @@ -0,0 +1,68 @@ +from unittest.mock import Mock + +import pytest +from django.dispatch import receiver + +from pictures import signals, tasks +from tests.test_migrations import skip_dramatiq +from tests.testapp.models import SimpleModel + + +@pytest.mark.django_db +@skip_dramatiq +def test_process_picture_sends_process_picture_done(image_upload_file): + obj = SimpleModel.objects.create(picture=image_upload_file) + + handler = Mock() + signals.picture_processed.connect(handler) + + tasks._process_picture( + obj.picture.storage.deconstruct(), + obj.picture.name, + obj.picture.sender, + new=[i.deconstruct() for i in obj.picture.get_picture_files_list()], + ) + + handler.assert_called_once_with( + signal=signals.picture_processed, + sender=SimpleModel._meta.get_field("picture"), + file_name=obj.picture.name, + new=[i.deconstruct() for i in obj.picture.get_picture_files_list()], + old=[], + ) + + +@pytest.mark.django_db +@skip_dramatiq +def test_process_picture_sends_process_picture_done_on_create(image_upload_file): + handler = Mock() + signals.picture_processed.connect(handler) + + obj = SimpleModel.objects.create(picture=image_upload_file) + + handler.assert_called_once_with( + signal=signals.picture_processed, + sender=SimpleModel._meta.get_field("picture"), + file_name=obj.picture.name, + new=[i.deconstruct() for i in obj.picture.get_picture_files_list()], + old=[], + ) + + +@pytest.mark.django_db +@skip_dramatiq +def test_processed_object_found(image_upload_file): + obj = SimpleModel.objects.create() + + found_object = None + + @receiver(signals.picture_processed, sender=SimpleModel._meta.get_field("picture")) + def handler(*, sender, file_name, **__): + nonlocal found_object + + # Users can now modify the object that picture_processed corresponds to + found_object = sender.model.objects.get(**{sender.name: file_name}) + + obj.picture.save("image.png", image_upload_file) + + assert obj == found_object diff --git a/tests/test_tasks.py b/tests/test_tasks.py index a3b655a..0cc908b 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -18,6 +18,7 @@ def test_process_picture__file_cannot_be_reopened(image_upload_file): tasks._process_picture( obj.picture.storage.deconstruct(), obj.picture.name, + obj.picture.sender, new=[i.deconstruct() for i in obj.picture.get_picture_files_list()], )