From a012b11e9720a1586c58b915d22c5d362cacd43d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Thu, 13 Dec 2012 19:01:50 +0100 Subject: [PATCH 01/14] Refs #21 and refs #23 - Download view passes a file wrapper to download response. The file wrapper encapsulates file attributes such as name, size or URL (introduced URL). --- django_downloadview/files.py | 133 +++++++++++++++ django_downloadview/middlewares.py | 3 +- django_downloadview/nginx.py | 210 ++++++++++++++++++----- django_downloadview/response.py | 143 ++++++++++------ django_downloadview/views.py | 257 ++++++++--------------------- docs/api/django_downloadview.txt | 8 + 6 files changed, 476 insertions(+), 278 deletions(-) create mode 100644 django_downloadview/files.py diff --git a/django_downloadview/files.py b/django_downloadview/files.py new file mode 100644 index 0000000..40d9bdd --- /dev/null +++ b/django_downloadview/files.py @@ -0,0 +1,133 @@ +"""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) diff --git a/django_downloadview/middlewares.py b/django_downloadview/middlewares.py index bfe1210..1dd96fb 100644 --- a/django_downloadview/middlewares.py +++ b/django_downloadview/middlewares.py @@ -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 diff --git a/django_downloadview/nginx.py b/django_downloadview/nginx.py index 7c8c623..bdb7131 100644 --- a/django_downloadview/nginx.py +++ b/django_downloadview/nginx.py @@ -6,6 +6,8 @@ See also `Nginx X-accel documentation `_ 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) diff --git a/django_downloadview/response.py b/django_downloadview/response.py index eac6927..41e8353 100644 --- a/django_downloadview/response.py +++ b/django_downloadview/response.py @@ -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): diff --git a/django_downloadview/views.py b/django_downloadview/views.py index b258fc4..9792d82 100644 --- a/django_downloadview/views.py +++ b/django_downloadview/views.py @@ -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,123 +27,63 @@ 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): +class BaseDownloadView(DownloadMixin, View): + def get(self, request, *args, **kwargs): + """Handle GET requests: stream a file.""" + return self.render_to_response() + + +class StorageDownloadView(): + """Download a file from storage and filename.""" + storage = DefaultStorage() + path = None + + +class SimpleDownloadView(): + """Download a file from filename.""" + path = None + + +class VirtualDownloadView(): + file_obj = None + + +class DownloadView(BaseDownloadView): """Download a file from storage and filename.""" #: Server-side name (including path) of the file to serve. #: @@ -169,21 +105,10 @@ class DownloadView(DownloadMixin, View): 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() - - def get(self, request, *args, **kwargs): - """Handle GET requests: stream a file.""" - return self.render_to_response() + if self.storage: + return StorageFile(self.storage, self.filename) + else: + return File(open(self.filename)) class ObjectDownloadView(DownloadMixin, BaseDetailView): @@ -227,75 +152,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 diff --git a/docs/api/django_downloadview.txt b/docs/api/django_downloadview.txt index b919a52..e32220a 100644 --- a/docs/api/django_downloadview.txt +++ b/docs/api/django_downloadview.txt @@ -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 ------------------------- From f9fe4f3a2fb66eb72ead3dfcad3a8d212eb871e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20HUBSCHER?= Date: Tue, 5 Feb 2013 10:51:11 +0100 Subject: [PATCH 02/14] Improve DownloadView for cStringIO file download response --- django_downloadview/response.py | 30 ++++++++++++++++++++++-------- django_downloadview/views.py | 19 +++++++++++++++---- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/django_downloadview/response.py b/django_downloadview/response.py index 41e8353..3a6f45f 100644 --- a/django_downloadview/response.py +++ b/django_downloadview/response.py @@ -14,7 +14,7 @@ class DownloadResponse(HttpResponse): """ def __init__(self, file_instance, attachment=True, basename=None, - status=200, content_type=None): + status=200, content_type=None, size=None): """Constructor. It differs a bit from HttpResponse constructor. @@ -42,6 +42,9 @@ class DownloadResponse(HttpResponse): response (default implementation uses mimetypes, based on file name). Defaults is ``None``. + size: + Size of the file response + """ self.file = file_instance super(DownloadResponse, self).__init__(content=self.file, @@ -49,6 +52,8 @@ class DownloadResponse(HttpResponse): content_type=content_type) self.basename = basename self.attachment = attachment + self.content_type = content_type + self.size = size if not content_type: del self['Content-Type'] # Will be set later. # Apply default headers. @@ -69,7 +74,7 @@ class DownloadResponse(HttpResponse): except AttributeError: headers = {} headers['Content-Type'] = self.get_content_type() - headers['Content-Length'] = self.file.size + headers['Content-Length'] = self.get_size() if self.attachment: headers['Content-Disposition'] = 'attachment; filename=%s' \ % self.get_basename() @@ -95,12 +100,15 @@ class DownloadResponse(HttpResponse): 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()} + if not self.content_type: + 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()} + return self.content_type def get_mime_type(self): """Return mime-type of the file.""" @@ -119,6 +127,12 @@ class DownloadResponse(HttpResponse): """Return the charset of the file to serve.""" return settings.DEFAULT_CHARSET + def get_size(self): + """Return the size of the file to serve.""" + if not self.size: + self.size = self.file.size + return self.size + def is_download_response(response): """Return ``True`` if ``response`` is a download response. diff --git a/django_downloadview/views.py b/django_downloadview/views.py index 9792d82..101bb33 100644 --- a/django_downloadview/views.py +++ b/django_downloadview/views.py @@ -40,6 +40,15 @@ class DownloadMixin(object): def get_basename(self): return self.basename + def get_size(self): + return self.get_file().size + + def get_modification_time(self): + return self.get_file().modified_time + + def get_content_type(self): + return self.get_file().content_type + def render_to_response(self, *args, **kwargs): """Returns a response with a file as attachment.""" # Respect the If-Modified-Since header. @@ -47,16 +56,18 @@ class DownloadMixin(object): if_modified_since = self.request.META.get('HTTP_IF_MODIFIED_SINCE', None) if if_modified_since is not None: - modification_time = file_instance.modified_time - size = file_instance.size + modification_time = self.get_modification_time() + size = self.get_size() if not was_modified_since(if_modified_since, modification_time, size): - content_type = file_instance.content_type + content_type = self.get_content_type() return HttpResponseNotModified(content_type=content_type) # Return download response. response_kwargs = {'file_instance': file_instance, 'attachment': self.attachment, - 'basename': self.get_basename()} + 'basename': self.get_basename(), + 'size': self.get_size(), + 'content_type': self.get_content_type()} response_kwargs.update(kwargs) response = self.response_class(**response_kwargs) return response From f7dd4114d7a4472290a15c8d2b66820a88013094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20HUBSCHER?= Date: Tue, 5 Feb 2013 16:21:58 +0100 Subject: [PATCH 03/14] Fix error on content-type --- django_downloadview/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/django_downloadview/views.py b/django_downloadview/views.py index 101bb33..0875a8f 100644 --- a/django_downloadview/views.py +++ b/django_downloadview/views.py @@ -47,7 +47,10 @@ class DownloadMixin(object): return self.get_file().modified_time def get_content_type(self): - return self.get_file().content_type + try: + return self.get_file().content_type + except: + return None def render_to_response(self, *args, **kwargs): """Returns a response with a file as attachment.""" From d4dfbcffbe619e83955b602eb34f472d042dbc33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20HUBSCHER?= Date: Tue, 5 Feb 2013 16:49:43 +0100 Subject: [PATCH 04/14] Come back baby come back --- django_downloadview/response.py | 30 ++++++++---------------------- django_downloadview/views.py | 22 ++++------------------ 2 files changed, 12 insertions(+), 40 deletions(-) diff --git a/django_downloadview/response.py b/django_downloadview/response.py index 3a6f45f..41e8353 100644 --- a/django_downloadview/response.py +++ b/django_downloadview/response.py @@ -14,7 +14,7 @@ class DownloadResponse(HttpResponse): """ def __init__(self, file_instance, attachment=True, basename=None, - status=200, content_type=None, size=None): + status=200, content_type=None): """Constructor. It differs a bit from HttpResponse constructor. @@ -42,9 +42,6 @@ class DownloadResponse(HttpResponse): response (default implementation uses mimetypes, based on file name). Defaults is ``None``. - size: - Size of the file response - """ self.file = file_instance super(DownloadResponse, self).__init__(content=self.file, @@ -52,8 +49,6 @@ class DownloadResponse(HttpResponse): content_type=content_type) self.basename = basename self.attachment = attachment - self.content_type = content_type - self.size = size if not content_type: del self['Content-Type'] # Will be set later. # Apply default headers. @@ -74,7 +69,7 @@ class DownloadResponse(HttpResponse): except AttributeError: headers = {} headers['Content-Type'] = self.get_content_type() - headers['Content-Length'] = self.get_size() + headers['Content-Length'] = self.file.size if self.attachment: headers['Content-Disposition'] = 'attachment; filename=%s' \ % self.get_basename() @@ -100,15 +95,12 @@ class DownloadResponse(HttpResponse): def get_content_type(self): """Return a suitable "Content-Type" header for ``self.file``.""" - if not self.content_type: - 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()} - return self.content_type + 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.""" @@ -127,12 +119,6 @@ class DownloadResponse(HttpResponse): """Return the charset of the file to serve.""" return settings.DEFAULT_CHARSET - def get_size(self): - """Return the size of the file to serve.""" - if not self.size: - self.size = self.file.size - return self.size - def is_download_response(response): """Return ``True`` if ``response`` is a download response. diff --git a/django_downloadview/views.py b/django_downloadview/views.py index 0875a8f..9792d82 100644 --- a/django_downloadview/views.py +++ b/django_downloadview/views.py @@ -40,18 +40,6 @@ class DownloadMixin(object): def get_basename(self): return self.basename - def get_size(self): - return self.get_file().size - - def get_modification_time(self): - return self.get_file().modified_time - - def get_content_type(self): - try: - return self.get_file().content_type - except: - return None - def render_to_response(self, *args, **kwargs): """Returns a response with a file as attachment.""" # Respect the If-Modified-Since header. @@ -59,18 +47,16 @@ class DownloadMixin(object): if_modified_since = self.request.META.get('HTTP_IF_MODIFIED_SINCE', None) if if_modified_since is not None: - modification_time = self.get_modification_time() - size = self.get_size() + modification_time = file_instance.modified_time + size = file_instance.size if not was_modified_since(if_modified_since, modification_time, size): - content_type = self.get_content_type() + 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(), - 'size': self.get_size(), - 'content_type': self.get_content_type()} + 'basename': self.get_basename()} response_kwargs.update(kwargs) response = self.response_class(**response_kwargs) return response From 562773799a26d502682b03b47215263fb95fbdd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Wed, 6 Feb 2013 09:12:16 +0100 Subject: [PATCH 05/14] =?UTF-8?q?Added=20R=C3=A9my=20to=20AUTHORS.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index db40ae7..f4ca592 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,3 +8,4 @@ Original code by `Novapost `_ team: * Lauréline Guérin * Gregory Tappero * Benoît Bryon +* Rémy Hubscher From b8d51c12fb91f45f1e606e54c387667864d030b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Wed, 6 Feb 2013 09:16:05 +0100 Subject: [PATCH 06/14] Minor refactoring in demo's tests. --- demo/demoproject/download/tests.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/demo/demoproject/download/tests.py b/demo/demoproject/download/tests.py index ee631b5..867c651 100644 --- a/demo/demoproject/download/tests.py +++ b/demo/demoproject/download/tests.py @@ -1,3 +1,4 @@ +# coding=utf8 """Test suite for demoproject.download.""" from os import listdir from os.path import abspath, dirname, join @@ -28,9 +29,7 @@ class DownloadTestCase(TestCase): class DownloadViewTestCase(DownloadTestCase): """Test generic DownloadView.""" def test_download_hello_world(self): - """'download_hello_world' view returns hello-world.txt as attachement. - - """ + """download_hello_world view returns hello-world.txt as attachement.""" download_url = reverse('download_hello_world') response = self.client.get(download_url) self.assertEquals(response.status_code, 200) @@ -48,12 +47,10 @@ class ObjectDownloadViewTestCase(DownloadTestCase): @temporary_media_root() def test_download_hello_world(self): """'download_document' view returns hello-world.txt as attachement.""" - slug = 'hello-world' + 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'])), - ) + 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'], From cc3df73905bea8fc9b979237e587dc563c4c2b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Wed, 6 Feb 2013 11:01:33 +0100 Subject: [PATCH 07/14] Refs #23 - Implemented and tested PathDownloadView. This PathDownloadView is to replace an use case of former DownloadView. --- demo/demoproject/download/tests.py | 40 ++++++++++++++---------- demo/demoproject/download/urls.py | 21 +++++++++---- demo/demoproject/download/views.py | 46 +++++++++++++++++++++++++--- demo/demoproject/settings.py | 7 +++-- demo/demoproject/templates/home.html | 15 +++++++-- demo/demoproject/tests.py | 13 ++++++++ demo/demoproject/urls.py | 5 +-- django_downloadview/views.py | 42 +++++++++++++++++++++---- 8 files changed, 148 insertions(+), 41 deletions(-) create mode 100644 demo/demoproject/tests.py diff --git a/demo/demoproject/download/tests.py b/demo/demoproject/download/tests.py index 867c651..246ccf7 100644 --- a/demo/demoproject/download/tests.py +++ b/demo/demoproject/download/tests.py @@ -4,7 +4,7 @@ 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 @@ -25,13 +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') @@ -42,21 +37,32 @@ 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 can return 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 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}) + 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) diff --git a/demo/demoproject/download/urls.py b/demo/demoproject/download/urls.py index 53e4296..234d974 100644 --- a/demo/demoproject/download/urls.py +++ b/demo/demoproject/download/urls.py @@ -1,10 +1,19 @@ +# 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[a-zA-Z0-9_-]+)/$', 'download_document', - name='download_document'), +urlpatterns = patterns( + 'demoproject.download.views', + # Path-based downloads. + url(r'^hello-world\.txt$', + 'download_hello_world', + name='hello_world'), + url(r'^path/(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$', + 'download_fixture_from_path', + name='fixture_from_path'), + # Model-based downloads. + url(r'^document/(?P[a-zA-Z0-9_-]+)/$', + 'download_document', + name='document'), ) diff --git a/demo/demoproject/download/views.py b/demo/demoproject/download/views.py index 6ba9854..44e3315 100644 --- a/demo/demoproject/download/views.py +++ b/demo/demoproject/download/views.py @@ -1,16 +1,54 @@ +# coding=utf8 +"""Demo download views.""" from os.path import abspath, dirname, join -from django_downloadview import DownloadView, ObjectDownloadView +from django_downloadview.views import ObjectDownloadView, PathDownloadView from demoproject.download.models import Document +# Some initializations. + app_dir = dirname(abspath(__file__)) +"""Directory containing code of :py:module:`demoproject.download.views`.""" + fixtures_dir = join(app_dir, 'fixtures') -hello_world_file = join(fixtures_dir, 'hello-world.txt') +"""Directory containing files fixtures.""" + +hello_world_path = join(fixtures_dir, 'hello-world.txt') +"""Path to a text file that says 'Hello world!'.""" -download_hello_world = DownloadView.as_view(filename=hello_world_file, - storage=None) +# Here are the views. + +download_hello_world = PathDownloadView.as_view(path=hello_world_path) +"""Direct download of one file, based on an absolute path. + +You could use this example as a shortcut, inside other views. + +""" + + +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) + +download_fixture_from_path = CustomPathDownloadView.as_view() +"""Pre-configured :py:class:`CustomPathDownloadView`.""" + download_document = ObjectDownloadView.as_view(model=Document) +"""Pre-configured download view for :py:class:`Document` model.""" diff --git a/demo/demoproject/settings.py b/demo/demoproject/settings.py index 57ccda1..377f740 100755 --- a/demo/demoproject/settings.py +++ b/demo/demoproject/settings.py @@ -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', + ] diff --git a/demo/demoproject/templates/home.html b/demo/demoproject/templates/home.html index 10caf22..4e499d6 100644 --- a/demo/demoproject/templates/home.html +++ b/demo/demoproject/templates/home.html @@ -7,9 +7,18 @@

