Repository: wagtail/wagtail (Python)

Issue:
Saving a draft with an empty EmbedBlock causes a crash
<!--
Found a bug? Please fill out the sections below. 👍
-->

### Issue Summary

Starting with Wagtail 7.0, saving a page draft that contains an empty EmbedBlock causes Wagtail to crash

### Steps to Reproduce

1. Setup a fresh bakerydemo using Wagtail 7.0

2. Create a new Standard page as a child of the homepage.

3. In the Page body field add an Embed block but do not edit it

4. Click Safe draft

You'll get an Attribute Error: 'NoneType' object has no attribute 'encode'

location: wagtail/embeds/embeds.py, line 66, in get_embed_hash
  h = safe_md5(url.encode("utf-8"), usedforsecurity=False)

It won't crash if you downgrade to Wagtail 6.4.1.  You'll instead see a validation error message that the embed block's url field is required.

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes

### Technical details

-   Python version: 3.13.3
-   Django version: 5.2.1
-   Wagtail version: 7.0




Discussion:
Hi @jstamp, The issue is there and can be easily reproduced.
I've made fix for this in this [PR](https://github.com/wagtail/wagtail/pull/13141)



Patch:
diff --git a/wagtail/embeds/blocks.py b/wagtail/embeds/blocks.py
index 75cef37f494f..acb8d412b396 100644
--- a/wagtail/embeds/blocks.py
+++ b/wagtail/embeds/blocks.py
@@ -87,8 +87,11 @@ def clean(self, value):
 
     def normalize(self, value):
         if isinstance(value, EmbedValue):
-            return value
-        return EmbedValue(value)
+            return value if value.url else None
+        elif value:
+            return EmbedValue(value)
+        else:
+            return None
 
     class Meta:
         icon = "media"


Test patch:
diff --git a/wagtail/embeds/tests/test_embeds.py b/wagtail/embeds/tests/test_embeds.py
index 6173811c5bfd..f817dbedfea7 100644
--- a/wagtail/embeds/tests/test_embeds.py
+++ b/wagtail/embeds/tests/test_embeds.py
@@ -895,6 +895,24 @@ def test_catches_embed_not_found(self, get_embed):
 
 
 class TestEmbedBlock(TestCase):
+    def set_up_embed_response(self):
+        responses.get(
+            url="https://www.youtube.com/oembed",
+            match=[
+                matchers.query_param_matcher(
+                    {"url": "https://www.youtube.com/watch/", "format": "json"}
+                ),
+            ],
+            json={
+                "type": "something",
+                "url": "http://www.example.com",
+                "title": "test_title",
+                "width": "640",
+                "height": "480",
+                "html": "<h1>Hello world!</h1>",
+            },
+        )
+
     def test_deserialize(self):
         """
         Deserialising the JSONish value of an EmbedBlock (a URL) should give us an EmbedValue
@@ -920,12 +938,12 @@ def test_serialize(self):
         serialized_empty_val = block.get_prep_value(None)
         self.assertEqual(serialized_empty_val, "")
 
-    @patch("wagtail.embeds.embeds.get_embed")
-    def test_render(self, get_embed):
-        get_embed.return_value = Embed(html="<h1>Hello world!</h1>")
+    @responses.activate
+    def test_render(self):
+        self.set_up_embed_response()
 
         block = EmbedBlock()
-        block_val = block.to_python("http://www.example.com/foo")
+        block_val = block.to_python("https://www.youtube.com/watch/")
 
         temp = template.Template("embed: {{ embed }}")
         context = template.Context({"embed": block_val})
@@ -934,17 +952,14 @@ def test_render(self, get_embed):
         # Check that the embed was in the returned HTML
         self.assertIn("<h1>Hello world!</h1>", result)
 
-        # Check that get_embed was called correctly
-        get_embed.assert_any_call("http://www.example.com/foo", None, None)
-
-    @patch("wagtail.embeds.embeds.get_embed")
-    def test_render_within_structblock(self, get_embed):
+    @responses.activate
+    def test_render_within_structblock(self):
         """
         When rendering the value of an EmbedBlock directly in a template
         (as happens when accessing it as a child of a StructBlock), the
         proper embed output should be rendered, not the URL.
         """
-        get_embed.return_value = Embed(html="<h1>Hello world!</h1>")
+        self.set_up_embed_response()
 
         block = blocks.StructBlock(
             [
@@ -954,7 +969,7 @@ def test_render_within_structblock(self, get_embed):
         )
 
         block_val = block.to_python(
-            {"title": "A test", "embed": "http://www.example.com/foo"}
+            {"title": "A test", "embed": "https://www.youtube.com/watch/"}
         )
 
         temp = template.Template("embed: {{ self.embed }}")
@@ -963,9 +978,6 @@ def test_render_within_structblock(self, get_embed):
 
         self.assertIn("<h1>Hello world!</h1>", result)
 
-        # Check that get_embed was called correctly
-        get_embed.assert_any_call("http://www.example.com/foo", None, None)
-
     def test_value_from_form(self):
         """
         EmbedBlock should be able to turn a URL submitted as part of a form
