Repository: wagtail/wagtail

Issue:
Uncaught IOError when a rich text area contains an embedded image with a missing file
ref: https://groups.google.com/d/msg/wagtail/zsCfTu5RMck/97tvyY8hzBAJ

When displaying images via the `{% image %}` tag, we catch IOErrors that occur due a missing file in `original_images`, and output '/media/not-found' in place of the real URL. This ensures that whenever a database from a live site is installed on a development machine without downloading the corresponding image files, we're still able to render the page, albeit with a broken image.

However, there is currently no equivalent error-trapping for images that are embedded in a rich text area. This results in the IOError being left uncaught, and the page failing to render.

Uncaught IOError when a rich text area contains an embedded image with a missing file
ref: https://groups.google.com/d/msg/wagtail/zsCfTu5RMck/97tvyY8hzBAJ

When displaying images via the `{% image %}` tag, we catch IOErrors that occur due a missing file in `original_images`, and output '/media/not-found' in place of the real URL. This ensures that whenever a database from a live site is installed on a development machine without downloading the corresponding image files, we're still able to render the page, albeit with a broken image.

However, there is currently no equivalent error-trapping for images that are embedded in a rich text area. This results in the IOError being left uncaught, and the page failing to render.



Discussion:

Resolving patch:
diff --git a/wagtail/wagtailimages/formats.py b/wagtail/wagtailimages/formats.py
--- a/wagtail/wagtailimages/formats.py
+++ b/wagtail/wagtailimages/formats.py
@@ -1,6 +1,7 @@
 from django.utils.html import escape
 
 from wagtail.utils.apps import get_app_submodules
+from wagtail.wagtailimages.models import SourceImageIOError
 
 
 class Format(object):
@@ -25,7 +26,15 @@ def image_to_editor_html(self, image, alt_text):
         )
 
     def image_to_html(self, image, alt_text, extra_attributes=''):
-        rendition = image.get_rendition(self.filter_spec)
+        try:
+            rendition = image.get_rendition(self.filter_spec)
+        except SourceImageIOError:
+            # Image file is (probably) missing from /media/original_images - generate a dummy
+            # rendition so that we just output a broken image, rather than crashing out completely
+            # during rendering
+            Rendition = image.renditions.model  # pick up any custom Image / Rendition classes that may be in use
+            rendition = Rendition(image=image, width=0, height=0)
+            rendition.file.name = 'not-found'
 
         if self.classnames:
             class_attr = 'class="%s" ' % escape(self.classnames)
diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py
--- a/wagtail/wagtailimages/models.py
+++ b/wagtail/wagtailimages/models.py
@@ -1,7 +1,7 @@
 import os.path
 import re
 
-from six import BytesIO
+from six import BytesIO, text_type
 
 from taggit.managers import TaggableManager
 
@@ -28,6 +28,13 @@
 from wagtail.wagtailadmin.utils import get_object_usage
 
 
+class SourceImageIOError(IOError):
+    """
+    Custom exception to distinguish IOErrors that were thrown while opening the source image
+    """
+    pass
+
+
 def get_upload_to(instance, filename):
     folder_name = 'original_images'
     filename = instance.file.field.storage.get_valid_name(filename)
@@ -178,7 +185,15 @@ def get_rendition(self, filter):
             # If we have a backend attribute then pass it to process
             # image - else pass 'default'
             backend_name = getattr(self, 'backend', 'default')
-            generated_image = filter.process_image(file_field.file, backend_name=backend_name, focal_point=self.get_focal_point())
+
+            try:
+                image_file = file_field.file  # triggers a call to self.storage.open, so IOErrors from missing files will be raised at this point
+            except IOError as e:
+                # re-throw this as a SourceImageIOError so that calling code can distinguish
+                # these from IOErrors elsewhere in the process
+                raise SourceImageIOError(text_type(e))
+
+            generated_image = filter.process_image(image_file, backend_name=backend_name, focal_point=self.get_focal_point())
 
             # generate new filename derived from old one, inserting the filter spec and focal point key before the extension
             if self.has_focal_point():
diff --git a/wagtail/wagtailimages/templatetags/wagtailimages_tags.py b/wagtail/wagtailimages/templatetags/wagtailimages_tags.py
--- a/wagtail/wagtailimages/templatetags/wagtailimages_tags.py
+++ b/wagtail/wagtailimages/templatetags/wagtailimages_tags.py
@@ -1,6 +1,6 @@
 from django import template
 
