Repository: wagtail/wagtail (Python)

Issue:
Some Signal Handlers fail for Models with UUID Primary Keys
### Issue Summary

In some places, django-tasks is used now. A task cannot be enqueued with an uuid as an argument, because it is not json serializable.

A model with a UUID primary key might trigger signals, that enqueue tasks using the primary key of the affected instance and thereby crash.

I'm not 100% sure, if this is a wagtail or more a django-tasks issue.

### Steps to Reproduce

1. Register a model with a uuid primary key to the reference index and create or update an instance.

 I created a failing test for demonstration, see PR #12855 .


This bug potentially affects all places, where `instance.pk` is used as an argument to enqueue a task:

- wagtail.search.signal_handlers.post_save_signal_handler
- wagtail.images.signal_handlers.post_save_image_feature_detection
- wagtail.signal_handlers.update_reference_index_on_save

### Working on this

I'm not sure, what would be the best fix for this. Is there another solution than using a custom JSON encoder in django-tasks?

I guess, it would be more of a django-tasks issue then. But this is probably still a useful ticket for documentation purposes.

One must probably consider not using django-tasks in those handlers alternatively.


Discussion:
Thanks for the report @maqnius!

It looks like this can be fixed on Wagtail's side by passing `str(instance.pk)` instead of `instance.pk` in the call to `enqueue`. (I think it's a safe assumption that `model.objects.get(pk=some_pk)` will always be able to retrieve the instance when passed a string representation of the value - if that's not the case, you're going to have problems doing anything at all with URLs...)

As for whether `django-tasks` should be able to handle UUID arguments itself - [DEP 0014](https://github.com/django/deps/blob/main/accepted/0014-background-workers.rst#tasks) says

> Task arguments must be JSON serializable, to avoid compatibility and versioning issues. Complex arguments should be converted to a format which is JSON-serializable.

and while you could make a tenuous argument that `UUID` values _are_ JSON-serializable because [DjangoJSONEncoder](https://docs.djangoproject.com/en/5.1/topics/serialization/#djangojsonencoder) makes them that way, you could say the same thing about any arbitrary type that someone somewhere has written a custom encoder class for. The only sensible interpretation of "JSON-serializable" is "types that JSON supports natively", so I'd say `django-tasks` is correctly following the spec here (but @RealOrangeOne is welcome to make a call on that...)
`django-tasks` is doing exactly what it should here. The problem isn't the serialization - that's the easy bit. The problem is the deserialization. If a task receives the parameter `"ead589bb-5c7c-41b2-ab08-7a6f27f0006a"`, it doesn't (and can't) know whether that should be a `uuid.UUID` or kept as a string. Forcing the user to handle it themselves is annoying, sure, but it means there's no risk of surprises. 

See also https://github.com/RealOrangeOne/django-tasks/issues/134



Patch:
diff --git a/wagtail/images/signal_handlers.py b/wagtail/images/signal_handlers.py
index 7897fb83e310..7a0a3eb7404e 100644
--- a/wagtail/images/signal_handlers.py
+++ b/wagtail/images/signal_handlers.py
@@ -27,7 +27,7 @@ def post_save_image_feature_detection(instance, **kwargs):
         if kwargs["raw"] is False and not instance.has_focal_point():
             # Set the focal point
             set_image_focal_point_task.enqueue(
-                instance._meta.app_label, instance._meta.model_name, instance.pk
+                instance._meta.app_label, instance._meta.model_name, str(instance.pk)
             )
 
 
diff --git a/wagtail/search/signal_handlers.py b/wagtail/search/signal_handlers.py
index f471f00096b5..13ae2a487bcb 100644
--- a/wagtail/search/signal_handlers.py
+++ b/wagtail/search/signal_handlers.py
@@ -6,7 +6,7 @@
 
 def post_save_signal_handler(instance, **kwargs):
     insert_or_update_object_task.enqueue(
-        instance._meta.app_label, instance._meta.model_name, instance.pk
+        instance._meta.app_label, instance._meta.model_name, str(instance.pk)
     )
 
 
diff --git a/wagtail/signal_handlers.py b/wagtail/signal_handlers.py
index 90c5ca736a32..4c0a8f92f262 100644
--- a/wagtail/signal_handlers.py
+++ b/wagtail/signal_handlers.py
@@ -72,7 +72,7 @@ def update_reference_index_on_save(instance, **kwargs):
         return
 
     update_reference_index_task.enqueue(
-        instance._meta.app_label, instance._meta.model_name, instance.pk
+        instance._meta.app_label, instance._meta.model_name, str(instance.pk)
     )
 
 


Test patch:
diff --git a/wagtail/search/tests/test_index_functions.py b/wagtail/search/tests/test_index_functions.py
index 0f65d3f3ccb8..0bb2cde08b18 100644
--- a/wagtail/search/tests/test_index_functions.py
+++ b/wagtail/search/tests/test_index_functions.py
@@ -6,7 +6,7 @@
 from wagtail.models import Page
 from wagtail.search import index
 from wagtail.test.search import models
-from wagtail.test.testapp.models import SimplePage
+from wagtail.test.testapp.models import AdvertWithCustomUUIDPrimaryKey, SimplePage
 from wagtail.test.utils import WagtailTestUtils
 
 
@@ -166,6 +166,12 @@ def test_index_on_create(self, backend):
             )
         backend().add.assert_called_with(obj)
 
