From 7899568e01fc9c68afe995fa71de915dd9fcdd76 Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Fri, 8 Aug 2008 20:59:02 +0000 Subject: [PATCH] File storage refactoring, adding far more flexibility to Django's file handling. The new files.txt document has details of the new features. This is a backwards-incompatible change; consult BackwardsIncompatibleChanges for details. Fixes #3567, #3621, #4345, #5361, #5655, #7415. Many thanks to Marty Alchin who did the vast majority of this work. git-svn-id: http://code.djangoproject.com/svn/django/trunk@8244 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/conf/global_settings.py | 3 + django/contrib/admin/widgets.py | 4 +- django/core/files/__init__.py | 1 + django/core/files/base.py | 169 ++++++++ django/core/files/images.py | 42 ++ django/core/files/storage.py | 214 ++++++++++ django/core/files/uploadedfile.py | 59 +-- django/db/models/__init__.py | 1 + django/db/models/base.py | 121 ++---- django/db/models/fields/__init__.py | 160 +------- django/db/models/fields/files.py | 315 ++++++++++++++ django/db/models/manipulators.py | 3 +- django/utils/images.py | 23 +- docs/custom_model_fields.txt | 39 ++ docs/db-api.txt | 43 +- docs/files.txt | 388 ++++++++++++++++++ docs/model-api.txt | 58 ++- docs/settings.txt | 10 + docs/upload_handling.txt | 25 +- tests/modeltests/files/__init__.py | 1 + tests/modeltests/files/models.py | 118 ++++++ tests/modeltests/model_forms/models.py | 69 ++-- tests/regressiontests/admin_widgets/models.py | 14 +- tests/regressiontests/bug639/models.py | 12 +- tests/regressiontests/bug639/tests.py | 2 +- .../regressiontests/file_storage/__init__.py | 1 + tests/regressiontests/file_storage/models.py | 44 ++ tests/regressiontests/file_storage/test.png | Bin 0 -> 482 bytes tests/regressiontests/file_storage/tests.py | 66 +++ tests/regressiontests/file_uploads/models.py | 7 +- tests/regressiontests/file_uploads/tests.py | 22 +- .../serializers_regress/models.py | 4 +- .../serializers_regress/tests.py | 4 +- 33 files changed, 1585 insertions(+), 457 deletions(-) create mode 100644 django/core/files/base.py create mode 100644 django/core/files/images.py create mode 100644 django/core/files/storage.py create mode 100644 django/db/models/fields/files.py create mode 100644 docs/files.txt create mode 100644 tests/modeltests/files/__init__.py create mode 100644 tests/modeltests/files/models.py create mode 100644 tests/regressiontests/file_storage/__init__.py create mode 100644 tests/regressiontests/file_storage/models.py create mode 100644 tests/regressiontests/file_storage/test.png create mode 100644 tests/regressiontests/file_storage/tests.py diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 811feed349..c52d8d9a89 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -226,6 +226,9 @@ SECRET_KEY = '' # Path to the "jing" executable -- needed to validate XMLFields JING_PATH = "/usr/bin/jing" +# Default file storage mechanism that holds media. +DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' + # Absolute path to the directory that holds media. # Example: "/home/media/media.lawrence.com/" MEDIA_ROOT = '' diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py index 884171be2e..02a52702d3 100644 --- a/django/contrib/admin/widgets.py +++ b/django/contrib/admin/widgets.py @@ -85,8 +85,8 @@ class AdminFileWidget(forms.FileInput): def render(self, name, value, attrs=None): output = [] if value: - output.append('%s %s
%s ' % \ - (_('Currently:'), settings.MEDIA_URL, value, value, _('Change:'))) + output.append('%s %s
%s ' % \ + (_('Currently:'), value.url, value, _('Change:'))) output.append(super(AdminFileWidget, self).render(name, value, attrs)) return mark_safe(u''.join(output)) diff --git a/django/core/files/__init__.py b/django/core/files/__init__.py index e69de29bb2..0c3ef57af8 100644 --- a/django/core/files/__init__.py +++ b/django/core/files/__init__.py @@ -0,0 +1 @@ +from django.core.files.base import File diff --git a/django/core/files/base.py b/django/core/files/base.py new file mode 100644 index 0000000000..69739d6488 --- /dev/null +++ b/django/core/files/base.py @@ -0,0 +1,169 @@ +import os + +from django.utils.encoding import smart_str, smart_unicode + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +class File(object): + DEFAULT_CHUNK_SIZE = 64 * 2**10 + + def __init__(self, file): + self.file = file + self._name = file.name + self._mode = file.mode + self._closed = False + + def __str__(self): + return smart_str(self.name or '') + + def __unicode__(self): + return smart_unicode(self.name or u'') + + def __repr__(self): + return "<%s: %s>" % (self.__class__.__name__, self or "None") + + def __nonzero__(self): + return not not self.name + + def __len__(self): + return self.size + + def _get_name(self): + return self._name + name = property(_get_name) + + def _get_mode(self): + return self._mode + mode = property(_get_mode) + + def _get_closed(self): + return self._closed + closed = property(_get_closed) + + def _get_size(self): + if not hasattr(self, '_size'): + if hasattr(self.file, 'size'): + self._size = self.file.size + elif os.path.exists(self.file.name): + self._size = os.path.getsize(self.file.name) + else: + raise AttributeError("Unable to determine the file's size.") + return self._size + + def _set_size(self, size): + self._size = size + + size = property(_get_size, _set_size) + + def chunks(self, chunk_size=None): + """ + Read the file and yield chucks of ``chunk_size`` bytes (defaults to + ``UploadedFile.DEFAULT_CHUNK_SIZE``). + """ + if not chunk_size: + chunk_size = self.__class__.DEFAULT_CHUNK_SIZE + + if hasattr(self, 'seek'): + self.seek(0) + # Assume the pointer is at zero... + counter = self.size + + while counter > 0: + yield self.read(chunk_size) + counter -= chunk_size + + def multiple_chunks(self, chunk_size=None): + """ + Returns ``True`` if you can expect multiple chunks. + + NB: If a particular file representation is in memory, subclasses should + always return ``False`` -- there's no good reason to read from memory in + chunks. + """ + if not chunk_size: + chunk_size = self.DEFAULT_CHUNK_SIZE + return self.size > chunk_size + + def xreadlines(self): + return iter(self) + + def readlines(self): + return list(self.xreadlines()) + + def __iter__(self): + # Iterate over this file-like object by newlines + buffer_ = None + for chunk in self.chunks(): + chunk_buffer = StringIO(chunk) + + for line in chunk_buffer: + if buffer_: + line = buffer_ + line + buffer_ = None + + # If this is the end of a line, yield + # otherwise, wait for the next round + if line[-1] in ('\n', '\r'): + yield line + else: + buffer_ = line + + if buffer_ is not None: + yield buffer_ + + def open(self, mode=None): + if not self.closed: + self.seek(0) + elif os.path.exists(self.file.name): + self.file = open(self.file.name, mode or self.file.mode) + else: + raise ValueError("The file cannot be reopened.") + + def seek(self, position): + self.file.seek(position) + + def tell(self): + return self.file.tell() + + def read(self, num_bytes=None): + if num_bytes is None: + return self.file.read() + return self.file.read(num_bytes) + + def write(self, content): + if not self.mode.startswith('w'): + raise IOError("File was not opened with write access.") + self.file.write(content) + + def flush(self): + if not self.mode.startswith('w'): + raise IOError("File was not opened with write access.") + self.file.flush() + + def close(self): + self.file.close() + self._closed = True + +class ContentFile(File): + """ + A File-like object that takes just raw content, rather than an actual file. + """ + def __init__(self, content): + self.file = StringIO(content or '') + self.size = len(content or '') + self.file.seek(0) + self._closed = False + + def __str__(self): + return 'Raw content' + + def __nonzero__(self): + return True + + def open(self, mode=None): + if self._closed: + self._closed = False + self.seek(0) diff --git a/django/core/files/images.py b/django/core/files/images.py new file mode 100644 index 0000000000..3fa5013027 --- /dev/null +++ b/django/core/files/images.py @@ -0,0 +1,42 @@ +""" +Utility functions for handling images. + +Requires PIL, as you might imagine. +""" + +from PIL import ImageFile as PIL +from django.core.files import File + +class ImageFile(File): + """ + A mixin for use alongside django.core.files.base.File, which provides + additional features for dealing with images. + """ + def _get_width(self): + return self._get_image_dimensions()[0] + width = property(_get_width) + + def _get_height(self): + return self._get_image_dimensions()[1] + height = property(_get_height) + + def _get_image_dimensions(self): + if not hasattr(self, '_dimensions_cache'): + self._dimensions_cache = get_image_dimensions(self) + return self._dimensions_cache + +def get_image_dimensions(file_or_path): + """Returns the (width, height) of an image, given an open file or a path.""" + p = PIL.Parser() + if hasattr(file_or_path, 'read'): + file = file_or_path + else: + file = open(file_or_path, 'rb') + while 1: + data = file.read(1024) + if not data: + break + p.feed(data) + if p.image: + return p.image.size + return None diff --git a/django/core/files/storage.py b/django/core/files/storage.py new file mode 100644 index 0000000000..351cc03bb5 --- /dev/null +++ b/django/core/files/storage.py @@ -0,0 +1,214 @@ +import os +import urlparse + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation +from django.utils.encoding import force_unicode, smart_str +from django.utils.text import force_unicode, get_valid_filename +from django.utils._os import safe_join +from django.core.files import locks, File + +__all__ = ('Storage', 'FileSystemStorage', 'DefaultStorage', 'default_storage') + +class Storage(object): + """ + A base storage class, providing some default behaviors that all other + storage systems can inherit or override, as necessary. + """ + + # The following methods represent a public interface to private methods. + # These shouldn't be overridden by subclasses unless absolutely necessary. + + def open(self, name, mode='rb', mixin=None): + """ + Retrieves the specified file from storage, using the optional mixin + class to customize what features are available on the File returned. + """ + file = self._open(name, mode) + if mixin: + # Add the mixin as a parent class of the File returned from storage. + file.__class__ = type(mixin.__name__, (mixin, file.__class__), {}) + return file + + def save(self, name, content): + """ + Saves new content to the file specified by name. The content should be a + proper File object, ready to be read from the beginning. + """ + # Check for old-style usage. Warn here first since there are multiple + # locations where we need to support both new and old usage. + if isinstance(content, basestring): + import warnings + warnings.warn( + message = "Representing files as strings is deprecated." \ + "Use django.core.files.base.ContentFile instead.", + category = DeprecationWarning, + stacklevel = 2 + ) + from django.core.files.base import ContentFile + content = ContentFile(content) + + # Get the proper name for the file, as it will actually be saved. + if name is None: + name = content.name + name = self.get_available_name(name) + + self._save(name, content) + + # Store filenames with forward slashes, even on Windows + return force_unicode(name.replace('\\', '/')) + + # These methods are part of the public API, with default implementations. + + def get_valid_name(self, name): + """ + Returns a filename, based on the provided filename, that's suitable for + use in the target storage system. + """ + return get_valid_filename(name) + + def get_available_name(self, name): + """ + Returns a filename that's free on the target storage system, and + available for new content to be written to. + """ + # If the filename already exists, keep adding an underscore to the name + # of the file until the filename doesn't exist. + while self.exists(name): + try: + dot_index = name.rindex('.') + except ValueError: # filename has no dot + name += '_' + else: + name = name[:dot_index] + '_' + name[dot_index:] + return name + + def path(self, name): + """ + Returns a local filesystem path where the file can be retrieved using + Python's built-in open() function. Storage systems that can't be + accessed using open() should *not* implement this method. + """ + raise NotImplementedError("This backend doesn't support absolute paths.") + + # The following methods form the public API for storage systems, but with + # no default implementations. Subclasses must implement *all* of these. + + def delete(self, name): + """ + Deletes the specified file from the storage system. + """ + raise NotImplementedError() + + def exists(self, name): + """ + Returns True if a file referened by the given name already exists in the + storage system, or False if the name is available for a new file. + """ + raise NotImplementedError() + + def listdir(self, path): + """ + Lists the contents of the specified path, returning a 2-tuple of lists; + the first item being directories, the second item being files. + """ + raise NotImplementedError() + + def size(self, name): + """ + Returns the total size, in bytes, of the file specified by name. + """ + raise NotImplementedError() + + def url(self, name): + """ + Returns an absolute URL where the file's contents can be accessed + directly by a web browser. + """ + raise NotImplementedError() + +class FileSystemStorage(Storage): + """ + Standard filesystem storage + """ + + def __init__(self, location=settings.MEDIA_ROOT, base_url=settings.MEDIA_URL): + self.location = os.path.abspath(location) + self.base_url = base_url + + def _open(self, name, mode='rb'): + return File(open(self.path(name), mode)) + + def _save(self, name, content): + full_path = self.path(name) + + directory = os.path.dirname(full_path) + if not os.path.exists(directory): + os.makedirs(directory) + elif not os.path.isdir(directory): + raise IOError("%s exists and is not a directory." % directory) + + if hasattr(content, 'temporary_file_path'): + # This file has a file path that we can move. + file_move_safe(content.temporary_file_path(), full_path) + content.close() + else: + # This is a normal uploadedfile that we can stream. + fp = open(full_path, 'wb') + locks.lock(fp, locks.LOCK_EX) + for chunk in content.chunks(): + fp.write(chunk) + locks.unlock(fp) + fp.close() + + def delete(self, name): + name = self.path(name) + # If the file exists, delete it from the filesystem. + if os.path.exists(name): + os.remove(name) + + def exists(self, name): + return os.path.exists(self.path(name)) + + def listdir(self, path): + path = self.path(path) + directories, files = [], [] + for entry in os.listdir(path): + if os.path.isdir(os.path.join(path, entry)): + directories.append(entry) + else: + files.append(entry) + return directories, files + + def path(self, name): + try: + path = safe_join(self.location, name) + except ValueError: + raise SuspiciousOperation("Attempted access to '%s' denied." % name) + return os.path.normpath(path) + + def size(self, name): + return os.path.getsize(self.path(name)) + + def url(self, name): + if self.base_url is None: + raise ValueError("This file is not accessible via a URL.") + return urlparse.urljoin(self.base_url, name).replace('\\', '/') + +def get_storage_class(import_path): + try: + dot = import_path.rindex('.') + except ValueError: + raise ImproperlyConfigured("%s isn't a storage module." % import_path) + module, classname = import_path[:dot], import_path[dot+1:] + try: + mod = __import__(module, {}, {}, ['']) + except ImportError, e: + raise ImproperlyConfigured('Error importing storage module %s: "%s"' % (module, e)) + try: + return getattr(mod, classname) + except AttributeError: + raise ImproperlyConfigured('Storage module "%s" does not define a "%s" class.' % (module, classname)) + +DefaultStorage = get_storage_class(settings.DEFAULT_FILE_STORAGE) +default_storage = DefaultStorage() diff --git a/django/core/files/uploadedfile.py b/django/core/files/uploadedfile.py index 5e81e968cd..a5a12930e3 100644 --- a/django/core/files/uploadedfile.py +++ b/django/core/files/uploadedfile.py @@ -10,6 +10,7 @@ except ImportError: from StringIO import StringIO from django.conf import settings +from django.core.files.base import File from django.core.files import temp as tempfile @@ -39,7 +40,7 @@ def deprecated_property(old, new, readonly=False): else: return property(getter, setter) -class UploadedFile(object): +class UploadedFile(File): """ A abstract uploaded file (``TemporaryUploadedFile`` and ``InMemoryUploadedFile`` are the built-in concrete subclasses). @@ -76,23 +77,6 @@ class UploadedFile(object): name = property(_get_name, _set_name) - def chunks(self, chunk_size=None): - """ - Read the file and yield chucks of ``chunk_size`` bytes (defaults to - ``UploadedFile.DEFAULT_CHUNK_SIZE``). - """ - if not chunk_size: - chunk_size = UploadedFile.DEFAULT_CHUNK_SIZE - - if hasattr(self, 'seek'): - self.seek(0) - # Assume the pointer is at zero... - counter = self.size - - while counter > 0: - yield self.read(chunk_size) - counter -= chunk_size - # Deprecated properties filename = deprecated_property(old="filename", new="name") file_name = deprecated_property(old="file_name", new="name") @@ -108,18 +92,6 @@ class UploadedFile(object): return self.read() data = property(_get_data) - def multiple_chunks(self, chunk_size=None): - """ - Returns ``True`` if you can expect multiple chunks. - - NB: If a particular file representation is in memory, subclasses should - always return ``False`` -- there's no good reason to read from memory in - chunks. - """ - if not chunk_size: - chunk_size = UploadedFile.DEFAULT_CHUNK_SIZE - return self.size > chunk_size - # Abstract methods; subclasses *must* define read() and probably should # define open/close. def read(self, num_bytes=None): @@ -131,33 +103,6 @@ class UploadedFile(object): def close(self): pass - def xreadlines(self): - return self - - def readlines(self): - return list(self.xreadlines()) - - def __iter__(self): - # Iterate over this file-like object by newlines - buffer_ = None - for chunk in self.chunks(): - chunk_buffer = StringIO(chunk) - - for line in chunk_buffer: - if buffer_: - line = buffer_ + line - buffer_ = None - - # If this is the end of a line, yield - # otherwise, wait for the next round - if line[-1] in ('\n', '\r'): - yield line - else: - buffer_ = line - - if buffer_ is not None: - yield buffer_ - # Backwards-compatible support for uploaded-files-as-dictionaries. def __getitem__(self, key): warnings.warn( diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index 18c47e86f3..cbd685547e 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -8,6 +8,7 @@ from django.db.models.manager import Manager from django.db.models.base import Model from django.db.models.fields import * from django.db.models.fields.subclassing import SubfieldBase +from django.db.models.fields.files import FileField, ImageField from django.db.models.fields.related import ForeignKey, OneToOneField, ManyToManyField, ManyToOneRel, ManyToManyRel, OneToOneRel, TABULAR, STACKED from django.db.models import signals diff --git a/django/db/models/base.py b/django/db/models/base.py index 3d7eac9284..59a503ff82 100644 --- a/django/db/models/base.py +++ b/django/db/models/base.py @@ -3,6 +3,7 @@ import types import sys import os from itertools import izip +from warnings import warn try: set except NameError: @@ -12,7 +13,7 @@ import django.db.models.manipulators # Imported to register signal handler. import django.db.models.manager # Ditto. from django.core import validators from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, FieldError -from django.db.models.fields import AutoField, ImageField +from django.db.models.fields import AutoField from django.db.models.fields.related import OneToOneRel, ManyToOneRel, OneToOneField from django.db.models.query import delete_objects, Q, CollectedObjects from django.db.models.options import Options @@ -463,110 +464,42 @@ class Model(object): return getattr(self, cachename) def _get_FIELD_filename(self, field): - if getattr(self, field.attname): # Value is not blank. - return os.path.normpath(os.path.join(settings.MEDIA_ROOT, getattr(self, field.attname))) - return '' + warn("instance.get_%s_filename() is deprecated. Use instance.%s.path instead." % \ + (field.attname, field.attname), DeprecationWarning, stacklevel=3) + try: + return getattr(self, field.attname).path + except ValueError: + return '' def _get_FIELD_url(self, field): - if getattr(self, field.attname): # Value is not blank. - import urlparse - return urlparse.urljoin(settings.MEDIA_URL, getattr(self, field.attname)).replace('\\', '/') - return '' + warn("instance.get_%s_url() is deprecated. Use instance.%s.url instead." % \ + (field.attname, field.attname), DeprecationWarning, stacklevel=3) + try: + return getattr(self, field.attname).url + except ValueError: + return '' def _get_FIELD_size(self, field): - return os.path.getsize(self._get_FIELD_filename(field)) + warn("instance.get_%s_size() is deprecated. Use instance.%s.size instead." % \ + (field.attname, field.attname), DeprecationWarning, stacklevel=3) + return getattr(self, field.attname).size - def _save_FIELD_file(self, field, filename, raw_field, save=True): - # Create the upload directory if it doesn't already exist - directory = os.path.join(settings.MEDIA_ROOT, field.get_directory_name()) - if not os.path.exists(directory): - os.makedirs(directory) - elif not os.path.isdir(directory): - raise IOError('%s exists and is not a directory' % directory) - - # Check for old-style usage (files-as-dictionaries). Warn here first - # since there are multiple locations where we need to support both new - # and old usage. - if isinstance(raw_field, dict): - import warnings - warnings.warn( - message = "Representing uploaded files as dictionaries is deprecated. Use django.core.files.uploadedfile.SimpleUploadedFile instead.", - category = DeprecationWarning, - stacklevel = 2 - ) - from django.core.files.uploadedfile import SimpleUploadedFile - raw_field = SimpleUploadedFile.from_dict(raw_field) - - elif isinstance(raw_field, basestring): - import warnings - warnings.warn( - message = "Representing uploaded files as strings is deprecated. Use django.core.files.uploadedfile.SimpleUploadedFile instead.", - category = DeprecationWarning, - stacklevel = 2 - ) - from django.core.files.uploadedfile import SimpleUploadedFile - raw_field = SimpleUploadedFile(filename, raw_field) - - if filename is None: - filename = raw_field.file_name - - filename = field.get_filename(filename) - - # If the filename already exists, keep adding an underscore to the name - # of the file until the filename doesn't exist. - while os.path.exists(os.path.join(settings.MEDIA_ROOT, filename)): - try: - dot_index = filename.rindex('.') - except ValueError: # filename has no dot. - filename += '_' - else: - filename = filename[:dot_index] + '_' + filename[dot_index:] - - # Save the file name on the object and write the file to disk. - setattr(self, field.attname, filename) - full_filename = self._get_FIELD_filename(field) - if hasattr(raw_field, 'temporary_file_path'): - # This file has a file path that we can move. - file_move_safe(raw_field.temporary_file_path(), full_filename) - raw_field.close() - else: - # This is a normal uploadedfile that we can stream. - fp = open(full_filename, 'wb') - locks.lock(fp, locks.LOCK_EX) - for chunk in raw_field.chunks(): - fp.write(chunk) - locks.unlock(fp) - fp.close() - - # Save the width and/or height, if applicable. - if isinstance(field, ImageField) and \ - (field.width_field or field.height_field): - from django.utils.images import get_image_dimensions - width, height = get_image_dimensions(full_filename) - if field.width_field: - setattr(self, field.width_field, width) - if field.height_field: - setattr(self, field.height_field, height) - - # Save the object because it has changed, unless save is False. - if save: - self.save() + def _save_FIELD_file(self, field, filename, content, save=True): + warn("instance.save_%s_file() is deprecated. Use instance.%s.save() instead." % \ + (field.attname, field.attname), DeprecationWarning, stacklevel=3) + return getattr(self, field.attname).save(filename, content, save) _save_FIELD_file.alters_data = True def _get_FIELD_width(self, field): - return self._get_image_dimensions(field)[0] + warn("instance.get_%s_width() is deprecated. Use instance.%s.width instead." % \ + (field.attname, field.attname), DeprecationWarning, stacklevel=3) + return getattr(self, field.attname).width() def _get_FIELD_height(self, field): - return self._get_image_dimensions(field)[1] - - def _get_image_dimensions(self, field): - cachename = "__%s_dimensions_cache" % field.name - if not hasattr(self, cachename): - from django.utils.images import get_image_dimensions - filename = self._get_FIELD_filename(field) - setattr(self, cachename, get_image_dimensions(filename)) - return getattr(self, cachename) + warn("instance.get_%s_height() is deprecated. Use instance.%s.height instead." % \ + (field.attname, field.attname), DeprecationWarning, stacklevel=3) + return getattr(self, field.attname).height() ############################################ diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index dbb3e520c0..f19fb258a3 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -10,6 +10,7 @@ except ImportError: from django.db import connection, get_creation_module from django.db.models import signals from django.db.models.query_utils import QueryWrapper +from django.dispatch import dispatcher from django.conf import settings from django.core import validators from django import oldforms @@ -757,131 +758,6 @@ class EmailField(CharField): defaults.update(kwargs) return super(EmailField, self).formfield(**defaults) -class FileField(Field): - def __init__(self, verbose_name=None, name=None, upload_to='', **kwargs): - self.upload_to = upload_to - kwargs['max_length'] = kwargs.get('max_length', 100) - Field.__init__(self, verbose_name, name, **kwargs) - - def get_internal_type(self): - return "FileField" - - def get_db_prep_value(self, value): - "Returns field's value prepared for saving into a database." - # Need to convert UploadedFile objects provided via a form to unicode for database insertion - if hasattr(value, 'name'): - return value.name - elif value is None: - return None - else: - return unicode(value) - - def get_manipulator_fields(self, opts, manipulator, change, name_prefix='', rel=False, follow=True): - field_list = Field.get_manipulator_fields(self, opts, manipulator, change, name_prefix, rel, follow) - if not self.blank: - if rel: - # This validator makes sure FileFields work in a related context. - class RequiredFileField(object): - def __init__(self, other_field_names, other_file_field_name): - self.other_field_names = other_field_names - self.other_file_field_name = other_file_field_name - self.always_test = True - def __call__(self, field_data, all_data): - if not all_data.get(self.other_file_field_name, False): - c = validators.RequiredIfOtherFieldsGiven(self.other_field_names, ugettext_lazy("This field is required.")) - c(field_data, all_data) - # First, get the core fields, if any. - core_field_names = [] - for f in opts.fields: - if f.core and f != self: - core_field_names.extend(f.get_manipulator_field_names(name_prefix)) - # Now, if there are any, add the validator to this FormField. - if core_field_names: - field_list[0].validator_list.append(RequiredFileField(core_field_names, field_list[1].field_name)) - else: - v = validators.RequiredIfOtherFieldNotGiven(field_list[1].field_name, ugettext_lazy("This field is required.")) - v.always_test = True - field_list[0].validator_list.append(v) - field_list[0].is_required = field_list[1].is_required = False - - # If the raw path is passed in, validate it's under the MEDIA_ROOT. - def isWithinMediaRoot(field_data, all_data): - f = os.path.abspath(os.path.join(settings.MEDIA_ROOT, field_data)) - if not f.startswith(os.path.abspath(os.path.normpath(settings.MEDIA_ROOT))): - raise validators.ValidationError, _("Enter a valid filename.") - field_list[1].validator_list.append(isWithinMediaRoot) - return field_list - - def contribute_to_class(self, cls, name): - super(FileField, self).contribute_to_class(cls, name) - setattr(cls, 'get_%s_filename' % self.name, curry(cls._get_FIELD_filename, field=self)) - setattr(cls, 'get_%s_url' % self.name, curry(cls._get_FIELD_url, field=self)) - setattr(cls, 'get_%s_size' % self.name, curry(cls._get_FIELD_size, field=self)) - setattr(cls, 'save_%s_file' % self.name, lambda instance, filename, raw_field, save=True: instance._save_FIELD_file(self, filename, raw_field, save)) - signals.post_delete.connect(self.delete_file, sender=cls) - - def delete_file(self, instance, **kwargs): - if getattr(instance, self.attname): - file_name = getattr(instance, 'get_%s_filename' % self.name)() - # If the file exists and no other object of this type references it, - # delete it from the filesystem. - if os.path.exists(file_name) and \ - not instance.__class__._default_manager.filter(**{'%s__exact' % self.name: getattr(instance, self.attname)}): - os.remove(file_name) - - def get_manipulator_field_objs(self): - return [oldforms.FileUploadField, oldforms.HiddenField] - - def get_manipulator_field_names(self, name_prefix): - return [name_prefix + self.name + '_file', name_prefix + self.name] - - def save_file(self, new_data, new_object, original_object, change, rel, save=True): - upload_field_name = self.get_manipulator_field_names('')[0] - if new_data.get(upload_field_name, False): - if rel: - file = new_data[upload_field_name][0] - else: - file = new_data[upload_field_name] - - if not file: - return - - # Backwards-compatible support for files-as-dictionaries. - # We don't need to raise a warning because Model._save_FIELD_file will - # do so for us. - try: - file_name = file.name - except AttributeError: - file_name = file['filename'] - - func = getattr(new_object, 'save_%s_file' % self.name) - func(file_name, file, save) - - def get_directory_name(self): - return os.path.normpath(force_unicode(datetime.datetime.now().strftime(smart_str(self.upload_to)))) - - def get_filename(self, filename): - from django.utils.text import get_valid_filename - f = os.path.join(self.get_directory_name(), get_valid_filename(os.path.basename(filename))) - return os.path.normpath(f) - - def save_form_data(self, instance, data): - from django.core.files.uploadedfile import UploadedFile - if data and isinstance(data, UploadedFile): - getattr(instance, "save_%s_file" % self.name)(data.name, data, save=False) - - def formfield(self, **kwargs): - defaults = {'form_class': forms.FileField} - # If a file has been provided previously, then the form doesn't require - # that a new file is provided this time. - # The code to mark the form field as not required is used by - # form_for_instance, but can probably be removed once form_for_instance - # is gone. ModelForm uses a different method to check for an existing file. - if 'initial' in kwargs: - defaults['required'] = False - defaults.update(kwargs) - return super(FileField, self).formfield(**defaults) - class FilePathField(Field): def __init__(self, verbose_name=None, name=None, path='', match=None, recursive=False, **kwargs): self.path, self.match, self.recursive = path, match, recursive @@ -923,40 +799,6 @@ class FloatField(Field): defaults.update(kwargs) return super(FloatField, self).formfield(**defaults) -class ImageField(FileField): - def __init__(self, verbose_name=None, name=None, width_field=None, height_field=None, **kwargs): - self.width_field, self.height_field = width_field, height_field - FileField.__init__(self, verbose_name, name, **kwargs) - - def get_manipulator_field_objs(self): - return [oldforms.ImageUploadField, oldforms.HiddenField] - - def contribute_to_class(self, cls, name): - super(ImageField, self).contribute_to_class(cls, name) - # Add get_BLAH_width and get_BLAH_height methods, but only if the - # image field doesn't have width and height cache fields. - if not self.width_field: - setattr(cls, 'get_%s_width' % self.name, curry(cls._get_FIELD_width, field=self)) - if not self.height_field: - setattr(cls, 'get_%s_height' % self.name, curry(cls._get_FIELD_height, field=self)) - - def save_file(self, new_data, new_object, original_object, change, rel, save=True): - FileField.save_file(self, new_data, new_object, original_object, change, rel, save) - # If the image has height and/or width field(s) and they haven't - # changed, set the width and/or height field(s) back to their original - # values. - if change and (self.width_field or self.height_field) and save: - if self.width_field: - setattr(new_object, self.width_field, getattr(original_object, self.width_field)) - if self.height_field: - setattr(new_object, self.height_field, getattr(original_object, self.height_field)) - new_object.save() - - def formfield(self, **kwargs): - defaults = {'form_class': forms.ImageField} - defaults.update(kwargs) - return super(ImageField, self).formfield(**defaults) - class IntegerField(Field): empty_strings_allowed = False def get_db_prep_value(self, value): diff --git a/django/db/models/fields/files.py b/django/db/models/fields/files.py new file mode 100644 index 0000000000..76639596b5 --- /dev/null +++ b/django/db/models/fields/files.py @@ -0,0 +1,315 @@ +import datetime +import os + +from django.conf import settings +from django.db.models.fields import Field +from django.core.files.base import File, ContentFile +from django.core.files.storage import default_storage +from django.core.files.images import ImageFile, get_image_dimensions +from django.core.files.uploadedfile import UploadedFile +from django.utils.functional import curry +from django.db.models import signals +from django.utils.encoding import force_unicode, smart_str +from django.utils.translation import ugettext_lazy, ugettext as _ +from django import oldforms +from django import forms +from django.core import validators +from django.db.models.loading import cache + +class FieldFile(File): + def __init__(self, instance, field, name): + self.instance = instance + self.field = field + self.storage = field.storage + self._name = name or u'' + self._closed = False + + def __eq__(self, other): + # Older code may be expecting FileField values to be simple strings. + # By overriding the == operator, it can remain backwards compatibility. + if hasattr(other, 'name'): + return self.name == other.name + return self.name == other + + # The standard File contains most of the necessary properties, but + # FieldFiles can be instantiated without a name, so that needs to + # be checked for here. + + def _require_file(self): + if not self: + raise ValueError("The '%s' attribute has no file associated with it." % self.field.name) + + def _get_file(self): + self._require_file() + if not hasattr(self, '_file'): + self._file = self.storage.open(self.name, 'rb') + return self._file + file = property(_get_file) + + def _get_path(self): + self._require_file() + return self.storage.path(self.name) + path = property(_get_path) + + def _get_url(self): + self._require_file() + return self.storage.url(self.name) + url = property(_get_url) + + def open(self, mode='rb'): + self._require_file() + return super(FieldFile, self).open(mode) + # open() doesn't alter the file's contents, but it does reset the pointer + open.alters_data = True + + # In addition to the standard File API, FieldFiles have extra methods + # to further manipulate the underlying file, as well as update the + # associated model instance. + + def save(self, name, content, save=True): + name = self.field.generate_filename(self.instance, name) + self._name = self.storage.save(name, content) + setattr(self.instance, self.field.name, self.name) + + # Update the filesize cache + self._size = len(content) + + # Save the object because it has changed, unless save is False + if save: + self.instance.save() + save.alters_data = True + + def delete(self, save=True): + self.close() + self.storage.delete(self.name) + + self._name = None + setattr(self.instance, self.field.name, self.name) + + # Delete the filesize cache + if hasattr(self, '_size'): + del self._size + + if save: + self.instance.save() + delete.alters_data = True + + def __getstate__(self): + # FieldFile needs access to its associated model field and an instance + # it's attached to in order to work properly, but the only necessary + # data to be pickled is the file's name itself. Everything else will + # be restored later, by FileDescriptor below. + return {'_name': self.name, '_closed': False} + +class FileDescriptor(object): + def __init__(self, field): + self.field = field + + def __get__(self, instance=None, owner=None): + if instance is None: + raise AttributeError, "%s can only be accessed from %s instances." % (self.field.name(self.owner.__name__)) + file = instance.__dict__[self.field.name] + if not isinstance(file, FieldFile): + # Create a new instance of FieldFile, based on a given file name + instance.__dict__[self.field.name] = self.field.attr_class(instance, self.field, file) + elif not hasattr(file, 'field'): + # The FieldFile was pickled, so some attributes need to be reset. + file.instance = instance + file.field = self.field + file.storage = self.field.storage + return instance.__dict__[self.field.name] + + def __set__(self, instance, value): + instance.__dict__[self.field.name] = value + +class FileField(Field): + attr_class = FieldFile + + def __init__(self, verbose_name=None, name=None, upload_to='', storage=None, **kwargs): + for arg in ('core', 'primary_key', 'unique'): + if arg in kwargs: + raise TypeError("'%s' is not a valid argument for %s." % (arg, self.__class__)) + + self.storage = storage or default_storage + self.upload_to = upload_to + if callable(upload_to): + self.generate_filename = upload_to + + kwargs['max_length'] = kwargs.get('max_length', 100) + super(FileField, self).__init__(verbose_name, name, **kwargs) + + def get_internal_type(self): + return "FileField" + + def get_db_prep_lookup(self, lookup_type, value): + if hasattr(value, 'name'): + value = value.name + return super(FileField, self).get_db_prep_lookup(lookup_type, value) + + def get_db_prep_value(self, value): + "Returns field's value prepared for saving into a database." + # Need to convert File objects provided via a form to unicode for database insertion + if value is None: + return None + return unicode(value) + + def get_manipulator_fields(self, opts, manipulator, change, name_prefix='', rel=False, follow=True): + field_list = Field.get_manipulator_fields(self, opts, manipulator, change, name_prefix, rel, follow) + if not self.blank: + if rel: + # This validator makes sure FileFields work in a related context. + class RequiredFileField(object): + def __init__(self, other_field_names, other_file_field_name): + self.other_field_names = other_field_names + self.other_file_field_name = other_file_field_name + self.always_test = True + def __call__(self, field_data, all_data): + if not all_data.get(self.other_file_field_name, False): + c = validators.RequiredIfOtherFieldsGiven(self.other_field_names, ugettext_lazy("This field is required.")) + c(field_data, all_data) + # First, get the core fields, if any. + core_field_names = [] + for f in opts.fields: + if f.core and f != self: + core_field_names.extend(f.get_manipulator_field_names(name_prefix)) + # Now, if there are any, add the validator to this FormField. + if core_field_names: + field_list[0].validator_list.append(RequiredFileField(core_field_names, field_list[1].field_name)) + else: + v = validators.RequiredIfOtherFieldNotGiven(field_list[1].field_name, ugettext_lazy("This field is required.")) + v.always_test = True + field_list[0].validator_list.append(v) + field_list[0].is_required = field_list[1].is_required = False + + # If the raw path is passed in, validate it's under the MEDIA_ROOT. + def isWithinMediaRoot(field_data, all_data): + f = os.path.abspath(os.path.join(settings.MEDIA_ROOT, field_data)) + if not f.startswith(os.path.abspath(os.path.normpath(settings.MEDIA_ROOT))): + raise validators.ValidationError(_("Enter a valid filename.")) + field_list[1].validator_list.append(isWithinMediaRoot) + return field_list + + def contribute_to_class(self, cls, name): + super(FileField, self).contribute_to_class(cls, name) + setattr(cls, self.name, FileDescriptor(self)) + setattr(cls, 'get_%s_filename' % self.name, curry(cls._get_FIELD_filename, field=self)) + setattr(cls, 'get_%s_url' % self.name, curry(cls._get_FIELD_url, field=self)) + setattr(cls, 'get_%s_size' % self.name, curry(cls._get_FIELD_size, field=self)) + setattr(cls, 'save_%s_file' % self.name, lambda instance, name, content, save=True: instance._save_FIELD_file(self, name, content, save)) + signals.post_delete.connect(self.delete_file, sender=cls) + + def delete_file(self, instance, sender, **kwargs): + file = getattr(instance, self.attname) + # If no other object of this type references the file, + # and it's not the default value for future objects, + # delete it from the backend. + if file and file.name != self.default and \ + not sender._default_manager.filter(**{self.name: file.name}): + file.delete(save=False) + elif file: + # Otherwise, just close the file, so it doesn't tie up resources. + file.close() + + def get_manipulator_field_objs(self): + return [oldforms.FileUploadField, oldforms.HiddenField] + + def get_manipulator_field_names(self, name_prefix): + return [name_prefix + self.name + '_file', name_prefix + self.name] + + def save_file(self, new_data, new_object, original_object, change, rel, save=True): + upload_field_name = self.get_manipulator_field_names('')[0] + if new_data.get(upload_field_name, False): + if rel: + file = new_data[upload_field_name][0] + else: + file = new_data[upload_field_name] + + # Backwards-compatible support for files-as-dictionaries. + # We don't need to raise a warning because the storage backend will + # do so for us. + try: + filename = file.name + except AttributeError: + filename = file['filename'] + filename = self.get_filename(filename) + + getattr(new_object, self.attname).save(filename, file, save) + + def get_directory_name(self): + return os.path.normpath(force_unicode(datetime.datetime.now().strftime(smart_str(self.upload_to)))) + + def get_filename(self, filename): + return os.path.normpath(self.storage.get_valid_name(os.path.basename(filename))) + + def generate_filename(self, instance, filename): + return os.path.join(self.get_directory_name(), self.get_filename(filename)) + + def save_form_data(self, instance, data): + if data and isinstance(data, UploadedFile): + getattr(instance, self.name).save(data.name, data, save=False) + + def formfield(self, **kwargs): + defaults = {'form_class': forms.FileField} + # If a file has been provided previously, then the form doesn't require + # that a new file is provided this time. + # The code to mark the form field as not required is used by + # form_for_instance, but can probably be removed once form_for_instance + # is gone. ModelForm uses a different method to check for an existing file. + if 'initial' in kwargs: + defaults['required'] = False + defaults.update(kwargs) + return super(FileField, self).formfield(**defaults) + +class ImageFieldFile(ImageFile, FieldFile): + def save(self, name, content, save=True): + + if not hasattr(content, 'read'): + import warnings + warnings.warn( + message = "Representing files as strings is deprecated." \ + "Use django.core.files.base.ContentFile instead.", + category = DeprecationWarning, + stacklevel = 2 + ) + content = ContentFile(content) + + # Repopulate the image dimension cache. + self._dimensions_cache = get_image_dimensions(content) + + # Update width/height fields, if needed + if self.field.width_field: + setattr(self.instance, self.field.width_field, self.width) + if self.field.height_field: + setattr(self.instance, self.field.height_field, self.height) + + super(ImageFieldFile, self).save(name, content, save) + + def delete(self, save=True): + # Clear the image dimensions cache + if hasattr(self, '_dimensions_cache'): + del self._dimensions_cache + super(ImageFieldFile, self).delete(save) + +class ImageField(FileField): + attr_class = ImageFieldFile + + def __init__(self, verbose_name=None, name=None, width_field=None, height_field=None, **kwargs): + self.width_field, self.height_field = width_field, height_field + FileField.__init__(self, verbose_name, name, **kwargs) + + def get_manipulator_field_objs(self): + return [oldforms.ImageUploadField, oldforms.HiddenField] + + def contribute_to_class(self, cls, name): + super(ImageField, self).contribute_to_class(cls, name) + # Add get_BLAH_width and get_BLAH_height methods, but only if the + # image field doesn't have width and height cache fields. + if not self.width_field: + setattr(cls, 'get_%s_width' % self.name, curry(cls._get_FIELD_width, field=self)) + if not self.height_field: + setattr(cls, 'get_%s_height' % self.name, curry(cls._get_FIELD_height, field=self)) + + def formfield(self, **kwargs): + defaults = {'form_class': forms.ImageField} + defaults.update(kwargs) + return super(ImageField, self).formfield(**defaults) diff --git a/django/db/models/manipulators.py b/django/db/models/manipulators.py index a3c917a486..c657d0158b 100644 --- a/django/db/models/manipulators.py +++ b/django/db/models/manipulators.py @@ -1,7 +1,8 @@ from django.core.exceptions import ObjectDoesNotExist from django import oldforms from django.core import validators -from django.db.models.fields import FileField, AutoField +from django.db.models.fields import AutoField +from django.db.models.fields.files import FileField from django.db.models import signals from django.utils.functional import curry from django.utils.datastructures import DotExpandedDict diff --git a/django/utils/images.py b/django/utils/images.py index 122c6ae233..c6cc37cf9a 100644 --- a/django/utils/images.py +++ b/django/utils/images.py @@ -1,22 +1,5 @@ -""" -Utility functions for handling images. +import warnings -Requires PIL, as you might imagine. -""" +from django.core.files.images import get_image_dimensions -import ImageFile - -def get_image_dimensions(path): - """Returns the (width, height) of an image at a given path.""" - p = ImageFile.Parser() - fp = open(path, 'rb') - while 1: - data = fp.read(1024) - if not data: - break - p.feed(data) - if p.image: - return p.image.size - break - fp.close() - return None +warnings.warn("django.utils.images has been moved to django.core.files.images.", DeprecationWarning) diff --git a/docs/custom_model_fields.txt b/docs/custom_model_fields.txt index 6b8f3c3ac6..a45e876fc6 100644 --- a/docs/custom_model_fields.txt +++ b/docs/custom_model_fields.txt @@ -596,3 +596,42 @@ smoothly: instance, not a ``HandField``). So if your ``__unicode__()`` method automatically converts to the string form of your Python object, you can save yourself a lot of work. + +Writing a ``FileField`` subclass +================================= + +In addition to the above methods, fields that deal with files have a few other +special requirements which must be taken into account. The majority of the +mechanics provided by ``FileField``, such as controlling database storage and +retrieval, can remain unchanged, leaving subclasses to deal with the challenge +of supporting a particular type of file. + +Django provides a ``File`` class, which is used as a proxy to the file's +contents and operations. This can be subclassed to customzie hwo the file is +accessed, and what methods are available. It lives at +``django.db.models.fields.files``, and its default behavior is explained in the +`file documentation`_. + +Once a subclass of ``File`` is created, the new ``FileField`` subclass must be +told to use it. To do so, simply assign the new ``File`` subclass to the special +``attr_class`` attribute of the ``FileField`` subclass. + +.. _file documentation: ../files/ + +A few suggestions +------------------ + +In addition to the above details, there are a few guidelines which can greatly +improve the efficiency and readability of the field's code. + + 1. The source for Django's own ``ImageField`` (in + ``django/db/models/fields/files.py``) is a great example of how to + subclass ``FileField`` to support a particular type of file, as it + incorporates all of the techniques described above. + + 2. Cache file attributes wherever possible. Since files may be stored in + remote storage systems, retrieving them may cost extra time, or even + money, that isn't always necessary. Once a file is retrieved to obtain + some data about its content, cache as much of that data as possible to + reduce the number of times the file must be retrieved on subsequent + calls for that information. diff --git a/docs/db-api.txt b/docs/db-api.txt index 4f03a4810d..7e6406f334 100644 --- a/docs/db-api.txt +++ b/docs/db-api.txt @@ -2298,53 +2298,34 @@ For a full example, see the `lookup API sample model`_. get_FOO_filename() ------------------ -For every ``FileField``, the object will have a ``get_FOO_filename()`` method, -where ``FOO`` is the name of the field. This returns the full filesystem path -to the file, according to your ``MEDIA_ROOT`` setting. - -.. note:: - It is only valid to call this method **after** saving the model when the - field has been set. Prior to saving, the value returned will not contain - the upload directory (the `upload_to` parameter) in the path. - -Note that ``ImageField`` is technically a subclass of ``FileField``, so every -model with an ``ImageField`` will also get this method. +**Deprecated in Django development version**; use ``object.FOO.name`` instead. +See `managing files`_ for details. get_FOO_url() ------------- -For every ``FileField``, the object will have a ``get_FOO_url()`` method, -where ``FOO`` is the name of the field. This returns the full URL to the file, -according to your ``MEDIA_URL`` setting. If the value is blank, this method -returns an empty string. - -.. note:: - As with ``get_FOO_filename()``, it is only valid to call this method - **after** saving the model, otherwise an incorrect result will be - returned. +**Deprecated in Django development version**; use ``object.FOO.url`` instead. +See `managing files`_ for details. get_FOO_size() -------------- -For every ``FileField``, the object will have a ``get_FOO_size()`` method, -where ``FOO`` is the name of the field. This returns the size of the file, in -bytes. (Behind the scenes, it uses ``os.path.getsize``.) +**Deprecated in Django development version**; use ``object.FOO.size`` instead. +See `managing files`_ for details. save_FOO_file(filename, raw_contents) ------------------------------------- -For every ``FileField``, the object will have a ``save_FOO_file()`` method, -where ``FOO`` is the name of the field. This saves the given file to the -filesystem, using the given filename. If a file with the given filename already -exists, Django adds an underscore to the end of the filename (but before the -extension) until the filename is available. +**Deprecated in Django development version**; use ``object.FOO.save()`` instead. +See `managing files`_ for details. get_FOO_height() and get_FOO_width() ------------------------------------ -For every ``ImageField``, the object will have ``get_FOO_height()`` and -``get_FOO_width()`` methods, where ``FOO`` is the name of the field. This -returns the height (or width) of the image, as an integer, in pixels. +**Deprecated in Django development version**; use ``object.FOO.width`` and +``object.FOO.height`` instead. See `managing files`_ for details. + +.. _`managing files`: ../files/ Shortcuts ========= diff --git a/docs/files.txt b/docs/files.txt new file mode 100644 index 0000000000..9a8326806f --- /dev/null +++ b/docs/files.txt @@ -0,0 +1,388 @@ +============== +Managing files +============== + +**New in Django development version** + +This document describes Django's file access APIs. + +By default, Django stores files locally, using the ``MEDIA_ROOT`` and +``MEDIA_URL`` settings_. The examples below assume that you're using +these defaults. + +However, Django provides ways to write custom `file storage systems`_ that +allow you to completely customize where and how Django stores files. The +second half of this document describes how these storage systems work. + +.. _file storage systems: `File storage`_ +.. _settings: ../settings/ + +Using files in models +===================== + +When you use a `FileField`_ or `ImageField`_, Django provides a set of APIs you can use to deal with that file. + +.. _filefield: ../model-api/#filefield +.. _imagefield: ../model-api/#imagefield + +Consider the following model, using a ``FileField`` to store a photo:: + + class Car(models.Model): + name = models.CharField(max_length=255) + price = models.DecimalField(max_digits=5, decimal_places=2) + photo = models.ImageField(upload_to='cars') + +Any ``Car`` instance will have a ``photo`` attribute that you can use to get at +the details of the attached photo:: + + >>> car = Car.object.get(name="57 Chevy") + >>> car.photo + + >>> car.photo.name + u'chevy.jpg' + >>> car.photo.path + u'/media/cars/chevy.jpg' + >>> car.photo.url + u'http://media.example.com/cars/chevy.jpg' + +This object -- ``car.photo`` in the example -- is a ``File`` object, which means +it has all the methods and attributes described below. + +The ``File`` object +=================== + +Internally, Django uses a ``django.core.files.File`` any time it needs to +represent a file. This object is a thin wrapper around Python's `built-in file +object`_ with some Django-specific additions. + +.. _built-in file object: http://docs.python.org/lib/bltin-file-objects.html + +Creating ``File`` instances +--------------------------- + +Most of the time you'll simply use a ``File`` that Django's given you (i.e. a +file attached to an model as above, or perhaps an `uploaded file`_). + +.. _uploaded file: ../uploading_files/ + +If you need to construct a ``File`` yourself, the easiest way is to create one +using a Python built-in ``file`` object:: + + >>> from django.core.files import File + + # Create a Python file object using open() + >>> f = open('/tmp/hello.world', 'w') + >>> myfile = File(f) + +Now you can use any of the ``File`` attributes and methods defined below. + +``File`` attributes and methods +------------------------------- + +Django's ``File`` has the following attributes and methods: + +``File.path`` +~~~~~~~~~~~~~ + +The absolute path to the file's location on a local filesystem. + +Custom `file storage systems`_ may not store files locally; files stored on +these systems will have a ``path`` of ``None``. + +``File.url`` +~~~~~~~~~~~~ + +The URL where the file can be retrieved. This is often useful in templates_; for +example, a bit of a template for displaying a ``Car`` (see above) might look +like:: + + {{ car.name }} + +.. _templates: ../templates/ + +``File.size`` +~~~~~~~~~~~~~ + +The size of the file in bytes. + +``File.open(mode=None)`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +Open or reopen the file (which by definition also does ``File.seek(0)``). The +``mode`` argument allows the same values as Python's standard ``open()``. + +When reopening a file, ``mode`` will override whatever mode the file was +originally opened with; ``None`` means to reopen with the original mode. + +``File.read(num_bytes=None)`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Read content from the file. The optional ``size`` is the number of bytes to +read; if not specified, the file will be read to the end. + +``File.__iter__()`` +~~~~~~~~~~~~~~~~~~~ + +Iterate over the file yielding one line at a time. + +``File.chunks(chunk_size=None)`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Iterate over the file yielding "chunks" of a given size. ``chunk_size`` defaults +to 64 KB. + +This is especially useful with very large files since it allows them to be +streamed off disk and avoids storing the whole file in memory. + +``File.multiple_chunks(chunk_size=None)`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Returns ``True`` if the file is large enough to require multiple chunks to +access all of its content give some ``chunk_size``. + +``File.write(content)`` +~~~~~~~~~~~~~~~~~~~~~~~ + +Writes the specified content string to the file. Depending on the storage system +behind the scenes, this content might not be fully committed until ``close()`` +is called on the file. + +``File.close()`` +~~~~~~~~~~~~~~~~ + +Close the file. + +.. TODO: document the rest of the File methods. + +Additional ``ImageField`` attributes +------------------------------------ + +``File.width`` and ``File.height`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +These attributes provide the dimensions of the image. + +Additional methods on files attached to objects +----------------------------------------------- + +Any ``File`` that's associated with an object (as with ``Car.photo``, above) +will also have a couple of extra methods: + +``File.save(name, content, save=True)`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Saves a new file with the file name and contents provided. This will not replace +the existing file, but will create a new file and update the object to point to +it. If ``save`` is ``True``, the model's ``save()`` method will be called once +the file is saved. That is, these two lines:: + + >>> car.photo.save('myphoto.jpg', contents, save=False) + >>> car.save() + +are the same as this one line:: + + >>> car.photo.save('myphoto.jpg', contents, save=True) + +``File.delete(save=True)`` +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Remove the file from the model instance and delete the underlying file. The +``save`` argument works as above. + +File storage +============ + +Behind the scenes, Django delegates decisions about how and where to store files +to a file storage system. This is the object that actually understands things +like file systems, opening and reading files, etc. + +Django's default file storage is given by the `DEFAULT_FILE_STORAGE setting`_; +if you don't explicitly provide a storage system, this is the one that will be +used. + +.. _default_file_storage setting: ../settings/#default-file-storage + +The built-in filesystem storage class +------------------------------------- + +Django ships with a built-in ``FileSystemStorage`` class (defined in +``django.core.files.storage``) which implements basic local filesystem file +storage. Its initializer takes two arguments: + +====================== =================================================== +Argument Description +====================== =================================================== +``location`` Optional. Absolute path to the directory that will + hold the files. If omitted, it will be set to the + value of your ``MEDIA_ROOT`` setting. +``base_url`` Optional. URL that serves the files stored at this + location. If omitted, it will default to the value + of your ``MEDIA_URL`` setting. +====================== =================================================== + +For example, the following code will store uploaded files under +``/media/photos`` regardless of what your ``MEDIA_ROOT`` setting is:: + + from django.db import models + from django.core.files.storage import FileSystemStorage + + fs = FileSystemStorage(base_url='/media/photos') + + class Car(models.Model): + ... + photo = models.ImageField(storage=fs) + +`Custom storage systems`_ work the same way: you can pass them in as the +``storage`` argument to a ``FileField``. + +.. _custom storage systems: `writing a custom storage system`_ + +Storage objects +--------------- + +Though most of the time you'll want to use a ``File`` object (which delegates to +the proper storage for that file), you can use file storage systems directly. +You can create an instance of some custom file storage class, or -- often more +useful -- you can use the global default storage system:: + + >>> from django.core.files.storage import default_storage + + >>> path = default_storage.save('/path/to/file', 'new content') + >>> path + u'/path/to/file' + + >>> default_storage.filesize(path) + 11 + >>> default_storage.open(path).read() + 'new content' + + >>> default_storage.delete(path) + >>> default_storage.exists(path) + False + +Storage objects define the following methods: + +``Storage.exists(name)`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +``True`` if a file exists given some ``name``. + +``Storge.path(name)`` +~~~~~~~~~~~~~~~~~~~~~ + +The local filesystem path where the file can be opened using Python's standard +``open()``. For storage systems that aren't accessible from the local +filesystem, this will raise ``NotImplementedError`` instead. + +``Storage.size(name)`` +~~~~~~~~~~~~~~~~~~~~~~ + +Returns the total size, in bytes, of the file referenced by ``name``. + +``Storage.url(name)`` +~~~~~~~~~~~~~~~~~~~~~ + +Returns the URL where the contents of the file referenced by ``name`` can be +accessed. + +``Storage.open(name, mode='rb')`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Opens the file given by ``name``. Note that although the returned file is +guaranteed to be a ``File`` object, it might actually be some subclass. In the +case of remote file storage this means that reading/writing could be quite slow, +so be warned. + +``Storage.save(name, content)`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Saves a new file using the storage system, preferably with the name specified. +If there already exists a file with this name ``name``, the storage system may +modify the filename as necessary to get a unique name. The actual name of the +stored file will be returned. + +``Storage.delete(name)`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +Deletes the file referenced by ``name``. This method won't raise an exception if +the file doesn't exist. + +Writing a custom storage system +=============================== + +If you need to provide custom file storage -- a common example is storing files +on some remote system -- you can do so by defining a custom storage class. +You'll need to follow these steps: + +#. Your custom storage system must be a subclass of + ``django.core.files.storage.Storage``:: + + from django.core.files.storage import Storage + + class MyStorage(Storage): + ... + +#. Django must be able to instantiate your storage system without any arguments. + This means that any settings should be taken from ``django.conf.settings``:: + + from django.conf import settings + from django.core.files.storage import Storage + + class MyStorage(Storage): + def __init__(self, option=None): + if not option: + option = settings.CUSTOM_STORAGE_OPTIONS + ... + +#. Your storage class must implement the ``_open()`` and ``_save()`` methods, + along with any other methods appropriate to your storage class. See below for + more on these methods. + + In addition, if your class provides local file storage, it must override + the ``path()`` method. + +Custom storage system methods +----------------------------- + +Your custom storage system may override any of the storage methods explained +above in `storage objects`_. However, it's usually better to use the hooks +specifically designed for custom storage objects. These are: + +``_open(name, mode='rb')`` +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Required**. + +Called by ``Storage.open()``, this is the actual mechanism the storage class +uses to open the file. This must return a ``File`` object, though in most cases, +you'll want to return some subclass here that implements logic specific to the +backend storage system. + +``_save(name, content)`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +Called by ``Storage.save()``. The ``name`` will already have gone through +``get_valid_name()`` and ``get_available_name()``, and the ``content`` will be a +``File`` object itself. No return value is expected. + +``get_valid_name(name)`` +------------------------ + +Returns a filename suitable for use with the underlying storage system. The +``name`` argument passed to this method is the original filename sent to the +server, after having any path information removed. Override this to customize +how non-standard characters are converted to safe filenames. + +The code provided on ``Storage`` retains only alpha-numeric characters, periods +and underscores from the original filename, removing everything else. + +``get_available_name(name)`` +---------------------------- + +Returns a filename that is available in the storage mechanism, possibly taking +the provided filename into account. The ``name`` argument passed to this method +will have already cleaned to a filename valid for the storage system, according +to the ``get_valid_name()`` method described above. + +The code provided on ``Storage`` simply appends underscores to the filename +until it finds one that's available in the destination directory. \ No newline at end of file diff --git a/docs/model-api.txt b/docs/model-api.txt index 93b27b8c11..da5584e2bc 100644 --- a/docs/model-api.txt +++ b/docs/model-api.txt @@ -224,26 +224,64 @@ set to 75 by default, but you can specify it to override default behavior. ``FileField`` ~~~~~~~~~~~~~ -A file-upload field. Has one **required** argument: +A file-upload field. Has two special arguments, of which the first is +**required**: ====================== =================================================== Argument Description ====================== =================================================== - ``upload_to`` A local filesystem path that will be appended to - your ``MEDIA_ROOT`` setting to determine the - output of the ``get__url()`` helper - function. + ``upload_to`` Required. A filesystem-style path that will be + prepended to the filename before being committed to + the final storage destination. + + **New in Django development version** + + This may also be a callable, such as a function, + which will be called to obtain the upload path, + including the filename. See below for details. + + ``storage`` **New in Django development version** + + Optional. A storage object, which handles the + storage and retrieval of your files. See `managing + files`_ for details on how to provide this object. ====================== =================================================== -This path may contain `strftime formatting`_, which will be replaced by the -date/time of the file upload (so that uploaded files don't fill up the given -directory). +.. _managing files: ../files/ + +The ``upload_to`` path may contain `strftime formatting`_, which will be +replaced by the date/time of the file upload (so that uploaded files don't fill +up the given directory). + +**New in Django development version** + +If a callable is provided for the ``upload_to`` argument, that callable must be +able to accept two arguments, and return a Unix-style path (with forward +slashes) to be passed along to the storage system. The two arguments that will +be passed are: + + ====================== =================================================== + Argument Description + ====================== =================================================== + ``instance`` An instance of the model where the ``FileField`` is + defined. More specifically, this is the particular + instance where the current file is being attached. + + **Note**: In most cases, this object will not have + been saved to the database yet, so if it uses the + default ``AutoField``, *it might not yet have a + value for its primary key field*. + + ``filename`` The filename that was originally given to the file. + This may or may not be taken into account when + determining the final destination path. + ====================== =================================================== The admin represents this field as an ```` (a file-upload widget). -Using a ``FileField`` or an ``ImageField`` (see below) in a model takes a few -steps: +Using a ``FileField`` or an ``ImageField`` (see below) in a model without a +specified storage system takes a few steps: 1. In your settings file, you'll need to define ``MEDIA_ROOT`` as the full path to a directory where you'd like Django to store uploaded diff --git a/docs/settings.txt b/docs/settings.txt index 1d1627d41e..3b3e908fab 100644 --- a/docs/settings.txt +++ b/docs/settings.txt @@ -426,6 +426,16 @@ Default content type to use for all ``HttpResponse`` objects, if a MIME type isn't manually specified. Used with ``DEFAULT_CHARSET`` to construct the ``Content-Type`` header. +DEFAULT_FILE_STORAGE +-------------------- + +Default: ``'django.core.filestorage.filesystem.FileSystemStorage'`` + +Default file storage class to be used for any file-related operations that don't +specify a particular storage system. See the `file documentation`_ for details. + +.. _file documentation: ../files/ + DEFAULT_FROM_EMAIL ------------------ diff --git a/docs/upload_handling.txt b/docs/upload_handling.txt index c0e8605686..488778a4e4 100644 --- a/docs/upload_handling.txt +++ b/docs/upload_handling.txt @@ -155,25 +155,8 @@ Three `settings`_ control Django's file upload behavior: ``UploadedFile`` objects ======================== -All ``UploadedFile`` objects define the following methods/attributes: - - ``UploadedFile.read(self, num_bytes=None)`` - Returns a byte string of length ``num_bytes``, or the complete file if - ``num_bytes`` is ``None``. - - ``UploadedFile.chunks(self, chunk_size=None)`` - A generator yielding small chunks from the file. If ``chunk_size`` isn't - given, chunks will be 64 KB. - - ``UploadedFile.multiple_chunks(self, chunk_size=None)`` - Returns ``True`` if you can expect more than one chunk when calling - ``UploadedFile.chunks(self, chunk_size)``. - - ``UploadedFile.size`` - The size, in bytes, of the uploaded file. - - ``UploadedFile.name`` - The name of the uploaded file as provided by the user. +In addition to those inherited from `File`_, all ``UploadedFile`` objects define +the following methods/attributes: ``UploadedFile.content_type`` The content-type header uploaded with the file (e.g. ``text/plain`` or @@ -186,13 +169,11 @@ All ``UploadedFile`` objects define the following methods/attributes: For ``text/*`` content-types, the character set (i.e. ``utf8``) supplied by the browser. Again, "trust but verify" is the best policy here. - ``UploadedFile.__iter__()`` - Iterates over the lines in the file. - ``UploadedFile.temporary_file_path()`` Only files uploaded onto disk will have this method; it returns the full path to the temporary uploaded file. +.. _File: ../files/ Upload Handlers =============== diff --git a/tests/modeltests/files/__init__.py b/tests/modeltests/files/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/tests/modeltests/files/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/modeltests/files/models.py b/tests/modeltests/files/models.py new file mode 100644 index 0000000000..a2ee5a7256 --- /dev/null +++ b/tests/modeltests/files/models.py @@ -0,0 +1,118 @@ +""" +42. Storing files according to a custom storage system + +FileField and its variations can take a "storage" argument to specify how and +where files should be stored. +""" + +import tempfile + +from django.db import models +from django.core.files.base import ContentFile +from django.core.files.storage import FileSystemStorage +from django.core.cache import cache + +temp_storage = FileSystemStorage(location=tempfile.gettempdir()) + +# Write out a file to be used as default content +temp_storage.save('tests/default.txt', ContentFile('default content')) + +class Storage(models.Model): + def custom_upload_to(self, filename): + return 'foo' + + def random_upload_to(self, filename): + # This returns a different result each time, + # to make sure it only gets called once. + import random + return '%s/%s' % (random.randint(100, 999), filename) + + normal = models.FileField(storage=temp_storage, upload_to='tests') + custom = models.FileField(storage=temp_storage, upload_to=custom_upload_to) + random = models.FileField(storage=temp_storage, upload_to=random_upload_to) + default = models.FileField(storage=temp_storage, upload_to='tests', default='tests/default.txt') + +__test__ = {'API_TESTS':""" +# An object without a file has limited functionality. + +>>> obj1 = Storage() +>>> obj1.normal + +>>> obj1.normal.size +Traceback (most recent call last): +... +ValueError: The 'normal' attribute has no file associated with it. + +# Saving a file enables full functionality. + +>>> obj1.normal.save('django_test.txt', ContentFile('content')) +>>> obj1.normal + +>>> obj1.normal.size +7 +>>> obj1.normal.read() +'content' + +# Files can be read in a little at a time, if necessary. + +>>> obj1.normal.open() +>>> obj1.normal.read(3) +'con' +>>> obj1.normal.read() +'tent' +>>> '-'.join(obj1.normal.chunks(chunk_size=2)) +'co-nt-en-t' + +# Save another file with the same name. + +>>> obj2 = Storage() +>>> obj2.normal.save('django_test.txt', ContentFile('more content')) +>>> obj2.normal + +>>> obj2.normal.size +12 + +# Push the objects into the cache to make sure they pickle properly + +>>> cache.set('obj1', obj1) +>>> cache.set('obj2', obj2) +>>> cache.get('obj2').normal + + +# Deleting an object deletes the file it uses, if there are no other objects +# still using that file. + +>>> obj2.delete() +>>> obj2.normal.save('django_test.txt', ContentFile('more content')) +>>> obj2.normal + + +# Default values allow an object to access a single file. + +>>> obj3 = Storage.objects.create() +>>> obj3.default + +>>> obj3.default.read() +'default content' + +# But it shouldn't be deleted, even if there are no more objects using it. + +>>> obj3.delete() +>>> obj3 = Storage() +>>> obj3.default.read() +'default content' + +# Verify the fix for #5655, making sure the directory is only determined once. + +>>> obj4 = Storage() +>>> obj4.random.save('random_file', ContentFile('random content')) +>>> obj4.random + + +# Clean up the temporary files. + +>>> obj1.normal.delete() +>>> obj2.normal.delete() +>>> obj3.default.delete() +>>> obj4.random.delete() +"""} diff --git a/tests/modeltests/model_forms/models.py b/tests/modeltests/model_forms/models.py index be2a8ba835..3463cb7554 100644 --- a/tests/modeltests/model_forms/models.py +++ b/tests/modeltests/model_forms/models.py @@ -11,6 +11,9 @@ import os import tempfile from django.db import models +from django.core.files.storage import FileSystemStorage + +temp_storage = FileSystemStorage(tempfile.gettempdir()) ARTICLE_STATUS = ( (1, 'Draft'), @@ -60,7 +63,7 @@ class PhoneNumber(models.Model): class TextFile(models.Model): description = models.CharField(max_length=20) - file = models.FileField(upload_to=tempfile.gettempdir()) + file = models.FileField(storage=temp_storage, upload_to='tests') def __unicode__(self): return self.description @@ -73,9 +76,9 @@ class ImageFile(models.Model): # for PyPy, you need to check for the underlying modules # If PIL is not available, this test is equivalent to TextFile above. import Image, _imaging - image = models.ImageField(upload_to=tempfile.gettempdir()) + image = models.ImageField(storage=temp_storage, upload_to='tests') except ImportError: - image = models.FileField(upload_to=tempfile.gettempdir()) + image = models.FileField(storage=temp_storage, upload_to='tests') def __unicode__(self): return self.description @@ -786,6 +789,8 @@ u'Assistance' # FileField ################################################################### +# File forms. + >>> class TextFileForm(ModelForm): ... class Meta: ... model = TextFile @@ -808,9 +813,9 @@ True >>> instance = f.save() >>> instance.file -u'...test1.txt' + ->>> os.unlink(instance.get_file_filename()) +>>> instance.file.delete() >>> f = TextFileForm(data={'description': u'Assistance'}, files={'file': SimpleUploadedFile('test1.txt', 'hello world')}) >>> f.is_valid() @@ -819,7 +824,7 @@ True >>> instance = f.save() >>> instance.file -u'...test1.txt' + # Edit an instance that already has the file defined in the model. This will not # save the file again, but leave it exactly as it is. @@ -828,13 +833,13 @@ u'...test1.txt' >>> f.is_valid() True >>> f.cleaned_data['file'] -u'...test1.txt' + >>> instance = f.save() >>> instance.file -u'...test1.txt' + # Delete the current file since this is not done by Django. ->>> os.unlink(instance.get_file_filename()) +>>> instance.file.delete() # Override the file by uploading a new one. @@ -843,20 +848,20 @@ u'...test1.txt' True >>> instance = f.save() >>> instance.file -u'...test2.txt' + # Delete the current file since this is not done by Django. ->>> os.unlink(instance.get_file_filename()) +>>> instance.file.delete() >>> f = TextFileForm(data={'description': u'Assistance'}, files={'file': SimpleUploadedFile('test2.txt', 'hello world')}) >>> f.is_valid() True >>> instance = f.save() >>> instance.file -u'...test2.txt' + # Delete the current file since this is not done by Django. ->>> os.unlink(instance.get_file_filename()) +>>> instance.file.delete() >>> instance.delete() @@ -868,17 +873,17 @@ u'...test2.txt' True >>> instance = f.save() >>> instance.file -'' + >>> f = TextFileForm(data={'description': u'Assistance'}, files={'file': SimpleUploadedFile('test3.txt', 'hello world')}, instance=instance) >>> f.is_valid() True >>> instance = f.save() >>> instance.file -u'...test3.txt' + # Delete the current file since this is not done by Django. ->>> os.unlink(instance.get_file_filename()) +>>> instance.file.delete() >>> instance.delete() >>> f = TextFileForm(data={'description': u'Assistance'}, files={'file': SimpleUploadedFile('test3.txt', 'hello world')}) @@ -886,10 +891,10 @@ u'...test3.txt' True >>> instance = f.save() >>> instance.file -u'...test3.txt' + # Delete the current file since this is not done by Django. ->>> os.unlink(instance.get_file_filename()) +>>> instance.file.delete() >>> instance.delete() # ImageField ################################################################### @@ -911,10 +916,10 @@ True >>> instance = f.save() >>> instance.image -u'...test.png' + # Delete the current file since this is not done by Django. ->>> os.unlink(instance.get_image_filename()) +>>> instance.image.delete() >>> f = ImageFileForm(data={'description': u'An image'}, files={'image': SimpleUploadedFile('test.png', image_data)}) >>> f.is_valid() @@ -923,7 +928,7 @@ True >>> instance = f.save() >>> instance.image -u'...test.png' + # Edit an instance that already has the image defined in the model. This will not # save the image again, but leave it exactly as it is. @@ -932,14 +937,14 @@ u'...test.png' >>> f.is_valid() True >>> f.cleaned_data['image'] -u'...test.png' + >>> instance = f.save() >>> instance.image -u'...test.png' + # Delete the current image since this is not done by Django. ->>> os.unlink(instance.get_image_filename()) +>>> instance.image.delete() # Override the file by uploading a new one. @@ -948,10 +953,10 @@ u'...test.png' True >>> instance = f.save() >>> instance.image -u'...test2.png' + # Delete the current file since this is not done by Django. ->>> os.unlink(instance.get_image_filename()) +>>> instance.image.delete() >>> instance.delete() >>> f = ImageFileForm(data={'description': u'Changed it'}, files={'image': SimpleUploadedFile('test2.png', image_data)}) @@ -959,10 +964,10 @@ u'...test2.png' True >>> instance = f.save() >>> instance.image -u'...test2.png' + # Delete the current file since this is not done by Django. ->>> os.unlink(instance.get_image_filename()) +>>> instance.image.delete() >>> instance.delete() # Test the non-required ImageField @@ -973,17 +978,17 @@ u'...test2.png' True >>> instance = f.save() >>> instance.image -'' + >>> f = ImageFileForm(data={'description': u'And a final one'}, files={'image': SimpleUploadedFile('test3.png', image_data)}, instance=instance) >>> f.is_valid() True >>> instance = f.save() >>> instance.image -u'...test3.png' + # Delete the current file since this is not done by Django. ->>> os.unlink(instance.get_image_filename()) +>>> instance.image.delete() >>> instance.delete() >>> f = ImageFileForm(data={'description': u'And a final one'}, files={'image': SimpleUploadedFile('test3.png', image_data)}) @@ -991,7 +996,7 @@ u'...test3.png' True >>> instance = f.save() >>> instance.image -u'...test3.png' + >>> instance.delete() # Media on a ModelForm ######################################################## diff --git a/tests/regressiontests/admin_widgets/models.py b/tests/regressiontests/admin_widgets/models.py index 584d973c83..544806f819 100644 --- a/tests/regressiontests/admin_widgets/models.py +++ b/tests/regressiontests/admin_widgets/models.py @@ -1,6 +1,7 @@ from django.conf import settings from django.db import models +from django.core.files.storage import default_storage class Member(models.Model): name = models.CharField(max_length=100) @@ -18,6 +19,7 @@ class Band(models.Model): class Album(models.Model): band = models.ForeignKey(Band) name = models.CharField(max_length=100) + cover_art = models.ImageField(upload_to='albums') def __unicode__(self): return self.name @@ -46,12 +48,12 @@ HTML escaped. >>> print conditional_escape(w.render('test', datetime(2007, 12, 1, 9, 30)))