@@ -1001,50 +1013,54 @@ def test_default(self):
         self.assertIsInstance(block5.get_default(), EmbedValue)
         self.assertEqual(block5.get_default().url, "http://www.example.com/foo")
 
-    @patch("wagtail.embeds.embeds.get_embed")
-    def test_clean_required(self, get_embed):
-        get_embed.return_value = Embed(html="<h1>Hello world!</h1>")
+    @responses.activate
+    def test_clean_required(self):
+        self.set_up_embed_response()
 
         block = EmbedBlock()
 
         cleaned_value = block.clean(
-            EmbedValue("https://www.youtube.com/watch?v=_U79Wc965vw")
+            block.normalize(EmbedValue("https://www.youtube.com/watch/"))
         )
         self.assertIsInstance(cleaned_value, EmbedValue)
-        self.assertEqual(
-            cleaned_value.url, "https://www.youtube.com/watch?v=_U79Wc965vw"
-        )
+        self.assertEqual(cleaned_value.url, "https://www.youtube.com/watch/")
 
         with self.assertRaisesMessage(ValidationError, ""):
-            block.clean(None)
+            block.clean(block.normalize(None))
 
-    @patch("wagtail.embeds.embeds.get_embed")
-    def test_clean_non_required(self, get_embed):
-        get_embed.return_value = Embed(html="<h1>Hello world!</h1>")
+    @responses.activate
+    def test_clean_non_required(self):
+        self.set_up_embed_response()
 
         block = EmbedBlock(required=False)
 
         cleaned_value = block.clean(
-            EmbedValue("https://www.youtube.com/watch?v=_U79Wc965vw")
+            block.normalize(EmbedValue("https://www.youtube.com/watch/"))
         )
         self.assertIsInstance(cleaned_value, EmbedValue)
-        self.assertEqual(
-            cleaned_value.url, "https://www.youtube.com/watch?v=_U79Wc965vw"
-        )
+        self.assertEqual(cleaned_value.url, "https://www.youtube.com/watch/")
 
-        cleaned_value = block.clean(None)
+        cleaned_value = block.clean(block.normalize(None))
         self.assertIsNone(cleaned_value)
 
-    @patch("wagtail.embeds.embeds.get_embed")
-    def test_clean_invalid_url(self, get_embed):
-        get_embed.side_effect = EmbedNotFoundException
+    @responses.activate
+    def test_clean_invalid_url(self):
+        # no response set up, so responses will raise a ConnectionError
 
         non_required_block = EmbedBlock(required=False)
 
         with self.assertRaises(ValidationError):
-            non_required_block.clean(EmbedValue("http://no-oembed-here.com/something"))
+            non_required_block.clean(
+                non_required_block.normalize(
+                    EmbedValue("http://no-oembed-here.com/something")
+                )
+            )
 
         required_block = EmbedBlock()
 
         with self.assertRaises(ValidationError):
-            required_block.clean(EmbedValue("http://no-oembed-here.com/something"))
+            required_block.clean(
+                non_required_block.normalize(
+                    EmbedValue("http://no-oembed-here.com/something")
+                )
+            )