+    def test_index_on_create_with_uuid_primary_key(self, backend):
+        backend().reset_mock()
+        with self.captureOnCommitCallbacks(execute=True):
+            obj = AdvertWithCustomUUIDPrimaryKey.objects.create(text="Test")
+        backend().add.assert_called_with(obj)
+
     def test_index_on_update(self, backend):
         obj = models.Book.objects.create(
             title="Test", publication_date=date(2017, 10, 18), number_of_pages=100
@@ -180,6 +186,18 @@ def test_index_on_update(self, backend):
         indexed_object = backend().add.call_args[0][0]
         self.assertEqual(indexed_object.title, "Updated test")
 
+    def test_index_on_update_with_uuid_primary_key(self, backend):
+        obj = AdvertWithCustomUUIDPrimaryKey.objects.create(text="Test")
+
+        backend().reset_mock()
+        obj.text = "Updated test"
+        with self.captureOnCommitCallbacks(execute=True):
+            obj.save()
+
+        self.assertEqual(backend().add.call_count, 1)
+        indexed_object = backend().add.call_args[0][0]
+        self.assertEqual(indexed_object.text, "Updated test")
+
     def test_index_on_delete(self, backend):
         obj = models.Book.objects.create(
             title="Test", publication_date=date(2017, 10, 18), number_of_pages=100
@@ -190,6 +208,14 @@ def test_index_on_delete(self, backend):
             obj.delete()
         backend().delete.assert_called_with(obj)
 
+    def test_index_on_delete_with_uuid_primary_key(self, backend):
+        obj = AdvertWithCustomUUIDPrimaryKey.objects.create(text="Test")
+
+        backend().reset_mock()
+        with self.captureOnCommitCallbacks(execute=True):
+            obj.delete()
+        backend().delete.assert_called_with(obj)
+
     def test_do_not_index_fields_omitted_from_update_fields(self, backend):
         obj = models.Book.objects.create(
             title="Test", publication_date=date(2017, 10, 18), number_of_pages=100
diff --git a/wagtail/test/testapp/migrations/0047_advertwithcustomuuidprimarykey_page.py b/wagtail/test/testapp/migrations/0047_advertwithcustomuuidprimarykey_page.py
new file mode 100644
index 000000000000..c7541b20b01a
--- /dev/null
+++ b/wagtail/test/testapp/migrations/0047_advertwithcustomuuidprimarykey_page.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.1.5 on 2025-02-13 01:36
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("tests", "0046_alter_custom_rendition_to_unique_constraint"),
+        ("wagtailcore", "0094_alter_page_locale"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="advertwithcustomuuidprimarykey",
+            name="page",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                to="wagtailcore.page",
+            ),
+        ),
+    ]
diff --git a/wagtail/test/testapp/models.py b/wagtail/test/testapp/models.py
index 9a926bf5b116..1ccc78feea70 100644
--- a/wagtail/test/testapp/models.py
+++ b/wagtail/test/testapp/models.py
@@ -1006,14 +1006,20 @@ def __str__(self):
 register_snippet(AdvertWithCustomPrimaryKey)
 
 
-class AdvertWithCustomUUIDPrimaryKey(ClusterableModel):
+class AdvertWithCustomUUIDPrimaryKey(index.Indexed, ClusterableModel):
     advert_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     url = models.URLField(null=True, blank=True)
     text = models.CharField(max_length=255)
+    page = models.ForeignKey(Page, null=True, blank=True, on_delete=models.SET_NULL)
 
     panels = [
         FieldPanel("url"),
         FieldPanel("text"),
+        FieldPanel("page"),
+    ]
+
+    search_fields = [
+        index.SearchField("text"),
     ]
 
     def __str__(self):
diff --git a/wagtail/tests/test_reference_index.py b/wagtail/tests/test_reference_index.py
index 263bddb54e77..a3fc9d731ab9 100644
--- a/wagtail/tests/test_reference_index.py
+++ b/wagtail/tests/test_reference_index.py
@@ -250,6 +250,19 @@ def test_model_field_index_ignore_generic_foreign_key(self):
         refs = ReferenceIndex.get_references_to(content_type)
         self.assertEqual(refs.count(), 0)
 
+    def test_model_with_uuid_primary_key(self):
+        refs = ReferenceIndex.get_references_to(self.event_page)
+        self.assertEqual(refs.count(), 0)
+
+        with self.captureOnCommitCallbacks(execute=True):
+            AdvertWithCustomUUIDPrimaryKey.objects.create(
+                text="An advertisement",
+                page=self.event_page,
+            )
+
+        refs = ReferenceIndex.get_references_to(self.event_page)
+        self.assertEqual(refs.count(), 1)
+
     def test_rebuild_references_index_no_verbosity(self):
         stdout = StringIO()
         management.call_command(