Date:
Time:

->>> w = AdminFileWidget() ->>> print conditional_escape(w.render('test', 'test')) -Currently: test
Change: - >>> band = Band.objects.create(pk=1, name='Linkin Park') ->>> album = band.album_set.create(name='Hybrid Theory') +>>> album = band.album_set.create(name='Hybrid Theory', cover_art=r'albums\hybrid_theory.jpg') + +>>> w = AdminFileWidget() +>>> print conditional_escape(w.render('test', album.cover_art)) +Currently: albums\hybrid_theory.jpg
Change: >>> rel = Album._meta.get_field('band').rel >>> w = ForeignKeyRawIdWidget(rel) @@ -81,5 +83,5 @@ True """ % { 'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX, - 'MEDIA_URL': settings.MEDIA_URL, + 'STORAGE_URL': default_storage.url(''), }} diff --git a/tests/regressiontests/bug639/models.py b/tests/regressiontests/bug639/models.py index fc241aba8c..22dd440fc3 100644 --- a/tests/regressiontests/bug639/models.py +++ b/tests/regressiontests/bug639/models.py @@ -1,16 +1,20 @@ import tempfile + from django.db import models +from django.core.files.storage import FileSystemStorage + +temp_storage = FileSystemStorage(tempfile.gettempdir()) class Photo(models.Model): title = models.CharField(max_length=30) - image = models.FileField(upload_to=tempfile.gettempdir()) + image = models.FileField(storage=temp_storage, upload_to='tests') # Support code for the tests; this keeps track of how many times save() gets # called on each instance. def __init__(self, *args, **kwargs): - super(Photo, self).__init__(*args, **kwargs) - self._savecount = 0 + super(Photo, self).__init__(*args, **kwargs) + self._savecount = 0 def save(self): super(Photo, self).save() - self._savecount +=1 \ No newline at end of file + self._savecount += 1 diff --git a/tests/regressiontests/bug639/tests.py b/tests/regressiontests/bug639/tests.py index 2726dec897..69e4a3ba3b 100644 --- a/tests/regressiontests/bug639/tests.py +++ b/tests/regressiontests/bug639/tests.py @@ -36,4 +36,4 @@ class Bug639Test(unittest.TestCase): Make sure to delete the "uploaded" file to avoid clogging /tmp. """ p = Photo.objects.get() - os.unlink(p.get_image_filename()) + p.image.delete(save=False) diff --git a/tests/regressiontests/file_storage/__init__.py b/tests/regressiontests/file_storage/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/tests/regressiontests/file_storage/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/regressiontests/file_storage/models.py b/tests/regressiontests/file_storage/models.py new file mode 100644 index 0000000000..8cd8b9e56c --- /dev/null +++ b/tests/regressiontests/file_storage/models.py @@ -0,0 +1,44 @@ +import os +import tempfile +from django.db import models +from django.core.files.storage import FileSystemStorage +from django.core.files.base import ContentFile + +temp_storage = FileSystemStorage(tempfile.gettempdir()) + +# Test for correct behavior of width_field/height_field. +# Of course, we can't run this without PIL. + +try: + # Checking for the existence of Image is enough for CPython, but + # for PyPy, you need to check for the underlying modules + import Image, _imaging +except ImportError: + Image = None + +# If we have PIL, do these tests +if Image: + class Person(models.Model): + name = models.CharField(max_length=50) + mugshot = models.ImageField(storage=temp_storage, upload_to='tests', + height_field='mug_height', + width_field='mug_width') + mug_height = models.PositiveSmallIntegerField() + mug_width = models.PositiveSmallIntegerField() + + __test__ = {'API_TESTS': """ + +>>> image_data = open(os.path.join(os.path.dirname(__file__), "test.png"), 'rb').read() +>>> p = Person(name="Joe") +>>> p.mugshot.save("mug", ContentFile(image_data)) +>>> p.mugshot.width +16 +>>> p.mugshot.height +16 +>>> p.mug_height +16 +>>> p.mug_width +16 + +"""} + \ No newline at end of file diff --git a/tests/regressiontests/file_storage/test.png b/tests/regressiontests/file_storage/test.png new file mode 100644 index 0000000000000000000000000000000000000000..4f17cd075d0ad42a7e64f0f7b1a0232a9a7c35a4 GIT binary patch literal 482 zcmV<80UiE{P)1#%%7O+Pik(-92n&yf8QeS9 z!ef{&akk(2oxXFvLw#dI3meP$TRAMn!k4V+YD`Q-8osIfXGg5tElY+ir)P82Em(Mm z7dJ=TM%nl#NxA?Lfr#EZB#9|BR|klTi4`me01*IdMo+XE+7{G*Q+UXhxP;aXi zECT?&@AYov6G)7W2XK6G@_Q5^;PU1gfF(CS{9GTi!!6CMx_TNr9Tt0DcFwKuUX~01 zjB*4M0Z@Z#I54-qs0P)#qN=Qv%yd(#|0!^Kb)I2L1MuK~d{JI>a&UP)F0Y4(1Pet~ zm}sE27r@Tp-fzKanx?3VVA0>wZ8H}JwczB+Y-lTUo9(BA$daL~zw3jhEB literal 0 HcmV?d00001 diff --git a/tests/regressiontests/file_storage/tests.py b/tests/regressiontests/file_storage/tests.py new file mode 100644 index 0000000000..a4503d9805 --- /dev/null +++ b/tests/regressiontests/file_storage/tests.py @@ -0,0 +1,66 @@ +""" +Tests for the file storage mechanism + +>>> import tempfile +>>> from django.core.files.storage import FileSystemStorage +>>> from django.core.files.base import ContentFile + +>>> temp_storage = FileSystemStorage(location=tempfile.gettempdir()) + +# Standard file access options are available, and work as expected. + +>>> temp_storage.exists('storage_test') +False +>>> file = temp_storage.open('storage_test', 'w') +>>> file.write('storage contents') +>>> file.close() + +>>> temp_storage.exists('storage_test') +True +>>> file = temp_storage.open('storage_test', 'r') +>>> file.read() +'storage contents' +>>> file.close() + +>>> temp_storage.delete('storage_test') +>>> temp_storage.exists('storage_test') +False + +# Files can only be accessed if they're below the specified location. + +>>> temp_storage.exists('..') +Traceback (most recent call last): +... +SuspiciousOperation: Attempted access to '..' denied. +>>> temp_storage.open('/etc/passwd') +Traceback (most recent call last): + ... +SuspiciousOperation: Attempted access to '/etc/passwd' denied. + +# Custom storage systems can be created to customize behavior + +>>> class CustomStorage(FileSystemStorage): +... def get_available_name(self, name): +... # Append numbers to duplicate files rather than underscores, like Trac +... +... parts = name.split('.') +... basename, ext = parts[0], parts[1:] +... number = 2 +... +... while self.exists(name): +... name = '.'.join([basename, str(number)] + ext) +... number += 1 +... +... return name +>>> custom_storage = CustomStorage(tempfile.gettempdir()) + +>>> first = custom_storage.save('custom_storage', ContentFile('custom contents')) +>>> first +u'custom_storage' +>>> second = custom_storage.save('custom_storage', ContentFile('more contents')) +>>> second +u'custom_storage.2' + +>>> custom_storage.delete(first) +>>> custom_storage.delete(second) +""" diff --git a/tests/regressiontests/file_uploads/models.py b/tests/regressiontests/file_uploads/models.py index 3701750afe..9d020509af 100644 --- a/tests/regressiontests/file_uploads/models.py +++ b/tests/regressiontests/file_uploads/models.py @@ -1,9 +1,10 @@ import tempfile import os from django.db import models +from django.core.files.storage import FileSystemStorage -UPLOAD_ROOT = tempfile.mkdtemp() -UPLOAD_TO = os.path.join(UPLOAD_ROOT, 'test_upload') +temp_storage = FileSystemStorage(tempfile.mkdtemp()) +UPLOAD_TO = os.path.join(temp_storage.location, 'test_upload') class FileModel(models.Model): - testfile = models.FileField(upload_to=UPLOAD_TO) + testfile = models.FileField(storage=temp_storage, upload_to='test_upload') diff --git a/tests/regressiontests/file_uploads/tests.py b/tests/regressiontests/file_uploads/tests.py index dd6b7c4181..7c8b53ea89 100644 --- a/tests/regressiontests/file_uploads/tests.py +++ b/tests/regressiontests/file_uploads/tests.py @@ -9,7 +9,7 @@ from django.test import TestCase, client from django.utils import simplejson from django.utils.hashcompat import sha_constructor -from models import FileModel, UPLOAD_ROOT, UPLOAD_TO +from models import FileModel, temp_storage, UPLOAD_TO class FileUploadTests(TestCase): def test_simple_upload(self): @@ -194,22 +194,22 @@ class DirectoryCreationTests(unittest.TestCase): """ def setUp(self): self.obj = FileModel() - if not os.path.isdir(UPLOAD_ROOT): - os.makedirs(UPLOAD_ROOT) + if not os.path.isdir(temp_storage.location): + os.makedirs(temp_storage.location) def tearDown(self): - os.chmod(UPLOAD_ROOT, 0700) - shutil.rmtree(UPLOAD_ROOT) + os.chmod(temp_storage.location, 0700) + shutil.rmtree(temp_storage.location) def test_readonly_root(self): """Permission errors are not swallowed""" - os.chmod(UPLOAD_ROOT, 0500) + os.chmod(temp_storage.location, 0500) try: - self.obj.save_testfile_file('foo.txt', SimpleUploadedFile('foo.txt', 'x')) + self.obj.testfile.save('foo.txt', SimpleUploadedFile('foo.txt', 'x')) except OSError, err: self.assertEquals(err.errno, errno.EACCES) - except: - self.fail("OSError [Errno %s] not raised" % errno.EACCES) + except Exception, err: + self.fail("OSError [Errno %s] not raised." % errno.EACCES) def test_not_a_directory(self): """The correct IOError is raised when the upload directory name exists but isn't a directory""" @@ -217,11 +217,11 @@ class DirectoryCreationTests(unittest.TestCase): fd = open(UPLOAD_TO, 'w') fd.close() try: - self.obj.save_testfile_file('foo.txt', SimpleUploadedFile('foo.txt', 'x')) + self.obj.testfile.save('foo.txt', SimpleUploadedFile('foo.txt', 'x')) except IOError, err: # The test needs to be done on a specific string as IOError # is raised even without the patch (just not early enough) self.assertEquals(err.args[0], - "%s exists and is not a directory" % UPLOAD_TO) + "%s exists and is not a directory." % UPLOAD_TO) except: self.fail("IOError not raised") diff --git a/tests/regressiontests/serializers_regress/models.py b/tests/regressiontests/serializers_regress/models.py index 7d3f9d3b1d..4e2fbb1ee2 100644 --- a/tests/regressiontests/serializers_regress/models.py +++ b/tests/regressiontests/serializers_regress/models.py @@ -157,8 +157,8 @@ class DecimalPKData(models.Model): class EmailPKData(models.Model): data = models.EmailField(primary_key=True) -class FilePKData(models.Model): - data = models.FileField(primary_key=True, upload_to='/foo/bar') +# class FilePKData(models.Model): +# data = models.FileField(primary_key=True, upload_to='/foo/bar') class FilePathPKData(models.Model): data = models.FilePathField(primary_key=True) diff --git a/tests/regressiontests/serializers_regress/tests.py b/tests/regressiontests/serializers_regress/tests.py index f990d57a17..7a38af4cf9 100644 --- a/tests/regressiontests/serializers_regress/tests.py +++ b/tests/regressiontests/serializers_regress/tests.py @@ -144,7 +144,7 @@ test_data = [ (data_obj, 41, EmailData, None), (data_obj, 42, EmailData, ""), (data_obj, 50, FileData, 'file:///foo/bar/whiz.txt'), - (data_obj, 51, FileData, None), +# (data_obj, 51, FileData, None), (data_obj, 52, FileData, ""), (data_obj, 60, FilePathData, "/foo/bar/whiz.txt"), (data_obj, 61, FilePathData, None), @@ -242,7 +242,7 @@ The end."""), # (pk_obj, 620, DatePKData, datetime.date(2006,6,16)), # (pk_obj, 630, DateTimePKData, datetime.datetime(2006,6,16,10,42,37)), (pk_obj, 640, EmailPKData, "hovercraft@example.com"), - (pk_obj, 650, FilePKData, 'file:///foo/bar/whiz.txt'), +# (pk_obj, 650, FilePKData, 'file:///foo/bar/whiz.txt'), (pk_obj, 660, FilePathPKData, "/foo/bar/whiz.txt"), (pk_obj, 670, DecimalPKData, decimal.Decimal('12.345')), (pk_obj, 671, DecimalPKData, decimal.Decimal('-12.345')),