Browse Source

Fixed #28184 -- Allowed using a callable for FileField and ImageField storage.

miigotu 5 years ago
parent
commit
210657b791

+ 1 - 0
AUTHORS

@@ -268,6 +268,7 @@ answer newbie questions, and generally made Django that much better:
     Doug Napoleone <doug@dougma.com>
     dready <wil@mojipage.com>
     dusk@woofle.net
+    Dustyn Gibson <miigotu@gmail.com>
     Ed Morley <https://github.com/edmorley>
     eibaan@gmail.com
     elky <http://elky.me/>

+ 8 - 1
django/db/models/fields/files.py

@@ -5,7 +5,7 @@ from django import forms
 from django.core import checks
 from django.core.files.base import File
 from django.core.files.images import ImageFile
-from django.core.files.storage import default_storage
+from django.core.files.storage import Storage, default_storage
 from django.db.models import signals
 from django.db.models.fields import Field
 from django.utils.translation import gettext_lazy as _
@@ -234,6 +234,13 @@ class FileField(Field):
         self._primary_key_set_explicitly = 'primary_key' in kwargs
 
         self.storage = storage or default_storage
+        if callable(self.storage):
+            self.storage = self.storage()
+            if not isinstance(self.storage, Storage):
+                raise TypeError(
+                    "%s.storage must be a subclass/instance of %s.%s"
+                    % (self.__class__.__qualname__, Storage.__module__, Storage.__qualname__)
+                )
         self.upload_to = upload_to
 
         kwargs.setdefault('max_length', 100)

+ 7 - 2
docs/ref/models/fields.txt

@@ -822,8 +822,13 @@ Has two optional arguments:
 
 .. attribute:: FileField.storage
 
-    A storage object, which handles the storage and retrieval of your
-    files. See :doc:`/topics/files` for details on how to provide this object.
+    A storage object, or a callable which returns a storage object. This
+    handles the storage and retrieval of your files. See :doc:`/topics/files`
+    for details on how to provide this object.
+
+    .. versionchanged:: 3.1
+
+        The ability to provide a callable was added.
 
 The default form widget for this field is a
 :class:`~django.forms.ClearableFileInput`.

+ 5 - 0
docs/releases/3.1.txt

@@ -248,6 +248,11 @@ File Storage
 
 * ``FileSystemStorage.save()`` method now supports :class:`pathlib.Path`.
 
+* :class:`~django.db.models.FileField` and
+  :class:`~django.db.models.ImageField` now accept a callable for ``storage``.
+  This allows you to modify the used storage at runtime, selecting different
+  storages for different environments, for example.
+
 File Uploads
 ~~~~~~~~~~~~
 

+ 28 - 0
docs/topics/files.txt

@@ -202,3 +202,31 @@ For example, the following code will store uploaded files under
 :doc:`Custom storage systems </howto/custom-file-storage>` work the same way:
 you can pass them in as the ``storage`` argument to a
 :class:`~django.db.models.FileField`.
+
+Using a callable
+----------------
+
+.. versionadded:: 3.1
+
+You can use a callable as the :attr:`~django.db.models.FileField.storage`
+parameter for :class:`~django.db.models.FileField` or
+:class:`~django.db.models.ImageField`. This allows you to modify the used
+storage at runtime, selecting different storages for different environments,
+for example.
+
+Your callable will be evaluated when your models classes are loaded, and must
+return an instance of :class:`~django.core.files.storage.Storage`.
+
+For example::
+
+    from django.conf import settings
+    from django.db import models
+    from .storages import MyLocalStorage, MyRemoteStorage
+
+
+    def select_storage():
+        return MyLocalStorage() if settings.DEBUG else MyRemoteStorage()
+
+
+    class MyModel(models.Model):
+        my_file = models.FileField(storage=select_storage)

+ 12 - 0
tests/file_storage/models.py

@@ -23,6 +23,16 @@ temp_storage_location = tempfile.mkdtemp()
 temp_storage = FileSystemStorage(location=temp_storage_location)
 
 
+def callable_storage():
+    return temp_storage
+
+
+class CallableStorage(FileSystemStorage):
+    def __call__(self):
+        # no-op implementation.
+        return self
+
+
 class Storage(models.Model):
     def custom_upload_to(self, filename):
         return 'foo'
@@ -44,6 +54,8 @@ class Storage(models.Model):
         storage=CustomValidNameStorage(location=temp_storage_location),
         upload_to=random_upload_to,
     )
+    storage_callable = models.FileField(storage=callable_storage, upload_to='storage_callable')
+    storage_callable_class = models.FileField(storage=CallableStorage, upload_to='storage_callable_class')
     default = models.FileField(storage=temp_storage, upload_to='tests', default='tests/default.txt')
     empty = models.FileField(storage=temp_storage)
     limited_length = models.FileField(storage=temp_storage, upload_to='tests', max_length=20)

+ 44 - 1
tests/file_storage/tests.py

@@ -13,10 +13,13 @@ from urllib.request import urlopen
 from django.core.cache import cache
 from django.core.exceptions import SuspiciousFileOperation
 from django.core.files.base import ContentFile, File
-from django.core.files.storage import FileSystemStorage, get_storage_class
+from django.core.files.storage import (
+    FileSystemStorage, Storage as BaseStorage, get_storage_class,
+)
 from django.core.files.uploadedfile import (
     InMemoryUploadedFile, SimpleUploadedFile, TemporaryUploadedFile,
 )
+from django.db.models import FileField
 from django.db.models.fields.files import FileDescriptor
 from django.test import (
     LiveServerTestCase, SimpleTestCase, TestCase, override_settings,
@@ -866,6 +869,46 @@ class FileFieldStorageTests(TestCase):
             self.assertEqual(f.read(), b'content')
 
 
+class FieldCallableFileStorageTests(SimpleTestCase):
+    def setUp(self):
+        self.temp_storage_location = tempfile.mkdtemp(suffix='filefield_callable_storage')
+
+    def tearDown(self):
+        shutil.rmtree(self.temp_storage_location)
+
+    def test_callable_base_class_error_raises(self):
+        class NotStorage:
+            pass
+        msg = 'FileField.storage must be a subclass/instance of django.core.files.storage.Storage'
+        for invalid_type in (NotStorage, str, list, set, tuple):
+            with self.subTest(invalid_type=invalid_type):
+                with self.assertRaisesMessage(TypeError, msg):
+                    FileField(storage=invalid_type)
+
+    def test_callable_function_storage_file_field(self):
+        storage = FileSystemStorage(location=self.temp_storage_location)
+
+        def get_storage():
+            return storage
+
+        obj = FileField(storage=get_storage)
+        self.assertEqual(obj.storage, storage)
+        self.assertEqual(obj.storage.location, storage.location)
+
+    def test_callable_class_storage_file_field(self):
+        class GetStorage(FileSystemStorage):
+            pass
+
+        obj = FileField(storage=GetStorage)
+        self.assertIsInstance(obj.storage, BaseStorage)
+
+    def test_callable_storage_file_field_in_model(self):
+        obj = Storage()
+        self.assertEqual(obj.storage_callable.storage, temp_storage)
+        self.assertEqual(obj.storage_callable.storage.location, temp_storage_location)
+        self.assertIsInstance(obj.storage_callable_class.storage, BaseStorage)
+
+
 # Tests for a race condition on file saving (#4948).
 # This is written in such a way that it'll always pass on platforms
 # without threading.