Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed #28184 -- Allowed passing a callable storage to FileField. #8477

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Expand Up @@ -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/>
Expand Down
9 changes: 8 additions & 1 deletion django/db/models/fields/files.py
Expand Up @@ -5,7 +5,7 @@
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 _
Expand Down Expand Up @@ -234,6 +234,13 @@ def __init__(self, verbose_name=None, name=None, upload_to='', storage=None, **k
self._primary_key_set_explicitly = 'primary_key' in kwargs

self.storage = storage or default_storage
if callable(self.storage):
self.storage = self.storage()
miigotu marked this conversation as resolved.
Show resolved Hide resolved
miigotu marked this conversation as resolved.
Show resolved Hide resolved
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)
Expand Down
9 changes: 7 additions & 2 deletions docs/ref/models/fields.txt
Expand Up @@ -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.
carltongibson marked this conversation as resolved.
Show resolved Hide resolved

.. versionchanged:: 3.1

The ability to provide a callable was added.

The default form widget for this field is a
:class:`~django.forms.ClearableFileInput`.
Expand Down
5 changes: 5 additions & 0 deletions docs/releases/3.1.txt
Expand Up @@ -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
~~~~~~~~~~~~

Expand Down
28 changes: 28 additions & 0 deletions docs/topics/files.txt
Expand Up @@ -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
----------------

carltongibson marked this conversation as resolved.
Show resolved Hide resolved
.. 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
carltongibson marked this conversation as resolved.
Show resolved Hide resolved
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 changes: 12 additions & 0 deletions tests/file_storage/models.py
Expand Up @@ -23,6 +23,16 @@ def get_valid_name(self, name):
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'
Expand All @@ -44,6 +54,8 @@ def pathlib_upload_to(self, filename):
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)
Expand Down
49 changes: 48 additions & 1 deletion tests/file_storage/tests.py
Expand Up @@ -13,10 +13,14 @@
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, default_storage,
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,
Expand Down Expand Up @@ -866,6 +870,49 @@ def test_stringio(self):
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_file_field_storage_none_uses_default_storage(self):
self.assertEqual(FileField().storage, default_storage)

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.
Expand Down