Merge pull request #24 from novagile/23-file-wrapper

Refs #21 and closes #23 - Download view passes a file wrapper to download response.
This commit is contained in:
Benoît Bryon 2013-02-06 11:16:54 -08:00
commit c671037db0
23 changed files with 857 additions and 370 deletions

View file

@ -8,3 +8,4 @@ Original code by `Novapost <http://www.novapost.fr>`_ team:
* Lauréline Guérin <https://github.com/zebuline>
* Gregory Tappero <https://github.com/coulix>
* Benoît Bryon <benoit@marmelune.net>
* Rémy Hubscher <remy.hubscher@novapost.fr>

View file

@ -4,7 +4,12 @@ Changelog
1.1 (unreleased)
----------------
- Nothing changed yet.
**Backward incompatible changes.**
- Download views and response now use file wrappers. Most logic around file
attributes, formerly in views, moved to wrappers.
- Replaced DownloadView by PathDownloadView and StorageDownloadView. Use the
right one depending on the use case.
1.0 (2012-12-04)

View file

@ -6,10 +6,19 @@ ROOT_DIR = $(shell pwd)
DATA_DIR = $(ROOT_DIR)/var
WGET = wget
PYTHON = python
BUILDOUT_BOOTSTRAP_URL = https://raw.github.com/buildout/buildout/1.6.3/bootstrap/bootstrap.py
BUILDOUT_CFG = $(ROOT_DIR)/etc/buildout.cfg
BUILDOUT_BOOTSTRAP_URL = https://raw.github.com/buildout/buildout/1.7.0/bootstrap/bootstrap.py
BUILDOUT_BOOTSTRAP = $(ROOT_DIR)/lib/buildout/bootstrap.py
BUILDOUT_BOOTSTRAP_ARGS = -c $(BUILDOUT_CFG) --distribute buildout:directory=$(ROOT_DIR)
BUILDOUT = $(ROOT_DIR)/bin/buildout
BUILDOUT_ARGS = -N
BUILDOUT_ARGS = -N -c $(BUILDOUT_CFG) buildout:directory=$(ROOT_DIR)
configure:
# Configuration is stored in etc/ folder.
develop: buildout
buildout:
@ -20,18 +29,12 @@ buildout:
fi
# Bootstrap buildout.
if [ ! -f $(BUILDOUT) ]; then \
$(PYTHON) $(BUILDOUT_BOOTSTRAP) --distribute; \
$(PYTHON) $(BUILDOUT_BOOTSTRAP) $(BUILDOUT_BOOTSTRAP_ARGS); \
fi
# Run zc.buildout.
$(BUILDOUT) $(BUILDOUT_ARGS)
develop: buildout
update: develop
clean:
find $(ROOT_DIR)/ -name "*.pyc" -delete
find $(ROOT_DIR)/ -name ".noseids" -delete
@ -47,8 +50,16 @@ maintainer-clean: distclean
rm -rf $(ROOT_DIR)/lib/
test:
test: test-demo test-documentation
test-demo:
bin/demo test demo
mv $(ROOT_DIR)/.coverage $(ROOT_DIR)/var/test/demo.coverage
test-documentation:
bin/nosetests -c $(ROOT_DIR)/etc/nose.cfg sphinxcontrib.testbuild.tests
apidoc:

13
README
View file

@ -1,5 +1,5 @@
###################
Django-DownloadView
django-downloadview
###################
Django-DownloadView provides (class-based) generic download views for Django.
@ -19,6 +19,17 @@ Example, in some urls.py:
url_patterns = ('',
url('^download/(?P<slug>[A-Za-z0-9_-]+)/$', download, name='download'),
)
Several views are provided to cover frequent use cases:
* ``ObjectDownloadView`` when you have a model with a file field.
* ``StorageDownloadView`` when you manage files in a storage.
* ``PathDownloadView`` when you have an absolute filename on local filesystem.
* ``VirtualDownloadView`` when you the file is generated on the fly.
See :doc:`views` for details.
Then get increased performances with :doc:`optimizations/index`.
**********

View file

