Repository: wagtail/wagtail

Issue:
Allow specifying an alternative storage backend for image renditions
The `file` field of AbstractRendition doesn't specify a `storage` parameter, so it will be fixed as Django's DEFAULT_FILE_STORAGE setting. We should make this a configuration setting (suggested: `WAGTAILIMAGES_RENDITION_STORAGE`, defaulting to DEFAULT_FILE_STORAGE).

This would allow renditions to be stored in a distinct filesystem location from other uploaded files, outside of MEDIA_ROOT - for example, a `/public` folder configured at the webserver level. (It would probably also give us a more sensible setup for hosting images on services such as S3 - that way the original images would still exist locally, so we wouldn't need a download-then-upload round trip when generating renditions.)


Discussion:
Marking this as a good first issue.

* wagtail/images/models.py contains the `AbstractRendition`
* create & document a new images setting `WAGTAILIMAGES_RENDITION_STORAGE` in docs/reference/settings.rst
* Needs unit tests

Resolving patch:
diff --git a/wagtail/images/models.py b/wagtail/images/models.py
--- a/wagtail/images/models.py
+++ b/wagtail/images/models.py
@@ -10,10 +10,12 @@
 from django.core import checks
 from django.core.cache import InvalidCacheBackendError, caches
 from django.core.files import File
+from django.core.files.storage import default_storage
 from django.db import models
 from django.forms.utils import flatatt
 from django.urls import reverse
 from django.utils.functional import cached_property
+from django.utils.module_loading import import_string
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from taggit.managers import TaggableManager
@@ -70,6 +72,21 @@ def get_rendition_upload_to(instance, filename):
     return instance.get_upload_to(filename)
 
 
+def get_rendition_storage():
+    """
+    Obtain the storage object for an image rendition file.
+    Returns custom storage (if defined), or the default storage.
+
+    This needs to be a module-level function, because we do not yet
+    have an instance when Django loads the models.
+    """
+    storage = getattr(settings, "WAGTAILIMAGES_RENDITION_STORAGE", default_storage)
+    if isinstance(storage, str):
+        module = import_string(storage)
+        storage = module()
+    return storage
+
+
 class ImageFileMixin:
     def is_stored_locally(self):
         """
@@ -597,7 +614,10 @@ def get_cache_key(self, image):
 class AbstractRendition(ImageFileMixin, models.Model):
     filter_spec = models.CharField(max_length=255, db_index=True)
     file = models.ImageField(
-        upload_to=get_rendition_upload_to, width_field="width", height_field="height"
+        upload_to=get_rendition_upload_to,
+        storage=get_rendition_storage,
+        width_field="width",
+        height_field="height",
     )
     width = models.IntegerField(editable=False)
     height = models.IntegerField(editable=False)


Test patch:
diff --git a/wagtail/images/tests/test_models.py b/wagtail/images/tests/test_models.py
--- a/wagtail/images/tests/test_models.py
+++ b/wagtail/images/tests/test_models.py
@@ -3,6 +3,7 @@
 from django.contrib.auth.models import Group, Permission
 from django.core.cache import caches
 from django.core.files import File
+from django.core.files.storage import DefaultStorage, Storage
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.db.utils import IntegrityError
 from django.test import TestCase
@@ -10,7 +11,7 @@
 from django.urls import reverse
 from willow.image import Image as WillowImage
 
-from wagtail.images.models import Rendition, SourceImageIOError
+from wagtail.images.models import Rendition, SourceImageIOError, get_rendition_storage
 from wagtail.images.rect import Rect
 from wagtail.models import Collection, GroupCollectionPermission, Page
 from wagtail.test.testapp.models import (
@@ -23,6 +24,11 @@
 from .utils import Image, get_test_image_file
 
 
+class CustomStorage(Storage):
+    def __init__(self):
+        super().__init__()
+
+
 class TestImage(TestCase):
     def setUp(self):
         # Create an image for running tests on
@@ -346,6 +352,40 @@ def test_background_position_style_default(self):
             rendition.background_position_style, "background-position: 50% 50%;"
         )
 
+    def test_custom_rendition_backend_setting(self):
+        """
+        Test the usage of WAGTAILIMAGES_RENDITION_STORAGE setting.
+        """
+        # when setting is not set, instance.get_storage() returns DefaultStorage
+        from django.conf import settings
+
+        bkp = settings
+
+        self.assertFalse(hasattr(settings, "WAGTAILIMAGES_RENDITION_STORAGE"))
+        rendition1 = self.image.get_rendition("min-120x120")
+        self.assertIsInstance(rendition1.image.file.storage, DefaultStorage)
+
+        # when setting is set to a path
+        setattr(
+            settings,
+            "WAGTAILIMAGES_RENDITION_STORAGE",
+            "wagtail.images.tests.test_models.CustomStorage",
+        )
+        backend = get_rendition_storage()
+        self.assertIsInstance(backend, CustomStorage)
+
+        # when setting is set directly, get_rendition_storage() returns the custom storage backend
+        class CustomStorage2(Storage):
+            def __init__(self):
+                super().__init__()
+
+        setattr(settings, "WAGTAILIMAGES_RENDITION_STORAGE", CustomStorage2())
+        backend = get_rendition_storage()
+        self.assertIsInstance(backend, CustomStorage2)
+
+        # clean up
+        settings = bkp
+
 
 class TestUsageCount(TestCase):
     fixtures = ["test.json"]