-from wagtail.wagtailimages.models import Filter
+from wagtail.wagtailimages.models import Filter, SourceImageIOError
 
 register = template.Library()
 
@@ -53,10 +53,10 @@ def render(self, context):
 
         try:
             rendition = image.get_rendition(self.filter)
-        except IOError:
+        except SourceImageIOError:
             # It's fairly routine for people to pull down remote databases to their
             # local dev versions without retrieving the corresponding image files.
-            # In such a case, we would get an IOError at the point where we try to
+            # In such a case, we would get a SourceImageIOError at the point where we try to
             # create the resized version of a non-existent image. Since this is a
             # bit catastrophic for a missing image, we'll substitute a dummy
             # Rendition object so that we just output a broken link instead.


Test patch:
diff --git a/wagtail/tests/fixtures/test.json b/wagtail/tests/fixtures/test.json
--- a/wagtail/tests/fixtures/test.json
+++ b/wagtail/tests/fixtures/test.json
@@ -81,7 +81,8 @@
         "audience": "public",
         "location": "The North Pole",
         "body": "<p>Chestnuts roasting on an open fire</p>",
-        "cost": "Free"
+        "cost": "Free",
+        "feed_image": 1
     }
 },
 
@@ -223,7 +224,7 @@
         "date_from": "2015-04-22",
         "audience": "public",
         "location": "Ameristralia",
-        "body": "<p>come celebrate the independence of Ameristralia</p>",
+        "body": "<p>come celebrate the independence of Ameristralia <embed embedtype=\"image\" format=\"fullwidth\" id=\"1\" alt=\"where did my image go?\" /></p>",
         "cost": "Free"
     }
 },
@@ -625,5 +626,16 @@
         "page": 11,
         "password": "swordfish"
     }
+},
+{
+    "pk": 1,
+    "model": "wagtailimages.image",
+    "fields": {
+        "title": "A missing image",
+        "file": "original_images/missing.jpg",
+        "width": 1000,
+        "height": 1000,
+        "created_at": "2014-01-01T12:00:00.000Z"
+    }
 }
 ]
diff --git a/wagtail/tests/templates/tests/event_page.html b/wagtail/tests/templates/tests/event_page.html
--- a/wagtail/tests/templates/tests/event_page.html
+++ b/wagtail/tests/templates/tests/event_page.html
@@ -1,4 +1,4 @@
-{% load wagtailcore_tags %}
+{% load wagtailcore_tags wagtailimages_tags %}
 
 <!DOCTYPE HTML>
 <html>
@@ -8,6 +8,10 @@
     <body>
         <h1>{{ self.title }}</h1>
         <h2>Event</h2>
+        {% if self.feed_image %}
+            {% image self.feed_image width-200 class="feed-image" %}
+        {% endif %}
+        {{ self.body|richtext }}
         <p><a href="{% slugurl 'events' %}">Back to events index</a></p>
     </body>
 </html>
diff --git a/wagtail/wagtailimages/tests/tests.py b/wagtail/wagtailimages/tests/tests.py
--- a/wagtail/wagtailimages/tests/tests.py
+++ b/wagtail/wagtailimages/tests/tests.py
@@ -63,6 +63,25 @@ def test_image_tag_with_extra_attributes(self):
         self.assertTrue('title="my wonderful title"' in result)
 
 
+class TestMissingImage(TestCase):
+    """
+    Missing image files in media/original_images should be handled gracefully, to cope with
+    pulling live databases to a development instance without copying the corresponding image files.
+    In this case, it's acceptable to render broken images, but not to fail rendering the page outright.
+    """
+    fixtures = ['test.json']
+
+    def test_image_tag_with_missing_image(self):
+        # the page /events/christmas/ has a missing image as the feed image
+        response = self.client.get('/events/christmas/')
+        self.assertContains(response, '<img src="/media/not-found" width="0" height="0" alt="A missing image" class="feed-image">', html=True)
+
+    def test_rich_text_with_missing_image(self):
+        # the page /events/final-event/ has a missing image in the rich text body
+        response = self.client.get('/events/final-event/')
+        self.assertContains(response, '<img class="richtext-image full-width" src="/media/not-found" width="0" height="0" alt="where did my image go?">', html=True)
+
+
 class TestFormat(TestCase):
     def setUp(self):
         # test format