Skip to content

Commit

Permalink
Fixed #28184 -- Allowed using callable as FileField and ImageField st…
Browse files Browse the repository at this point in the history
…orage.
  • Loading branch information
miigotu authored and carltongibson committed Mar 31, 2020
1 parent 4216225 commit e74c9d0
Show file tree
Hide file tree
Showing 7 changed files with 129 additions and 5 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Expand Up @@ -267,6 +267,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
12 changes: 11 additions & 1 deletion django/db/models/fields/files.py
Expand Up @@ -3,9 +3,10 @@

from django import forms
from django.core import checks
from django.core.exceptions import ImproperlyConfigured
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 +235,15 @@ 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()
if not isinstance(self.storage, Storage):
raise ImproperlyConfigured(
"%s.storage must be a subclass/instance of %s.%s"
% (self.__class__.__name__, Storage.__module__, Storage.__name__)
)

self.upload_to = upload_to

kwargs.setdefault('max_length', 100)
Expand Down
5 changes: 3 additions & 2 deletions docs/ref/models/fields.txt
Expand Up @@ -822,8 +822,9 @@ 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.

The default form widget for this field is a
:class:`~django.forms.ClearableFileInput`.
Expand Down
6 changes: 6 additions & 0 deletions docs/releases/3.1.txt
Expand Up @@ -248,6 +248,12 @@ File Storage

* ``FileSystemStorage.save()`` method now supports :class:`pathlib.Path`.

* Added the ability to pass a callable as the storage parameter of a
: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 development and
production environments, for example.

File Uploads
~~~~~~~~~~~~

Expand Down
42 changes: 42 additions & 0 deletions docs/topics/files.txt
Expand Up @@ -202,3 +202,45 @@ 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
----------------

You can use a callable as the 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 development and production 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::

import os
from django.db import models
from .storages import MyLocalStorage, MyRemoteStorage


def select_storage():
if os.getenv('IS_PRODUCTION', False):
return MyRemoteStorage()
else:
return MyLocalStorage()


class MyModel(models.Model):
my_file = models.FileField(storage=select_storage)

A ``Storage`` subclass implementing ``__call__()`` to return ``self`` may use
this to feature to perform customization::

class CustomStorage(Storage):
# Adding a call method makes the instance callable.
def __call__(self):
if os.getenv('IS_PRODUCTION', False):
self.location = '/overridden/path/'
return self


class MyModel(models.Model):
my_file = models.FileField(storage=CustomStorage())
12 changes: 12 additions & 0 deletions tests/file_storage/models.py
Expand Up @@ -23,6 +23,14 @@ def get_valid_name(self, name):
temp_storage = FileSystemStorage(location=temp_storage_location)


def callable_storage():
return temp_storage


class CallableStorage(FileSystemStorage):
pass


class Storage(models.Model):
def custom_upload_to(self, filename):
return 'foo'
Expand All @@ -44,6 +52,10 @@ def pathlib_upload_to(self, filename):
storage=CustomValidNameStorage(location=temp_storage_location),
upload_to=random_upload_to,
)

callable_storage_field = models.FileField(storage=callable_storage, upload_to='callable_storage')
callable_storage_field2 = models.FileField(storage=CallableStorage, upload_to='callable_storage2')

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
56 changes: 54 additions & 2 deletions tests/file_storage/tests.py
Expand Up @@ -11,12 +11,18 @@
from urllib.request import urlopen

from django.core.cache import cache
from django.core.exceptions import SuspiciousFileOperation
from django.core.exceptions import (
ImproperlyConfigured, 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 storage_base_class, 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 +872,52 @@ 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 type_ in (NotStorage, str, list, set, tuple):
with self.subTest(type_=type_):
with self.assertRaisesMessage(ImproperlyConfigured, msg):
FileField(storage=type_)

# These should not raise, and should use default_storage
for type_ in (None, False):
with self.subTest(type_=type_):
obj = FileField(storage=type_)
self.assertEqual(obj.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, storage_base_class)

def test_callable_storage_file_field_in_model(self):
obj = Storage()
self.assertEqual(obj.callable_storage_field.storage, temp_storage)
self.assertEqual(obj.callable_storage_field.storage.location, temp_storage_location)
self.assertIsInstance(obj.callable_storage_field2.storage, storage_base_class)


# 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

0 comments on commit e74c9d0

Please sign in to comment.