From 81c616318b76b852928dce79b9d4511ee4b53dec Mon Sep 17 00:00:00 2001 From: James Meakin <12661555+jmsmkn@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:47:32 +0100 Subject: [PATCH 1/6] Add process_picture_done signal --- pictures/signals.py | 3 +++ pictures/tasks.py | 10 +++++++++- tests/test_signals.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 pictures/signals.py create mode 100644 tests/test_signals.py diff --git a/pictures/signals.py b/pictures/signals.py new file mode 100644 index 0000000..a9ea577 --- /dev/null +++ b/pictures/signals.py @@ -0,0 +1,3 @@ +import django.dispatch + +process_picture_done = django.dispatch.Signal() diff --git a/pictures/tasks.py b/pictures/tasks.py index a2bf1ba..4e70aa0 100644 --- a/pictures/tasks.py +++ b/pictures/tasks.py @@ -5,7 +5,7 @@ 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: @@ -41,6 +41,14 @@ def _process_picture( picture = utils.reconstruct(*picture) picture.delete() + signals.process_picture_done.send( + sender=_process_picture, + storage=storage.deconstruct(), + file_name=file_name, + new=new, + old=old, + ) + process_picture: PictureProcessor = _process_picture diff --git a/tests/test_signals.py b/tests/test_signals.py new file mode 100644 index 0000000..3a45dbf --- /dev/null +++ b/tests/test_signals.py @@ -0,0 +1,29 @@ +from unittest.mock import Mock + +import pytest + +from pictures import signals, tasks +from tests.testapp.models import SimpleModel + + +@pytest.mark.django_db +def test_process_picture_sends_process_picture_done(image_upload_file): + obj = SimpleModel.objects.create(picture=image_upload_file) + + handler = Mock() + signals.process_picture_done.connect(handler) + + tasks._process_picture( + obj.picture.storage.deconstruct(), + obj.picture.name, + new=[i.deconstruct() for i in obj.picture.get_picture_files_list()], + ) + + handler.assert_called_once_with( + signal=signals.process_picture_done, + sender=tasks._process_picture, + storage=obj.picture.storage.deconstruct(), + file_name=obj.picture.name, + new=[i.deconstruct() for i in obj.picture.get_picture_files_list()], + old=[], + ) From 2621125ca886ae72462164a920b1895c79e698c9 Mon Sep 17 00:00:00 2001 From: James Meakin <12661555+jmsmkn@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:25:50 +0100 Subject: [PATCH 2/6] Add field serialization --- pictures/models.py | 6 ++++++ pictures/tasks.py | 27 +++++++++++++++++++++++---- tests/test_signals.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/pictures/models.py b/pictures/models.py index 4b3f326..0d5fec3 100644 --- a/pictures/models.py +++ b/pictures/models.py @@ -157,6 +157,7 @@ def delete_all(self): self.name, [], [i.deconstruct() for i in self.get_picture_files_list()], + self.instance_name, ) def update_all(self, other: PictureFieldFile | None = None): @@ -171,8 +172,13 @@ def update_all(self, other: PictureFieldFile | None = None): self.name, [i.deconstruct() for i in new], [i.deconstruct() for i in old], + self.instance_name, ) + @property + def instance_name(self): + return f"{self.instance._meta.app_label}.{self.instance._meta.model_name}.{self.field.name}" + @property def width(self): self._require_file() diff --git a/pictures/tasks.py b/pictures/tasks.py index 4e70aa0..2693e3d 100644 --- a/pictures/tasks.py +++ b/pictures/tasks.py @@ -2,6 +2,7 @@ from typing import Protocol +from django.apps import apps from django.db import transaction from PIL import Image @@ -19,6 +20,7 @@ def __call__( file_name: str, new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, + field: str = "", ) -> None: ... @@ -27,6 +29,7 @@ def _process_picture( file_name: str, new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, + field: str = "", ) -> None: new = new or [] old = old or [] @@ -41,12 +44,19 @@ def _process_picture( picture = utils.reconstruct(*picture) picture.delete() + if field: + app_label, model_name, _ = field.split(".") + sender = apps.get_model(app_label=app_label, model_name=model_name) + else: + sender = _process_picture + signals.process_picture_done.send( - sender=_process_picture, + sender=sender, storage=storage.deconstruct(), file_name=file_name, new=new, old=old, + field=field, ) @@ -65,14 +75,16 @@ def process_picture_with_dramatiq( file_name: str, new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, + field: str = "", ) -> None: - _process_picture(storage, file_name, new, old) + _process_picture(storage, file_name, new, old, field) def process_picture( # noqa: F811 storage: tuple[str, list, dict], file_name: str, new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, + field: str = "", ) -> None: transaction.on_commit( lambda: process_picture_with_dramatiq.send( @@ -80,6 +92,7 @@ def process_picture( # noqa: F811 file_name=file_name, new=new, old=old, + field=field, ) ) @@ -99,14 +112,16 @@ def process_picture_with_celery( file_name: str, new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, + field: str = "", ) -> None: - _process_picture(storage, file_name, new, old) + _process_picture(storage, file_name, new, old, field) def process_picture( # noqa: F811 storage: tuple[str, list, dict], file_name: str, new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, + field: str = "", ) -> None: transaction.on_commit( lambda: process_picture_with_celery.apply_async( @@ -115,6 +130,7 @@ def process_picture( # noqa: F811 file_name=file_name, new=new, old=old, + field=field, ), queue=conf.get_settings().QUEUE_NAME, ) @@ -133,14 +149,16 @@ def process_picture_with_django_rq( file_name: str, new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, + field: str = "", ) -> None: - _process_picture(storage, file_name, new, old) + _process_picture(storage, file_name, new, old, field) def process_picture( # noqa: F811 storage: tuple[str, list, dict], file_name: str, new: list[tuple[str, list, dict]] | None = None, old: list[tuple[str, list, dict]] | None = None, + field: str = "", ) -> None: transaction.on_commit( lambda: process_picture_with_django_rq.delay( @@ -148,5 +166,6 @@ def process_picture( # noqa: F811 file_name=file_name, new=new, old=old, + field=field, ) ) diff --git a/tests/test_signals.py b/tests/test_signals.py index 3a45dbf..93abef2 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -1,6 +1,8 @@ from unittest.mock import Mock import pytest +from django.apps import apps +from django.dispatch import receiver from pictures import signals, tasks from tests.testapp.models import SimpleModel @@ -26,4 +28,44 @@ def test_process_picture_sends_process_picture_done(image_upload_file): file_name=obj.picture.name, new=[i.deconstruct() for i in obj.picture.get_picture_files_list()], old=[], + field="", ) + + +@pytest.mark.django_db +def test_process_picture_sends_process_picture_done_on_create(image_upload_file): + handler = Mock() + signals.process_picture_done.connect(handler) + + obj = SimpleModel.objects.create(picture=image_upload_file) + + handler.assert_called_once_with( + signal=signals.process_picture_done, + sender=SimpleModel, + storage=obj.picture.storage.deconstruct(), + file_name=obj.picture.name, + new=[i.deconstruct() for i in obj.picture.get_picture_files_list()], + old=[], + field="testapp.simplemodel.picture", + ) + + +@pytest.mark.django_db +def test_processed_object_found(image_upload_file): + obj = SimpleModel.objects.create() + + found_object = None + + @receiver(signals.process_picture_done, sender=SimpleModel) + def handler(*, file_name, field, **__): + nonlocal found_object + app_label, model_name, field_name = field.split(".") + model = apps.get_model(app_label=app_label, model_name=model_name) + + # Users can now modify the object that process_picture_done + # corresponds to + found_object = model.objects.get(**{field_name: file_name}) + + obj.picture.save("image.png", image_upload_file) + + assert obj == found_object From 1d6b82ec5fc3f06d870c82f0da97e3e890f917ea Mon Sep 17 00:00:00 2001 From: James Meakin <12661555+jmsmkn@users.noreply.github.com> Date: Fri, 28 Nov 2025 18:05:26 +0100 Subject: [PATCH 3/6] Implement review suggestions --- pictures/models.py | 12 ++++++++---- pictures/signals.py | 2 +- pictures/tasks.py | 42 +++++++++++++++++++----------------------- tests/test_signals.py | 29 +++++++++++------------------ tests/test_tasks.py | 1 + 5 files changed, 40 insertions(+), 46 deletions(-) diff --git a/pictures/models.py b/pictures/models.py index 0d5fec3..1c829b2 100644 --- a/pictures/models.py +++ b/pictures/models.py @@ -155,9 +155,9 @@ 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()], - self.instance_name, ) def update_all(self, other: PictureFieldFile | None = None): @@ -170,14 +170,18 @@ 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], - self.instance_name, ) @property - def instance_name(self): - return f"{self.instance._meta.app_label}.{self.instance._meta.model_name}.{self.field.name}" + def sender(self): + return ( + self.instance._meta.app_label, + self.instance._meta.model_name, + self.field.name, + ) @property def width(self): diff --git a/pictures/signals.py b/pictures/signals.py index a9ea577..25e3835 100644 --- a/pictures/signals.py +++ b/pictures/signals.py @@ -1,3 +1,3 @@ import django.dispatch -process_picture_done = django.dispatch.Signal() +picture_processed = django.dispatch.Signal() diff --git a/pictures/tasks.py b/pictures/tasks.py index 2693e3d..f236a2d 100644 --- a/pictures/tasks.py +++ b/pictures/tasks.py @@ -18,18 +18,18 @@ 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, - field: str = "", ) -> None: ... 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, - field: str = "", ) -> None: new = new or [] old = old or [] @@ -44,19 +44,15 @@ def _process_picture( picture = utils.reconstruct(*picture) picture.delete() - if field: - app_label, model_name, _ = field.split(".") - sender = apps.get_model(app_label=app_label, model_name=model_name) - else: - sender = _process_picture + 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.process_picture_done.send( - sender=sender, - storage=storage.deconstruct(), + signals.picture_processed.send( + sender=field, file_name=file_name, new=new, old=old, - field=field, ) @@ -73,26 +69,26 @@ 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, - field: str = "", ) -> None: - _process_picture(storage, file_name, new, old, field) + _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, - field: str = "", ) -> None: transaction.on_commit( lambda: process_picture_with_dramatiq.send( storage=storage, file_name=file_name, + sender=sender, new=new, old=old, - field=field, ) ) @@ -110,27 +106,27 @@ 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, - field: str = "", ) -> None: - _process_picture(storage, file_name, new, old, field) + _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, - field: str = "", ) -> None: transaction.on_commit( lambda: process_picture_with_celery.apply_async( kwargs=dict( storage=storage, file_name=file_name, + sender=sender, new=new, old=old, - field=field, ), queue=conf.get_settings().QUEUE_NAME, ) @@ -147,25 +143,25 @@ 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, - field: str = "", ) -> None: - _process_picture(storage, file_name, new, old, field) + _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, - field: str = "", ) -> None: transaction.on_commit( lambda: process_picture_with_django_rq.delay( storage=storage, file_name=file_name, + sender=sender, new=new, old=old, - field=field, ) ) diff --git a/tests/test_signals.py b/tests/test_signals.py index 93abef2..14a2322 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -1,7 +1,6 @@ from unittest.mock import Mock import pytest -from django.apps import apps from django.dispatch import receiver from pictures import signals, tasks @@ -13,40 +12,37 @@ def test_process_picture_sends_process_picture_done(image_upload_file): obj = SimpleModel.objects.create(picture=image_upload_file) handler = Mock() - signals.process_picture_done.connect(handler) + 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.process_picture_done, - sender=tasks._process_picture, - storage=obj.picture.storage.deconstruct(), + 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=[], - field="", ) @pytest.mark.django_db def test_process_picture_sends_process_picture_done_on_create(image_upload_file): handler = Mock() - signals.process_picture_done.connect(handler) + signals.picture_processed.connect(handler) obj = SimpleModel.objects.create(picture=image_upload_file) handler.assert_called_once_with( - signal=signals.process_picture_done, - sender=SimpleModel, - storage=obj.picture.storage.deconstruct(), + 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=[], - field="testapp.simplemodel.picture", ) @@ -56,15 +52,12 @@ def test_processed_object_found(image_upload_file): found_object = None - @receiver(signals.process_picture_done, sender=SimpleModel) - def handler(*, file_name, field, **__): + @receiver(signals.picture_processed, sender=SimpleModel._meta.get_field("picture")) + def handler(*, sender, file_name, **__): nonlocal found_object - app_label, model_name, field_name = field.split(".") - model = apps.get_model(app_label=app_label, model_name=model_name) - # Users can now modify the object that process_picture_done - # corresponds to - found_object = model.objects.get(**{field_name: file_name}) + # 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) 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()], ) From cafec7eb2f4fabf7d460fc36ad5834ff36758afb Mon Sep 17 00:00:00 2001 From: James Meakin <12661555+jmsmkn@users.noreply.github.com> Date: Fri, 28 Nov 2025 18:26:38 +0100 Subject: [PATCH 4/6] Fix tests --- tests/test_migrations.py | 3 +++ 1 file changed, 3 insertions(+) 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 ): From 67a476b6a6628e69d6ec59e3d8e7c0bbd23b39a6 Mon Sep 17 00:00:00 2001 From: James Meakin <12661555+jmsmkn@users.noreply.github.com> Date: Fri, 28 Nov 2025 18:36:20 +0100 Subject: [PATCH 5/6] Add example to README --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) 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: From ed11b97e01b51372954606a81bce7771f00ac8cf Mon Sep 17 00:00:00 2001 From: James Meakin <12661555+jmsmkn@users.noreply.github.com> Date: Fri, 28 Nov 2025 18:41:32 +0100 Subject: [PATCH 6/6] Handler will not be called without async task being run --- tests/test_signals.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_signals.py b/tests/test_signals.py index 14a2322..f64de12 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -4,10 +4,12 @@ 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) @@ -31,6 +33,7 @@ def test_process_picture_sends_process_picture_done(image_upload_file): @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) @@ -47,6 +50,7 @@ def test_process_picture_sends_process_picture_done_on_create(image_upload_file) @pytest.mark.django_db +@skip_dramatiq def test_processed_object_found(image_upload_file): obj = SimpleModel.objects.create()