From 210657b791fec359a9109b69e566018253edfad0 Mon Sep 17 00:00:00 2001 From: miigotu Date: Tue, 31 Mar 2020 12:12:39 +0200 Subject: [PATCH] Fixed #28184 -- Allowed using a callable for FileField and ImageField storage. --- AUTHORS | 1 + django/db/models/fields/files.py | 9 ++++++- docs/ref/models/fields.txt | 9 +++++-- docs/releases/3.1.txt | 5 ++++ docs/topics/files.txt | 28 ++++++++++++++++++++ tests/file_storage/models.py | 12 +++++++++ tests/file_storage/tests.py | 45 +++++++++++++++++++++++++++++++- 7 files changed, 105 insertions(+), 4 deletions(-) diff --git a/AUTHORS b/AUTHORS index d0b01a5eb2..41758bb38d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -268,6 +268,7 @@ answer newbie questions, and generally made Django that much better: Doug Napoleone dready dusk@woofle.net + Dustyn Gibson Ed Morley eibaan@gmail.com elky diff --git a/django/db/models/fields/files.py b/django/db/models/fields/files.py index 7ee38d937f..b682db414c 100644 --- a/django/db/models/fields/files.py +++ b/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) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 7c5401046b..7ca0b96d85 100644 --- a/docs/ref/models/fields.txt +++ b/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`. diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt index fd1bd4f40c..7df81329b4 100644 --- a/docs/releases/3.1.txt +++ b/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 ~~~~~~~~~~~~ diff --git a/docs/topics/files.txt b/docs/topics/files.txt index dbe9549818..73d0a11fff 100644 --- a/docs/topics/files.txt +++ b/docs/topics/files.txt @@ -202,3 +202,31 @@ For example, the following code will store uploaded files under :doc:`Custom storage systems ` 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) diff --git a/tests/file_storage/models.py b/tests/file_storage/models.py index 8085a8bb1a..9097f465dd 100644 --- a/tests/file_storage/models.py +++ b/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) diff --git a/tests/file_storage/tests.py b/tests/file_storage/tests.py index 9b41f5250d..a1d2bd372c 100644 --- a/tests/file_storage/tests.py +++ b/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.