@ -1,9 +1,10 @@
# coding=utf8
"""Test suite for demoproject.download."""
from os import listdir
from os.path import abspath, dirname, join
from django.core.files import File
from django.core.urlresolvers import reverse_lazy as reverse
from django.core.urlresolvers import reverse
from django.test import TestCase
from django_downloadview.test import temporary_media_root
@ -24,15 +25,8 @@ class DownloadTestCase(TestCase):
for f in listdir(fixtures_dir):
self.files[f] = abspath(join(fixtures_dir, f))
class DownloadViewTestCase(DownloadTestCase):
"""Test generic DownloadView."""
def test_download_hello_world(self):
"""'download_hello_world' view returns hello-world.txt as attachement.
"""
download_url = reverse('download_hello_world')
response = self.client.get(download_url)
def assertDownloadHelloWorld(self, response, is_attachment=True):
"""Assert response is 'hello-world.txt' download."""
self.assertEquals(response.status_code, 200)
self.assertEquals(response['Content-Type'],
'text/plain; charset=utf-8')
@ -43,23 +37,53 @@ class DownloadViewTestCase(DownloadTestCase):
response.content)
class PathDownloadViewTestCase(DownloadTestCase):
"""Test "hello_world" view."""
def test_download_hello_world(self):
"""hello_world view returns hello-world.txt as attachement."""
download_url = reverse('hello_world')
response = self.client.get(download_url)
self.assertDownloadHelloWorld(response)
class CustomPathDownloadViewTestCase(DownloadTestCase):
"""Test "fixture_from_path" view."""
def test_download_hello_world(self):
"""fixture_from_path view returns hello-world.txt as attachement."""
download_url = reverse('fixture_from_path', args=['hello-world.txt'])
response = self.client.get(download_url)
self.assertDownloadHelloWorld(response)
class StorageDownloadViewTestCase(DownloadTestCase):
"""Test "fixture_from_storage" view."""
def test_download_hello_world(self):
"""fixture_from_storage view returns hello-world.txt as attachement."""
download_url = reverse('fixture_from_storage',
args=['hello-world.txt'])
response = self.client.get(download_url)
self.assertDownloadHelloWorld(response)
class ObjectDownloadViewTestCase(DownloadTestCase):
"""Test generic ObjectDownloadView."""
@temporary_media_root()
def test_download_hello_world(self):
"""'download_document' view returns hello-world.txt as attachement."""
slug = 'hello-world'
download_url = reverse('download_document', kwargs={'slug': slug})
document = Document.objects.create(
slug=slug,
file=File(open(self.files['hello-world.txt'])),
)
slug = 'hello-world'
download_url = reverse('document', kwargs={'slug': slug})
Document.objects.create(slug=slug,
file=File(open(self.files['hello-world.txt'])))
response = self.client.get(download_url)
self.assertEquals(response.status_code, 200)
self.assertEquals(response['Content-Type'],
'text/plain; charset=utf-8')
self.assertFalse('ContentEncoding' in response)
self.assertEquals(response['Content-Disposition'],
'attachment; filename=hello-world.txt')
self.assertEqual(open(self.files['hello-world.txt']).read(),
response.content)
self.assertDownloadHelloWorld(response)
class GeneratedDownloadViewTestCase(DownloadTestCase):
"""Test "generated_hello_world" view."""
def test_download_hello_world(self):
"""generated_hello_world view returns hello-world.txt as attachement.
"""
download_url = reverse('generated_hello_world')
response = self.client.get(download_url)
self.assertDownloadHelloWorld(response)

View file