Welcome to django-downloadview demo!

Here are some demo links. Browse the code to see how they are implemented

diff --git a/demo/demoproject/tests.py b/demo/demoproject/tests.py new file mode 100644 index 0000000..35e6304 --- /dev/null +++ b/demo/demoproject/tests.py @@ -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) diff --git a/demo/demoproject/urls.py b/demo/demoproject/urls.py index d7597a1..d8ffdd0 100755 --- a/demo/demoproject/urls.py +++ b/demo/demoproject/urls.py @@ -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') ) diff --git a/django_downloadview/views.py b/django_downloadview/views.py index 9792d82..7baa51b 100644 --- a/django_downloadview/views.py +++ b/django_downloadview/views.py @@ -68,18 +68,48 @@ class BaseDownloadView(DownloadMixin, View): return self.render_to_response() +class PathDownloadView(BaseDownloadView): + """Serve a file using filename.""" + path = None + """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_url_kwarg = 'path' + """Name of the URL argument that contains 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(): - """Download a file from storage and filename.""" + """Serve a file using storage and filename.""" storage = DefaultStorage() path = None -class SimpleDownloadView(): - """Download a file from filename.""" - path = None - - class VirtualDownloadView(): + """Serve not-on-disk or generated-on-the-fly file. + + Use this class to serve :py:class:`StringIO` files. + + """ file_obj = None From 631099d17310ba5e2f9bb82851de8cac23747226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Wed, 6 Feb 2013 15:32:29 +0100 Subject: [PATCH 08/14] Updated buildout configuration. Added tests around documentation with sphinxcontrib-testbuild. --- Makefile | 31 +++++++++++++++++++++---------- buildout.cfg => etc/buildout.cfg | 26 +++++++++++++++++--------- etc/nose.cfg | 7 +++++++ 3 files changed, 45 insertions(+), 19 deletions(-) rename buildout.cfg => etc/buildout.cfg (71%) create mode 100644 etc/nose.cfg diff --git a/Makefile b/Makefile index 5bfd897..9fbe7fb 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/buildout.cfg b/etc/buildout.cfg similarity index 71% rename from buildout.cfg rename to etc/buildout.cfg index 950eee4..a46c6cf 100644 --- a/buildout.cfg +++ b/etc/buildout.cfg @@ -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 diff --git a/etc/nose.cfg b/etc/nose.cfg new file mode 100644 index 0000000..b43ad26 --- /dev/null +++ b/etc/nose.cfg @@ -0,0 +1,7 @@ +[nosetests] +verbosity = 2 +nocapture = True +with-doctest = True +rednose = True +no-path-adjustment = True +all-modules = True From 7876cfffc7eb829f687e2b496e6aaf978a4887c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Wed, 6 Feb 2013 15:41:17 +0100 Subject: [PATCH 09/14] Changed sidebars in documentation. Removed unused links at documentation index bottom. --- docs/conf.py | 7 ++++++- docs/index.txt | 9 --------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 5009ff2..27421ee 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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. diff --git a/docs/index.txt b/docs/index.txt index e987813..44efa00 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -14,12 +14,3 @@ Contents api/index about/index dev - - -****************** -Indices and tables -****************** - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` From 5c91a53f409e6904199ed08cd0007edcfc3aa3f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Wed, 6 Feb 2013 16:18:37 +0100 Subject: [PATCH 10/14] Introduced narrative overview of available download views in documentation. --- README | 9 +++++++++ docs/index.txt | 1 + docs/views.txt | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 docs/views.txt diff --git a/README b/README index 9780d40..603ab08 100644 --- a/README +++ b/README @@ -19,6 +19,15 @@ Example, in some urls.py: url_patterns = ('', url('^download/(?P[A-Za-z0-9_-]+)/$', download, name='download'), ) + +Several views are provided to cover frequent use cases: + +* ``PathDownloadView`` when you have an absolute filename. +* ``ObjectDownloadView`` when you have a model with a file field. + +See :doc:`views` for details. + +Then get increased performances with :doc:`optimizations/index`. ********** diff --git a/docs/index.txt b/docs/index.txt index 44efa00..e29873d 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -10,6 +10,7 @@ Contents demo install + views optimizations/index api/index about/index diff --git a/docs/views.txt b/docs/views.txt new file mode 100644 index 0000000..37429dd --- /dev/null +++ b/docs/views.txt @@ -0,0 +1,37 @@ +############## +Download views +############## + +This section contains narrative overview about class-based views provided by +django-downloadview. + + +**************** +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`. + + +****************** +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. From e94054bcba3e66854a73bf613fc8747e9a52ef9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Wed, 6 Feb 2013 18:59:22 +0100 Subject: [PATCH 11/14] Refs #23 - Implemented and tested StorageDownloadView. This StorageDownloadView is to replace an use case of former DownloadView. --- CHANGELOG | 7 ++++++- README | 5 +++-- demo/demoproject/download/tests.py | 12 +++++++++++- demo/demoproject/download/urls.py | 12 ++++++++---- demo/demoproject/download/views.py | 13 ++++++++++++- demo/demoproject/templates/home.html | 3 +++ django_downloadview/views.py | 21 +++++++++++++++++++-- docs/views.txt | 11 +++++++++++ 8 files changed, 73 insertions(+), 11 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index bc8f47b..c4f962d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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) diff --git a/README b/README index 603ab08..260271b 100644 --- a/README +++ b/README @@ -1,5 +1,5 @@ ################### -Django-DownloadView +django-downloadview ################### Django-DownloadView provides (class-based) generic download views for Django. @@ -22,8 +22,9 @@ Example, in some urls.py: Several views are provided to cover frequent use cases: -* ``PathDownloadView`` when you have an absolute filename. * ``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. See :doc:`views` for details. diff --git a/demo/demoproject/download/tests.py b/demo/demoproject/download/tests.py index 246ccf7..4cd535c 100644 --- a/demo/demoproject/download/tests.py +++ b/demo/demoproject/download/tests.py @@ -49,12 +49,22 @@ class PathDownloadViewTestCase(DownloadTestCase): class CustomPathDownloadViewTestCase(DownloadTestCase): """Test "fixture_from_path" view.""" def test_download_hello_world(self): - """fixture_from_path view can return hello-world.txt as attachement.""" + """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() diff --git a/demo/demoproject/download/urls.py b/demo/demoproject/download/urls.py index 234d974..c1f37e9 100644 --- a/demo/demoproject/download/urls.py +++ b/demo/demoproject/download/urls.py @@ -5,6 +5,14 @@ from django.conf.urls import patterns, url urlpatterns = patterns( 'demoproject.download.views', + # Model-based downloads. + url(r'^document/(?P[a-zA-Z0-9_-]+)/$', + 'download_document', + name='document'), + # Storage-based downloads. + url(r'^storage/(?P[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', @@ -12,8 +20,4 @@ urlpatterns = patterns( url(r'^path/(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$', 'download_fixture_from_path', name='fixture_from_path'), - # Model-based downloads. - url(r'^document/(?P[a-zA-Z0-9_-]+)/$', - 'download_document', - name='document'), ) diff --git a/demo/demoproject/download/views.py b/demo/demoproject/download/views.py index 44e3315..a073aa6 100644 --- a/demo/demoproject/download/views.py +++ b/demo/demoproject/download/views.py @@ -2,7 +2,10 @@ """Demo download views.""" from os.path import abspath, dirname, join -from django_downloadview.views import ObjectDownloadView, PathDownloadView +from django.core.files.storage import FileSystemStorage + +from django_downloadview.views import (ObjectDownloadView, PathDownloadView, + StorageDownloadView) from demoproject.download.models import Document @@ -18,6 +21,9 @@ fixtures_dir = join(app_dir, 'fixtures') hello_world_path = join(fixtures_dir, 'hello-world.txt') """Path to a text file that says 'Hello world!'.""" +fixtures_storage = FileSystemStorage(location=fixtures_dir) +"""Storage for fixtures.""" + # Here are the views. @@ -50,5 +56,10 @@ download_fixture_from_path = CustomPathDownloadView.as_view() """Pre-configured :py:class:`CustomPathDownloadView`.""" +download_fixture_from_storage = StorageDownloadView.as_view( + storage=fixtures_storage) +"""Pre-configured view using a storage.""" + + download_document = ObjectDownloadView.as_view(model=Document) """Pre-configured download view for :py:class:`Document` model.""" diff --git a/demo/demoproject/templates/home.html b/demo/demoproject/templates/home.html index 4e499d6..556d3e9 100644 --- a/demo/demoproject/templates/home.html +++ b/demo/demoproject/templates/home.html @@ -13,6 +13,9 @@
  • Download files using PathDownloadView and relative path in URL.
  • +
  • + Download files using StorageDownloadView and path in URL. +
  • ObjectDownloadView
  • diff --git a/django_downloadview/views.py b/django_downloadview/views.py index 7baa51b..4fb3428 100644 --- a/django_downloadview/views.py +++ b/django_downloadview/views.py @@ -98,10 +98,27 @@ class PathDownloadView(BaseDownloadView): return File(open(self.get_path())) -class StorageDownloadView(): +class StorageDownloadView(PathDownloadView): """Serve a file using storage and filename.""" storage = DefaultStorage() - path = None + """Storage the file to serve belongs to.""" + + path = None # Override docstring. + """Path to the file to serve relative to storage.""" + + 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 self.storage.open(self.get_path()) class VirtualDownloadView(): diff --git a/docs/views.txt b/docs/views.txt index 37429dd..5330c43 100644 --- a/docs/views.txt +++ b/docs/views.txt @@ -23,6 +23,17 @@ Two main use cases: override :py:meth:`django_downloadview.views.PathDownloadView:get_path`. +******************* +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. + + ****************** ObjectDownloadView ****************** From 02f0b7aa0d1a5cec73c319d63483caee4c922428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Wed, 6 Feb 2013 19:07:23 +0100 Subject: [PATCH 12/14] Using doc comments instead of docstrings for attributes. --- demo/demoproject/download/views.py | 23 +++++++++++------------ django_downloadview/views.py | 16 +++++++--------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/demo/demoproject/download/views.py b/demo/demoproject/download/views.py index a073aa6..c31d293 100644 --- a/demo/demoproject/download/views.py +++ b/demo/demoproject/download/views.py @@ -12,27 +12,26 @@ from demoproject.download.models import Document # Some initializations. +#: Directory containing code of :py:module:`demoproject.download.views`. app_dir = dirname(abspath(__file__)) -"""Directory containing code of :py:module:`demoproject.download.views`.""" +#: Directory containing files fixtures. fixtures_dir = join(app_dir, 'fixtures') -"""Directory containing files fixtures.""" +#: Path to a text file that says 'Hello world!'. hello_world_path = join(fixtures_dir, 'hello-world.txt') -"""Path to a text file that says 'Hello world!'.""" +#: Storage for fixtures. fixtures_storage = FileSystemStorage(location=fixtures_dir) -"""Storage for fixtures.""" # Here are the views. + +#: 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) -"""Direct download of one file, based on an absolute path. - -You could use this example as a shortcut, inside other views. - -""" class CustomPathDownloadView(PathDownloadView): @@ -52,14 +51,14 @@ class CustomPathDownloadView(PathDownloadView): path = super(CustomPathDownloadView, self).get_path() return join(fixtures_dir, path) +#: Pre-configured :py:class:`CustomPathDownloadView`. download_fixture_from_path = CustomPathDownloadView.as_view() -"""Pre-configured :py:class:`CustomPathDownloadView`.""" +#: Pre-configured view using a storage. download_fixture_from_storage = StorageDownloadView.as_view( storage=fixtures_storage) -"""Pre-configured view using a storage.""" +#: Pre-configured download view for :py:class:`Document` model. download_document = ObjectDownloadView.as_view(model=Document) -"""Pre-configured download view for :py:class:`Document` model.""" diff --git a/django_downloadview/views.py b/django_downloadview/views.py index 4fb3428..60a0275 100644 --- a/django_downloadview/views.py +++ b/django_downloadview/views.py @@ -70,16 +70,14 @@ class BaseDownloadView(DownloadMixin, View): 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 - """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. - - """ + #: Name of the URL argument that contains path. path_url_kwarg = 'path' - """Name of the URL argument that contains path.""" def get_path(self): """Return actual path of the file to serve. @@ -100,11 +98,11 @@ class PathDownloadView(BaseDownloadView): class StorageDownloadView(PathDownloadView): """Serve a file using storage and filename.""" + #: Storage the file to serve belongs to. storage = DefaultStorage() - """Storage the file to serve belongs to.""" + #: Path to the file to serve relative to storage. path = None # Override docstring. - """Path to the file to serve relative to storage.""" def get_path(self): """Return path of the file to serve, relative to storage. From 58cf4bac7d831babfdeea61dc215a1993227a726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Wed, 6 Feb 2013 19:15:00 +0100 Subject: [PATCH 13/14] Removed deprecated DownloadView. Replaced by PathDownloadView and StorageDownloadView. --- django_downloadview/__init__.py | 5 ++++- django_downloadview/views.py | 28 ---------------------------- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/django_downloadview/__init__.py b/django_downloadview/__init__.py index b09a8b1..5416895 100644 --- a/django_downloadview/__init__.py +++ b/django_downloadview/__init__.py @@ -1,5 +1,8 @@ """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) pkg_resources = __import__('pkg_resources') diff --git a/django_downloadview/views.py b/django_downloadview/views.py index 60a0275..a8e7f6b 100644 --- a/django_downloadview/views.py +++ b/django_downloadview/views.py @@ -128,34 +128,6 @@ class VirtualDownloadView(): file_obj = None -class DownloadView(BaseDownloadView): - """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.""" - if self.storage: - return StorageFile(self.storage, self.filename) - else: - return File(open(self.filename)) - - class ObjectDownloadView(DownloadMixin, BaseDetailView): """Download view for models which contain a FileField. From 060233abc50c6438466fe9a8816c8bff73130e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Wed, 6 Feb 2013 20:11:42 +0100 Subject: [PATCH 14/14] Refs #23 - Implemented VirtualDownloadView and VirtualFile for basic support of in-memory (StringIO) or other generated files. --- README | 1 + demo/demoproject/download/tests.py | 11 +++++++ demo/demoproject/download/urls.py | 4 +++ demo/demoproject/download/views.py | 30 +++++++++++++----- django_downloadview/__init__.py | 3 +- django_downloadview/files.py | 35 +++++++++++++++++++++ django_downloadview/views.py | 10 ++++-- docs/views.txt | 49 ++++++++++++++++++------------ 8 files changed, 112 insertions(+), 31 deletions(-) diff --git a/README b/README index 260271b..c718882 100644 --- a/README +++ b/README @@ -25,6 +25,7 @@ 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. diff --git a/demo/demoproject/download/tests.py b/demo/demoproject/download/tests.py index 4cd535c..0ae4092 100644 --- a/demo/demoproject/download/tests.py +++ b/demo/demoproject/download/tests.py @@ -76,3 +76,14 @@ class ObjectDownloadViewTestCase(DownloadTestCase): file=File(open(self.files['hello-world.txt']))) response = self.client.get(download_url) 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) diff --git a/demo/demoproject/download/urls.py b/demo/demoproject/download/urls.py index c1f37e9..4dbb43d 100644 --- a/demo/demoproject/download/urls.py +++ b/demo/demoproject/download/urls.py @@ -20,4 +20,8 @@ urlpatterns = patterns( url(r'^path/(?P[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'), ) diff --git a/demo/demoproject/download/views.py b/demo/demoproject/download/views.py index c31d293..00d33b6 100644 --- a/demo/demoproject/download/views.py +++ b/demo/demoproject/download/views.py @@ -1,11 +1,15 @@ # coding=utf8 """Demo download views.""" +from cStringIO import StringIO from os.path import abspath, dirname, join from django.core.files.storage import FileSystemStorage -from django_downloadview.views import (ObjectDownloadView, PathDownloadView, - StorageDownloadView) +from django_downloadview.files import VirtualFile +from django_downloadview.views import (ObjectDownloadView, + PathDownloadView, + StorageDownloadView, + VirtualDownloadView) from demoproject.download.models import Document @@ -27,6 +31,14 @@ fixtures_storage = FileSystemStorage(location=fixtures_dir) # 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. #: @@ -55,10 +67,12 @@ class CustomPathDownloadView(PathDownloadView): download_fixture_from_path = CustomPathDownloadView.as_view() -#: Pre-configured view using a storage. -download_fixture_from_storage = StorageDownloadView.as_view( - storage=fixtures_storage) +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 download view for :py:class:`Document` model. -download_document = ObjectDownloadView.as_view(model=Document) +#: Pre-configured view that serves "Hello world!" via a StringIO. +download_generated_hello_world = StringIODownloadView.as_view() diff --git a/django_downloadview/__init__.py b/django_downloadview/__init__.py index 5416895..f92cfe0 100644 --- a/django_downloadview/__init__.py +++ b/django_downloadview/__init__.py @@ -2,7 +2,8 @@ # Shortcut import. from django_downloadview.views import (PathDownloadView, ObjectDownloadView, - StorageDownloadView) + StorageDownloadView, + VirtualDownloadView) pkg_resources = __import__('pkg_resources') diff --git a/django_downloadview/files.py b/django_downloadview/files.py index 40d9bdd..03f45d1 100644 --- a/django_downloadview/files.py +++ b/django_downloadview/files.py @@ -131,3 +131,38 @@ class StorageFile(File): """ 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) diff --git a/django_downloadview/views.py b/django_downloadview/views.py index a8e7f6b..c85c65b 100644 --- a/django_downloadview/views.py +++ b/django_downloadview/views.py @@ -116,16 +116,20 @@ class StorageDownloadView(PathDownloadView): def get_file(self): """Use path and storage to return wrapper around file to serve.""" - return self.storage.open(self.get_path()) + return StorageFile(self.storage, self.get_path()) -class VirtualDownloadView(): +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. + """ - file_obj = None + def get_file(self): + """Return wrapper.""" + raise NotImplementedError() class ObjectDownloadView(DownloadMixin, BaseDetailView): diff --git a/docs/views.txt b/docs/views.txt index 5330c43..93ba67d 100644 --- a/docs/views.txt +++ b/docs/views.txt @@ -6,6 +6,31 @@ 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 **************** @@ -24,25 +49,11 @@ Two main use cases: ******************* -StorageDownloadView +VirtualDownloadView ******************* -The :py:class:`django_downloadview.views.StorageDownloadView` class-based view -allows you to **serve files given a storage and a path**. +The :py:class:`django_downloadview.views.VirtualDownloadView` class-based view +allows you to **serve files that don't live on disk**. -Use this view when you manage files in a storage (which is a good practice), -unrelated to a model. - - -****************** -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. +Use it when you want to stream a file which content is dynamically generated +or which lives in memory.