@ -1,10 +1,27 @@
# coding=utf8
"""URL mapping."""
from django.conf.urls import patterns, include, url
from django.conf.urls import patterns, url
urlpatterns = patterns('demoproject.download.views',
url(r'^hello-world\.txt$', 'download_hello_world',
name='download_hello_world'),
url(r'^document/(?P<slug>[a-zA-Z0-9_-]+)/$', 'download_document',
name='download_document'),
urlpatterns = patterns(
'demoproject.download.views',
# Model-based downloads.
url(r'^document/(?P<slug>[a-zA-Z0-9_-]+)/$',
'download_document',
name='document'),
# Storage-based downloads.
url(r'^storage/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$',
'download_fixture_from_storage',
name='fixture_from_storage'),
# Path-based downloads.
url(r'^hello-world\.txt$',
'download_hello_world',
name='hello_world'),
url(r'^path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$',
'download_fixture_from_path',
name='fixture_from_path'),
# Generated downloads.
url(r'^generated/hello-world\.txt$',
'download_generated_hello_world',
name='generated_hello_world'),
)

View file

@ -1,16 +1,78 @@
# coding=utf8
"""Demo download views."""
from cStringIO import StringIO
from os.path import abspath, dirname, join
from django_downloadview import DownloadView, ObjectDownloadView
from django.core.files.storage import FileSystemStorage
from django_downloadview.files import VirtualFile
from django_downloadview.views import (ObjectDownloadView,
PathDownloadView,
StorageDownloadView,
VirtualDownloadView)
from demoproject.download.models import Document
# Some initializations.
#: Directory containing code of :py:module:`demoproject.download.views`.
app_dir = dirname(abspath(__file__))
#: Directory containing files fixtures.
fixtures_dir = join(app_dir, 'fixtures')
hello_world_file = join(fixtures_dir, 'hello-world.txt')
#: Path to a text file that says 'Hello world!'.
hello_world_path = join(fixtures_dir, 'hello-world.txt')
#: Storage for fixtures.
fixtures_storage = FileSystemStorage(location=fixtures_dir)
download_hello_world = DownloadView.as_view(filename=hello_world_file,
storage=None)
# Here are the views.
#: Pre-configured download view for :py:class:`Document` model.
download_document = ObjectDownloadView.as_view(model=Document)
#: Pre-configured view using a storage.
download_fixture_from_storage = StorageDownloadView.as_view(
storage=fixtures_storage)
#: Direct download of one file, based on an absolute path.
#:
#: You could use this example as a shortcut, inside other views.
download_hello_world = PathDownloadView.as_view(path=hello_world_path)
class CustomPathDownloadView(PathDownloadView):
"""Example of customized PathDownloadView."""
def get_path(self):
"""Convert relative path (provided in URL) into absolute path.
Notice that this particularly simple use case is covered by
:py:class:`django_downloadview.views.StorageDownloadView`.
.. warning::
If you are doing such things, make the path secure! Prevent users
to download files anywhere in the filesystem.
"""
path = super(CustomPathDownloadView, self).get_path()
return join(fixtures_dir, path)
#: Pre-configured :py:class:`CustomPathDownloadView`.
download_fixture_from_path = CustomPathDownloadView.as_view()
class StringIODownloadView(VirtualDownloadView):
"""Sample download view using StringIO object."""
def get_file(self):
"""Return wrapper on StringIO object."""
file_obj = StringIO(u"Hello world!\n")
return VirtualFile(file_obj, name='hello-world.txt')
#: Pre-configured view that serves "Hello world!" via a StringIO.
download_generated_hello_world = StringIODownloadView.as_view()

View file

@ -65,14 +65,14 @@ MIDDLEWARE_CLASSES = [
#NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL = "/proxied-download"
# Development configuratio.
# Development configuration.
DEBUG = True
TEMPLATE_DEBUG = DEBUG
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
NOSE_ARGS = ['--verbose',
'--nocapture',
'--rednose',
'--with-id', # allows --failed which only reruns failed tests
'--with-id', # allows --failed which only reruns failed tests
'--id-file=%s' % join(data_dir, 'test', 'noseids'),
'--with-doctest',
'--with-xunit',
@ -81,4 +81,5 @@ NOSE_ARGS = ['--verbose',
'--cover-erase',
'--cover-package=django_downloadview',
'--no-path-adjustment',
]
'--all-modules',
]

View file

@ -7,9 +7,21 @@
<h1>Welcome to django-downloadview demo!</h1>
<p>Here are some demo links. Browse the code to see how they are implemented</p>
<ul>
<li><a href="{% url 'download_hello_world' %}">DownloadView</a></li>
<li><a href="{% url 'download_document' 'hello-world' %}">ObjectDownloadView</a></li>
<li><a href="{% url 'download_document_nginx' 'hello-world' %}">ObjectDownloadView decorated with nginx X-Accel-Redirect</a> (better if served behind nginx)</li>
<li><a href="{% url 'hello_world' %}">
Direct download to one file using PathDownloadView.
</a></li>
<li><a href="{% url 'fixture_from_path' 'hello-world.txt' %}">
Download files using PathDownloadView and relative path in URL.
</a></li>
<li><a href="{% url 'fixture_from_storage' 'hello-world.txt' %}">
Download files using StorageDownloadView and path in URL.
</a></li>
<li><a href="{% url 'document' 'hello-world' %}">
ObjectDownloadView
</a></li>
<li><a href="{% url 'download_document_nginx' 'hello-world' %}">
ObjectDownloadView decorated with nginx X-Accel-Redirect
</a> to be served behind Nginx</li>
</ul>
</body>
</html>

13
demo/demoproject/tests.py Normal file
View file

@ -0,0 +1,13 @@
# coding=utf8
"""Test suite for demoproject.download."""
from django.core.urlresolvers import reverse
from django.test import TestCase
class HomeViewTestCase(TestCase):
"""Test homepage."""
def test_get(self):
"""Homepage returns HTTP 200."""
home_url = reverse('home')
response = self.client.get(home_url)
self.assertEqual(response.status_code, 200)

View file

@ -5,11 +5,12 @@ from django.views.generic import TemplateView
home = TemplateView.as_view(template_name='home.html')
urlpatterns = patterns('',
urlpatterns = patterns(
'',
# Standard download views.
url(r'^download/', include('demoproject.download.urls')),
# Nginx optimizations.
url(r'^nginx/', include('demoproject.nginx.urls')),
# An informative page.
# An informative homepage.
url(r'', home, name='home')
)

View file

@ -1,5 +1,9 @@
"""django-downloadview provides generic download views for Django."""
from django_downloadview.views import DownloadView, ObjectDownloadView
# Shortcut import.
from django_downloadview.views import (PathDownloadView,
ObjectDownloadView,
StorageDownloadView,
VirtualDownloadView)
pkg_resources = __import__('pkg_resources')

View file

@ -0,0 +1,168 @@
"""File wrappers for use as exchange data between views and responses."""
from django.core.files import File
class StorageFile(File):
"""A file in a Django storage.
This class looks like :py:class:`django.db.models.fields.files.FieldFile`,
but unrelated to model instance.
"""
def __init__(self, storage, name, file=None):
"""Constructor.
storage:
Some :py:class:`django.core.files.storage.Storage` instance.
name:
File identifier in storage, usually a filename as a string.
"""
self.storage = storage
self.name = name
self.file = file
def _get_file(self):
"""Getter for :py:attr:``file`` property."""
if not hasattr(self, '_file') or self._file is None:
self._file = self.storage.open(self.name, 'rb')
return self._file
def _set_file(self, file):
"""Setter for :py:attr:``file`` property."""
self._file = file
def _del_file(self):
"""Deleter for :py:attr:``file`` property."""
del self._file
#: Required by django.core.files.utils.FileProxy.
file = property(_get_file, _set_file, _del_file)
def open(self, mode='rb'):
"""Retrieves the specified file from storage and return open() result.
Proxy to self.storage.open(self.name, mode).
"""
return self.storage.open(self.name, mode)
def save(self, content):
"""Saves new content to the file.
Proxy to self.storage.save(self.name).
The content should be a proper File object, ready to be read from the
beginning.
"""
return self.storage.save(self.name, content)
@property
def path(self):
"""Return a local filesystem path which is suitable for open().
Proxy to self.storage.path(self.name).
May raise NotImplementedError if storage doesn't support file access
with Python's built-in open() function
"""
return self.storage.path(self.name)
def delete(self):
"""Delete the specified file from the storage system.
Proxy to self.storage.delete(self.name).
"""
return self.storage.delete(self.name)
def exists(self):
"""Return True if file already exists in the storage system.
If False, then the name is available for a new file.
"""
return self.storage.exists(self.name)
@property
def size(self):
"""Return the total size, in bytes, of the file.
Proxy to self.storage.size(self.name).
"""
return self.storage.size(self.name)
@property
def url(self):
"""Return an absolute URL where the file's contents can be accessed.
Proxy to self.storage.url(self.name).
"""
return self.storage.url(self.name)
@property
def accessed_time(self):
"""Return the last accessed time (as datetime object) of the file.
Proxy to self.storage.accessed_time(self.name).
"""
return self.storage.accessed(self.name)
@property
def created_time(self):
"""Return the creation time (as datetime object) of the file.
Proxy to self.storage.created_time(self.name).
"""
return self.storage.created_time(self.name)
@property
def modified_time(self):
"""Return the last modification time (as datetime object) of the file.
Proxy to self.storage.modified_time(self.name).
"""
return self.storage.modified_time(self.name)
class VirtualFile(File):
def __init__(self, file=None, name=u'', url='', size=None):
"""Constructor.
file:
File object. Typically a StringIO.
name:
File basename.
url:
File URL.
"""
super(VirtualFile, self).__init__(file, name)
self.url = url
if size is not None:
self._size = size
def _get_size(self):
try:
return self._size
except AttributeError:
try:
self._size = self.file.size
except AttributeError:
self._size = len(self.file.getvalue())
return self._size
def _set_size(self, value):
return super(VirtualFile, self)._set_size(value)
size = property(_get_size, _set_size)

View file

@ -19,8 +19,7 @@ class BaseDownloadMiddleware(object):
return is_download_response(response)
def process_response(self, request, response):
"""Call :py:meth:`process_download_response` if ``response`` is
download."""
"""Call :py:meth:`process_download_response` if ``response`` is download."""
if self.is_download_response(response):
return self.process_download_response(request, response)
return response

View file

@ -6,6 +6,8 @@ See also `Nginx X-accel documentation <http://wiki.nginx.org/X-accel>`_ and
"""
from datetime import datetime, timedelta
import os
import warnings
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
@ -24,7 +26,7 @@ from django_downloadview.utils import content_type_to_charset
#:
#: Default value is None, which means "let Nginx choose", i.e. use Nginx
#: defaults or specific configuration.
#:
#:
#: If set to ``False``, Nginx buffering is disabled.
#: If set to ``True``, Nginx buffering is enabled.
DEFAULT_WITH_BUFFERING = None
@ -40,12 +42,13 @@ if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING'):
#:
#: Default value is None, which means "let Nginx choose", i.e. use Nginx
#: defaults or specific configuration.
#:
#:
#: If set to ``False``, Nginx limit rate is disabled.
#: Else, it indicates the limit rate in bytes.
DEFAULT_LIMIT_RATE = None
if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE'):
setattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE', DEFAULT_LIMIT_RATE)
setattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE',
DEFAULT_LIMIT_RATE)
#: Default value for X-Accel-Limit-Expires header.
@ -55,7 +58,7 @@ if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE'):
#:
#: Default value is None, which means "let Nginx choose", i.e. use Nginx
#: defaults or specific configuration.
#:
#:
#: If set to ``False``, Nginx buffering is disabled.
#: Else, it indicates the expiration delay, in seconds.
DEFAULT_EXPIRES = None
@ -63,6 +66,40 @@ if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES'):
setattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES', DEFAULT_EXPIRES)
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR.
DEFAULT_SOURCE_DIR = settings.MEDIA_ROOT
if hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT'):
warnings.warn("settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT is "
"deprecated, use "
"settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR instead.",
DeprecationWarning)
DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT
if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR'):
setattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR',
DEFAULT_SOURCE_DIR)
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL.
DEFAULT_SOURCE_URL = settings.MEDIA_URL
if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL'):
setattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL',
DEFAULT_SOURCE_URL)
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL.
DEFAULT_DESTINATION_URL = None
if hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL'):
warnings.warn("settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL is "
"deprecated, use "
"settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL "
"instead.",
DeprecationWarning)
DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL
if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL'):
setattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL',
DEFAULT_DESTINATION_URL)
class XAccelRedirectResponse(HttpResponse):
"""Http response that delegates serving file to Nginx."""
def __init__(self, redirect_url, content_type, basename=None, expires=None,
@ -81,8 +118,9 @@ class XAccelRedirectResponse(HttpResponse):
elif expires is not None: # We explicitely want it off.
self['X-Accel-Expires'] = 'off'
if limit_rate is not None:
self['X-Accel-Limit-Rate'] = limit_rate and '%d' % limit_rate \
or 'off'
self['X-Accel-Limit-Rate'] = (limit_rate
and '%d' % limit_rate
or 'off')
class XAccelRedirectValidator(object):
@ -95,33 +133,33 @@ class XAccelRedirectValidator(object):
"""Assert that ``response`` is a valid X-Accel-Redirect response.
Optional ``assertions`` dictionary can be used to check additional
items:
items:
* ``basename``: the basename of the file in the response.
* ``content_type``: the value of "Content-Type" header.
* ``redirect_url``: the value of "X-Accel-Redirect" header.
* ``charset``: the value of ``X-Accel-Charset`` header.
* ``with_buffering``: the value of ``X-Accel-Buffering`` header.
If ``False``, then makes sure that the header disables buffering.
If ``None``, then makes sure that the header is not set.
* ``expires``: the value of ``X-Accel-Expires`` header.
If ``False``, then makes sure that the header disables expiration.
If ``None``, then makes sure that the header is not set.
* ``limit_rate``: the value of ``X-Accel-Limit-Rate`` header.
If ``False``, then makes sure that the header disables limit rate.
If ``None``, then makes sure that the header is not set.
"""
self.assert_x_accel_redirect_response(test_case, response)
self.assert_x_accel_redirect_response(test_case, response)
for key, value in assertions.iteritems():
assert_func = getattr(self, 'assert_%s' % key)
assert_func(test_case, response, value)
assert_func(test_case, response, value)
def assert_x_accel_redirect_response(self, test_case, response):
test_case.assertTrue(isinstance(response, XAccelRedirectResponse))
@ -205,21 +243,92 @@ class BaseXAccelRedirectMiddleware(BaseDownloadMiddleware):
:py:class:`django_downloadview.decorators.DownloadDecorator`.
"""
def __init__(self, media_root, media_url, expires=None,
with_buffering=None, limit_rate=None):
def __init__(self, source_dir=None, source_url=None, destination_url=None,
expires=None, with_buffering=None, limit_rate=None,
media_root=None, media_url=None):
"""Constructor."""
self.media_root = media_root
self.media_url = media_url
if media_url is not None:
warnings.warn("%s ``media_url`` is deprecated. Use "
"``destination_url`` instead."
% self.__class__.__name__,
DeprecationWarning)
if destination_url is None:
self.destination_url = media_url
else:
self.destination_url = destination_url
else:
self.destination_url = destination_url
if media_root is not None:
warnings.warn("%s ``media_root`` is deprecated. Use "
"``source_dir`` instead." % self.__class__.__name__,
DeprecationWarning)
if source_dir is None:
self.source_dir = media_root
else:
self.source_dir = source_dir
else:
self.source_dir = source_dir
self.source_url = source_url
self.expires = expires
self.with_buffering = with_buffering
self.limit_rate = limit_rate
def is_download_response(self, response):
"""Return True for DownloadResponse, except for "virtual" files.
This implementation can't handle files that live in memory or which are
to be dynamically iterated over. So, we capture only responses whose
file attribute have either an URL or a file name.
"""
if super(BaseXAccelRedirectMiddleware,
self).is_download_response(response):
try:
response.file.url
except AttributeError:
try:
response.file.name
except AttributeError:
return False
return True
return False
def get_redirect_url(self, response):
"""Return redirect URL for file wrapped into response."""
absolute_filename = response.filename
relative_filename = absolute_filename[len(self.media_root):]
return '/'.join((self.media_url.rstrip('/'),
relative_filename.strip('/')))
url = None
file_url = ''
if self.source_url is not None:
try:
file_url = response.file.url
except AttributeError:
pass
else:
if file_url.startswith(self.source_url):
file_url = file_url[len(self.source_url):]
url = file_url
file_name = ''
if url is None and self.source_dir is not None:
try:
file_name = response.file.name
except AttributeError:
pass
else:
if file_name.startswith(self.source_dir):
file_name = os.path.relpath(file_name, self.source_dir)
url = file_name.replace(os.path.sep, '/')
if url is None:
message = ("""Couldn't capture/convert file attributes into a """
"""redirection. """
"""``source_url`` is "%(source_url)s", """
"""file's URL is "%(file_url)s". """
"""``source_dir`` is "%(source_dir)s", """
"""file's name is "%(file_name)s". """
% {'source_url': self.source_url,
'file_url': file_url,
'source_dir': self.source_dir,
'file_name': file_name})
raise ImproperlyConfigured(message)
return '/'.join((self.destination_url.rstrip('/'), url.lstrip('/')))
def process_download_response(self, request, response):
"""Replace DownloadResponse instances by NginxDownloadResponse ones."""
@ -240,24 +349,49 @@ class BaseXAccelRedirectMiddleware(BaseDownloadMiddleware):
class XAccelRedirectMiddleware(BaseXAccelRedirectMiddleware):
"""Apply X-Accel-Redirect globally, via Django settings."""
"""Apply X-Accel-Redirect globally, via Django settings.
Available settings are:
NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL:
The string at the beginning of URLs to replace with
``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``.
If ``None``, then URLs aren't captured.
Defaults to ``settings.MEDIA_URL``.
NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR:
The string at the beginning of filenames (path) to replace with
``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``.
If ``None``, then filenames aren't captured.
Defaults to ``settings.MEDIA_ROOT``.
NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL:
The base URL where requests are proxied to.
If ``None`` an ImproperlyConfigured exception is raised.
.. note::
The following settings are deprecated since version 1.1.
URLs can be used as redirection source since 1.1, and then "MEDIA_ROOT"
and "MEDIA_URL" became too confuse.
NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT:
Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR``.
NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL:
Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``.
"""
def __init__(self):
"""Use Django settings as configuration."""
try:
media_root = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT
except AttributeError:
if settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is None:
raise ImproperlyConfigured(
'settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT is required by '
'%s middleware' % self.__class__.name)
try:
media_url = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL
except AttributeError:
raise ImproperlyConfigured(
'settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL is required by '
'%s middleware' % self.__class__.name)
'settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is '
'required by %s middleware' % self.__class__.__name__)
super(XAccelRedirectMiddleware, self).__init__(
media_root,
media_url,
source_dir=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR,
source_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL,
destination_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL,
expires=settings.NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES,
with_buffering=settings.NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING,
limit_rate=settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE)

View file

@ -1,4 +1,8 @@
"""HttpResponse subclasses."""
import os
import mimetypes
from django.conf import settings
from django.http import HttpResponse
@ -9,64 +13,111 @@ class DownloadResponse(HttpResponse):
this response "lazy".
"""
def __init__(self, content, content_type, content_length, basename,
status=200, content_encoding=None, expires=None,
filename=None, url=None):
def __init__(self, file_instance, attachment=True, basename=None,
status=200, content_type=None):
"""Constructor.
It differs a bit from HttpResponse constructor.
Required arguments:
file_instance:
A file wrapper object. Could be a FieldFile.
* ``content`` is supposed to be an iterable that can read the file.
Consider :py:class:`wsgiref.util.FileWrapper`` as a good candidate.
attachement:
Boolean, whether to return the file as attachment or not. Affects
"Content-Disposition" header.
Defaults to ``True``.
* ``content_type`` contains mime-type and charset of the file.
It is used as "Content-Type" header.
basename:
Unicode. Only used if ``attachment`` is ``True``. Client-side name
of the file to stream. Affects "Content-Disposition" header.
Defaults to basename(``file_instance.name``).
* ``content_length`` is the size, in bytes, of the file.
It is used as "Content-Length" header.
status:
HTTP status code.
Defaults to 200.
* ``basename`` is the client-side name of the file ("save as" name).
It is used in "Content-Disposition" header.
Optional arguments:
* ``status`` is HTTP status code.
* ``content_encoding`` is used for "Content-Encoding" header.
* ``expires`` is a datetime.
It is used to set the "Expires" header.
* ``filename`` is the server-side name of the file.
It may be used by decorators or middlewares.
* ``url`` is the actual URL of the file content.
* If Django is to serve the file, then ``url`` should be
``request.get_full_path()``. This should be the default behaviour
when ``url`` is None.
* If ``url`` is not None and differs from
``request.get_full_path()``, then it means that the actual download
should be performed at another location. In that case,
DownloadResponse doesn't return a redirection, but ``url`` may be
caught and used by download middlewares or decorators (Nginx,
Lighttpd...).
content_type:
Value for "Content-Type" header.
If ``None``, then mime-type and encoding will be populated by the
response (default implementation uses mimetypes, based on file name).
Defaults is ``None``.
"""
super(DownloadResponse, self).__init__(content=content, status=status,
self.file = file_instance
super(DownloadResponse, self).__init__(content=self.file,
status=status,
content_type=content_type)
self.filename = filename
self.basename = basename
self['Content-Length'] = content_length
if content_encoding:
self['Content-Encoding'] = content_encoding
self.expires = expires
if expires:
self['Expires'] = expires
self['Content-Disposition'] = 'attachment; filename=%s' % basename
self.attachment = attachment
if not content_type:
del self['Content-Type'] # Will be set later.
# Apply default headers.
for header, value in self.default_headers.items():
if not header in self:
self[header] = value # Does self support setdefault?
@property
def default_headers(self):
"""Return dictionary of automatically-computed headers.
Uses an internal ``_default_headers`` cache.
Default values are computed if only cache hasn't been set.
"""
try:
return self._default_headers
except AttributeError:
headers = {}
headers['Content-Type'] = self.get_content_type()
headers['Content-Length'] = self.file.size
if self.attachment:
headers['Content-Disposition'] = 'attachment; filename=%s' \
% self.get_basename()
self._default_headers = headers
return self._default_headers
def items(self):
"""Return iterable of (header, value).
This method is called by http handlers just before WSGI's
start_response() is called... but it is not called by
django.test.ClientHandler! :'(
"""
return super(DownloadResponse, self).items()
def get_basename(self):
"""Return basename."""
if self.attachment and self.basename:
return self.basename
else:
return os.path.basename(self.file.name)
def get_content_type(self):
"""Return a suitable "Content-Type" header for ``self.file``."""
try:
return self.file.content_type
except AttributeError:
content_type_template = '%(mime_type)s; charset=%(charset)s'
return content_type_template % {'mime_type': self.get_mime_type(),
'charset': self.get_charset()}
def get_mime_type(self):
"""Return mime-type of the file."""
default_mime_type = 'application/octet-stream'
basename = self.get_basename()
mime_type, encoding = mimetypes.guess_type(basename)
return mime_type or default_mime_type
def get_encoding(self):
"""Return encoding of the file to serve."""
basename = self.get_basename()
mime_type, encoding = mimetypes.guess_type(basename)
return encoding
def get_charset(self):
"""Return the charset of the file to serve."""
return settings.DEFAULT_CHARSET
def is_download_response(response):

View file

@ -1,16 +1,12 @@
"""Views."""
import mimetypes
import os
from wsgiref.util import FileWrapper
from django.conf import settings
from django.core.files import File
from django.core.files.storage import DefaultStorage
from django.http import Http404, HttpResponseNotModified
from django.http import HttpResponseNotModified
from django.views.generic.base import View
from django.views.generic.detail import BaseDetailView
from django.views.static import was_modified_since
from django_downloadview.files import StorageFile
from django_downloadview.response import DownloadResponse
@ -31,161 +27,111 @@ class DownloadMixin(object):
#: Response class to be used in render_to_response().
response_class = DownloadResponse
#: Whether to return the response as attachment or not.
attachment = True
#: Client-side filename, if only file is returned as attachment.
basename = None
def get_file(self):
"""Return a django.core.files.File object, which is to be served."""
"""Return a file wrapper instance."""
raise NotImplementedError()
def get_filename(self):
"""Return server-side absolute filename of the file to serve.
"filename" is used server-side, whereas "basename" is the filename
that the client receives for download (i.e. used client side).
"""
file_obj = self.get_file()
return file_obj.name
def get_basename(self):
"""Return client-side filename, without path, of the file to be served.
return self.basename
"basename" is the filename that the client receives for download,
whereas "filename" is used server-side.
The base implementation returns the basename of the server-side
filename.
You may override this method to change the behavior.
"""
return os.path.basename(self.get_filename())
def get_file_wrapper(self):
"""Return a wsgiref.util.FileWrapper instance for the file to serve."""
try:
return self.file_wrapper
except AttributeError:
self.file_wrapper = FileWrapper(self.get_file())
return self.file_wrapper
def get_mime_type(self):
"""Return mime-type of the file to serve."""
try:
return self.mime_type
except AttributeError:
basename = self.get_basename()
self.mime_type, self.encoding = mimetypes.guess_type(basename)
if not self.mime_type:
self.mime_type = 'application/octet-stream'
return self.mime_type
def get_encoding(self):
"""Return encoding of the file to serve."""
try:
return self.encoding
except AttributeError:
filename = self.get_filename()
self.mime_type, self.encoding = mimetypes.guess_type(filename)
return self.encoding
def get_charset(self):
"""Return the charset of the file to serve."""
try:
return self.charset
except AttributeError:
self.charset = settings.DEFAULT_CHARSET
return self.charset
def get_modification_time(self):
"""Return last modification time of the file to serve."""
try:
return self.modification_time
except AttributeError:
self.stat = os.stat(self.get_filename())
self.modification_time = self.stat.st_mtime
return self.modification_time
def get_size(self):
"""Return the size (in bytes) of the file to serve."""
try:
return self.size
except AttributeError:
try:
self.size = self.stat.st_size
except AttributeError:
self.size = os.path.getsize(self.get_filename())
return self.size
def render_to_response(self, **kwargs):
def render_to_response(self, *args, **kwargs):
"""Returns a response with a file as attachment."""
mime_type = self.get_mime_type()
charset = self.get_charset()
content_type = '%s; charset=%s' % (mime_type, charset)
modification_time = self.get_modification_time()
size = self.get_size()
# Respect the If-Modified-Since header.
file_instance = self.get_file()
if_modified_since = self.request.META.get('HTTP_IF_MODIFIED_SINCE',
None)
if not was_modified_since(if_modified_since, modification_time, size):
return HttpResponseNotModified(content_type=content_type)
# Stream the file.
filename = self.get_filename()
basename = self.get_basename()
encoding = self.get_encoding()
wrapper = self.get_file_wrapper()
response_kwargs = {'content': wrapper,
'content_type': content_type,
'content_length': size,
'filename': filename,
'basename': basename,
'content_encoding': encoding,
'expires': None}
if if_modified_since is not None:
modification_time = file_instance.modified_time
size = file_instance.size
if not was_modified_since(if_modified_since, modification_time,
size):
content_type = file_instance.content_type
return HttpResponseNotModified(content_type=content_type)
# Return download response.
response_kwargs = {'file_instance': file_instance,
'attachment': self.attachment,
'basename': self.get_basename()}
response_kwargs.update(kwargs)
response = self.response_class(**response_kwargs)
# Do not close the file as response class may need it open: the wrapper
# is an iterator on the content of the file.
# Garbage collector will close the file.
return response
class DownloadView(DownloadMixin, View):
"""Download a file from storage and filename."""
#: Server-side name (including path) of the file to serve.
#:
#: If ``storage`` is not None, then the filename will be passed to the
#: storage, else filename is supposed to be an absolute filename of a file
#: located on the local filesystem.
filename = None
#: Storage to use to fetch the file.
#:
#: Defaults to Django's DefaultStorage(), which itself defaults to a
#: FileSystemStorage relative to settings.MEDIA_ROOT.
#:
#: The ``storage`` can be set to None, but you should use one. As an
#: example, storage classes may encapsulate some security checks
#: (FileSystemStorage actually refuses to serve files outside its root
#: location).
storage = DefaultStorage()
def get_file(self):
"""Use filename and storage to return file object to serve."""
try:
return self._file
except AttributeError:
try:
if self.storage:
self._file = self.storage.open(self.filename)
else:
self._file = File(open(self.filename))
return self._file
except IOError:
raise Http404()
class BaseDownloadView(DownloadMixin, View):
def get(self, request, *args, **kwargs):
"""Handle GET requests: stream a file."""
return self.render_to_response()
class PathDownloadView(BaseDownloadView):
"""Serve a file using filename."""
#: Server-side name (including path) of the file to serve.
#:
#: Filename is supposed to be an absolute filename of a file located on the
#: local filesystem.
path = None
#: Name of the URL argument that contains path.
path_url_kwarg = 'path'
def get_path(self):
"""Return actual path of the file to serve.
Default implementation simply returns view's :py:attr:`path`.
Override this method if you want custom implementation.
As an example, :py:attr:`path` could be relative and your custom
:py:meth:`get_path` implementation makes it absolute.
"""
return self.kwargs.get(self.path_url_kwarg, self.path)
def get_file(self):
"""Use path to return wrapper around file to serve."""
return File(open(self.get_path()))
class StorageDownloadView(PathDownloadView):
"""Serve a file using storage and filename."""
#: Storage the file to serve belongs to.
storage = DefaultStorage()
#: Path to the file to serve relative to storage.
path = None # Override docstring.
def get_path(self):
"""Return path of the file to serve, relative to storage.
Default implementation simply returns view's :py:attr:`path`.
Override this method if you want custom implementation.
"""
return super(StorageDownloadView, self).get_path()
def get_file(self):
"""Use path and storage to return wrapper around file to serve."""
return StorageFile(self.storage, self.get_path())
class VirtualDownloadView(BaseDownloadView):
"""Serve not-on-disk or generated-on-the-fly file.
Use this class to serve :py:class:`StringIO` files.
Override the :py:meth:`get_file` method to customize file wrapper.
"""
def get_file(self):
"""Return wrapper."""
raise NotImplementedError()
class ObjectDownloadView(DownloadMixin, BaseDetailView):
"""Download view for models which contain a FileField.
@ -227,75 +173,23 @@ class ObjectDownloadView(DownloadMixin, BaseDetailView):
#: Optional name of the model's attribute which contains the size.
size_field = None
def get_object(self):
"""Return model instance, using cache or a get_queryset()."""
try:
return self._object
except AttributeError:
self._object = super(ObjectDownloadView, self).get_object()
return self._object
object = property(get_object)
def get_fieldfile(self):
"""Return FieldFile instance (i.e. FileField attribute)."""
try:
return self.fieldfile
except AttributeError:
self.fieldfile = getattr(self.object, self.file_field)
return self.fieldfile
def get_file(self):
"""Return File instance."""
return self.get_fieldfile().file
def get_filename(self):
"""Return absolute filename."""
file_obj = self.get_file()
return file_obj.name
"""Return FieldFile instance."""
file_instance = getattr(self.object, self.file_field)
for field in ('encoding', 'mime_type', 'charset', 'modification_time',
'size'):
model_field = getattr(self, '%s_field' % field, False)
if model_field:
value = getattr(self.object, model_field)
setattr(file_instance, field, value)
return file_instance
def get_basename(self):
"""Return client-side filename."""
if self.basename_field:
return getattr(self.object, self.basename_field)
else:
return super(ObjectDownloadView, self).get_basename()
def get_mime_type(self):
"""Return mime-type."""
if self.mime_type_field:
return getattr(self.object, self.mime_type_field)
else:
return super(ObjectDownloadView, self).get_mime_type()
def get_charset(self):
"""Return charset of the file to serve."""
if self.charset_field:
return getattr(self.object, self.charset_field)
else:
return super(ObjectDownloadView, self).get_charset()
def get_encoding(self):
"""Return encoding of the file to serve."""
if self.encoding_field:
return getattr(self.object, self.encoding_field)
else:
return super(ObjectDownloadView, self).get_encoding()
def get_modification_time(self):
"""Return last modification time of the file to serve."""
if self.modification_time_field:
return getattr(self.object, self.modification_time_field)
else:
return super(ObjectDownloadView, self).get_modification_time()
def get_size(self):
"""Return size of the file to serve."""
if self.size_field:
return getattr(self.object, self.size_field)
else:
return self.get_fieldfile().size
def get(self, request, *args, **kwargs):
"""Handle GET requests: stream a file."""
return self.render_to_response()
basename = super(ObjectDownloadView, self).get_basename()
if basename is None:
field = 'basename'
model_field = getattr(self, '%s_field' % field, False)
if model_field:
basename = getattr(self.object, model_field)
return basename

View file

@ -17,6 +17,14 @@ django_downloadview Package
:undoc-members:
:show-inheritance:
:mod:`files` Module
-------------------
.. automodule:: django_downloadview.files
:members:
:undoc-members:
:show-inheritance:
:mod:`middlewares` Module
-------------------------

View file

@ -138,7 +138,12 @@ html_static_path = ['_static']
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
html_sidebars = {
'**': ['globaltoc.html',
'relations.html',
'sourcelink.html',
'searchbox.html'],
}
# Additional templates that should be rendered to pages, maps page names to
# template names.

View file

@ -10,16 +10,8 @@ Contents
demo
install
views
optimizations/index
api/index
about/index
dev
******************
Indices and tables
******************
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

59
docs/views.txt Normal file
View file

@ -0,0 +1,59 @@
##############
Download views
##############
This section contains narrative overview about class-based views provided by
django-downloadview.
******************
ObjectDownloadView
******************
The :py:class:`django_downloadview.views.ObjectDownloadView` class-based view
allows you to **serve files given a model with some file fields** such as
FileField or ImageField.
Use this view anywhere you could use Django's builtin ObjectDetailView.
Some options allow you to store file metadata (size, content-type, ...) in the
model, as deserialized fields.
*******************
StorageDownloadView
*******************
The :py:class:`django_downloadview.views.StorageDownloadView` class-based view
allows you to **serve files given a storage and a path**.
Use this view when you manage files in a storage (which is a good practice),
unrelated to a model.
****************
PathDownloadView
****************
The :py:class:`django_downloadview.views.PathDownloadView` class-based view
allows you to **serve files given an absolute path on local filesystem**.
Two main use cases:
* as a shortcut. This dead-simple view is straight to call, so you can use it
to simplify code in more complex views, provided you have an absolute path to
a local file.
* override. Extend :py:class:`django_downloadview.views.PathDownloadView` and
override :py:meth:`django_downloadview.views.PathDownloadView:get_path`.
*******************
VirtualDownloadView
*******************
The :py:class:`django_downloadview.views.VirtualDownloadView` class-based view
allows you to **serve files that don't live on disk**.
Use it when you want to stream a file which content is dynamically generated
or which lives in memory.

View file

@ -7,10 +7,15 @@ versions = versions
# of in current directory.
bin-directory = bin
develop-eggs-directory = lib/buildout/develop-eggs
downloads-directory = lib/buildout/downloads
eggs-directory = lib/buildout/eggs
installed = lib/buildout/.installed.cfg
parts-directory = lib/buildout/parts
# Package index, mirrors, allowed hosts and dependency links. Those options
# control locations where buildout looks for packages.
index = http://f.pypi.python.org/simple
find-links =
allow-hosts = *.python.org
use-dependency-links = false
# Development.
develop =
${buildout:directory}/
@ -29,7 +34,8 @@ eggs =
rednose
coverage
sphinx
initialization =
sphinxcontrib-testbuild
initialization =
import os
os.environ['DJANGO_SETTINGS_MODULE'] = 'demoproject.settings'
@ -45,19 +51,21 @@ recipe = z3c.recipe.scripts
eggs = zest.releaser
[versions]
Django = 1.4.2
Django = 1.4.3
Jinja2 = 2.6
Pygments = 1.6
Sphinx = 1.1.3
bpython = 0.11
bpython = 0.10.1
buildout-versions = 1.7
coverage = 3.5.2
nose = 1.1.2
coverage = 3.6
distribute = 0.6.34
docutils = 0.10
nose = 1.2.1
python-termstyle = 0.1.9
rednose = 0.3
sphinxcontrib-testbuild = 0.1.1
z3c.recipe.mkdir = 0.5
z3c.recipe.scripts = 1.0.1
zc.recipe.egg = 1.3.2
zest.releaser = 3.37
docutils = 0.9.1
Pygments = 1.5
zest.releaser = 3.43
django-nose = 1.1

7
etc/nose.cfg Normal file
View file

@ -0,0 +1,7 @@
[nosetests]
verbosity = 2
nocapture = True
with-doctest = True
rednose = True
no-path-adjustment = True
all-modules = True