Refactor and implement download code natively
- Use modified port of Django 2.2 FileResponse. - Remove Django DownloadView library. Signed-off-by: Roberto Rosario <roberto.rosario@mayan-edms.com>
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import types
|
||||
|
||||
from django.conf import settings
|
||||
from django.http.response import StreamingHttpResponse
|
||||
from django.utils import six
|
||||
from django.utils.six.moves.urllib.parse import quote
|
||||
|
||||
from mayan.apps.mimetype.api import get_mimetype
|
||||
|
||||
if six.PY3:
|
||||
dict_type = dict
|
||||
@@ -22,3 +28,75 @@ except NameError:
|
||||
FileNotFoundErrorException = IOError
|
||||
else:
|
||||
FileNotFoundErrorException = FileNotFoundError # NOQA
|
||||
|
||||
|
||||
class FileResponse(StreamingHttpResponse):
|
||||
"""
|
||||
Port of Django's 2.2 FileResponse
|
||||
Modified to allows downloading non file like content as attachment
|
||||
A streaming HTTP response class optimized for files.
|
||||
TODO: To be remove when the code moves to Django 2.2
|
||||
"""
|
||||
block_size = 4096
|
||||
|
||||
def __init__(self, as_attachment=False, filename='', *args, **kwargs):
|
||||
self.as_attachment = as_attachment
|
||||
self.filename = filename
|
||||
super(FileResponse, self).__init__(*args, **kwargs)
|
||||
|
||||
def _set_as_attachment(self, filename):
|
||||
if self.as_attachment:
|
||||
filename = self.filename or os.path.basename(filename)
|
||||
if filename:
|
||||
try:
|
||||
filename.encode('ascii')
|
||||
file_expr = 'filename="{}"'.format(filename)
|
||||
except UnicodeEncodeError:
|
||||
file_expr = "filename*=utf-8''{}".format(quote(filename))
|
||||
self['Content-Disposition'] = 'attachment; {}'.format(file_expr)
|
||||
|
||||
def _set_streaming_content(self, value):
|
||||
if not hasattr(value, 'read'):
|
||||
self.file_to_stream = None
|
||||
result = super(FileResponse, self)._set_streaming_content(value)
|
||||
self._set_as_attachment(filename=self.filename)
|
||||
|
||||
return result
|
||||
|
||||
self.file_to_stream = filelike = value
|
||||
if hasattr(filelike, 'close'):
|
||||
self._closable_objects.append(filelike)
|
||||
value = iter(lambda: filelike.read(self.block_size), b'')
|
||||
self.set_headers(filelike)
|
||||
super(FileResponse, self)._set_streaming_content(value)
|
||||
|
||||
def set_headers(self, filelike):
|
||||
"""
|
||||
Set some common response headers (Content-Length, Content-Type, and
|
||||
Content-Disposition) based on the `filelike` response content.
|
||||
"""
|
||||
encoding_map = {
|
||||
'bzip2': 'application/x-bzip',
|
||||
'gzip': 'application/gzip',
|
||||
'xz': 'application/x-xz',
|
||||
}
|
||||
filename = getattr(filelike, 'name', None)
|
||||
filename = filename if (isinstance(filename, str) and filename) else self.filename
|
||||
if os.path.isabs(filename):
|
||||
self['Content-Length'] = os.path.getsize(filelike.name)
|
||||
elif hasattr(filelike, 'getbuffer'):
|
||||
self['Content-Length'] = filelike.getbuffer().nbytes
|
||||
|
||||
if self.get('Content-Type', '').startswith(settings.DEFAULT_CONTENT_TYPE):
|
||||
if self.file_to_stream:
|
||||
content_type, encoding = get_mimetype(
|
||||
file_object=self.file_to_stream, mimetype_only=True
|
||||
)
|
||||
# Encoding isn't set to prevent browsers from automatically
|
||||
# uncompressing files.
|
||||
content_type = encoding_map.get(encoding, content_type)
|
||||
self['Content-Type'] = content_type or 'application/octet-stream'
|
||||
else:
|
||||
self['Content-Type'] = 'application/octet-stream'
|
||||
|
||||
self._set_as_attachment(filename=filename)
|
||||
|
||||
@@ -11,15 +11,13 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import (
|
||||
FormView as DjangoFormView, DetailView, TemplateView
|
||||
)
|
||||
from django.views.generic.base import View
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from django.views.generic.edit import (
|
||||
CreateView, DeleteView, FormMixin, ModelFormMixin, UpdateView
|
||||
)
|
||||
from django.views.generic.list import ListView
|
||||
|
||||
from django_downloadview import (
|
||||
TextIteratorIO, VirtualDownloadView, VirtualFile
|
||||
)
|
||||
from pure_pagination.mixins import PaginationMixin
|
||||
|
||||
from mayan.apps.acls.models import AccessControlList
|
||||
@@ -36,11 +34,10 @@ from .literals import (
|
||||
TEXT_SORT_ORDER_VARIABLE_NAME
|
||||
)
|
||||
from .mixins import (
|
||||
DeleteExtraDataMixin, DynamicFormViewMixin, ExternalObjectMixin,
|
||||
ExtraContextMixin, FormExtraKwargsMixin, MultipleObjectMixin,
|
||||
ObjectActionMixin, ObjectNameMixin,
|
||||
ObjectPermissionCheckMixin, RedirectionMixin, RestrictedQuerysetMixin,
|
||||
ViewPermissionCheckMixin
|
||||
DeleteExtraDataMixin, DownloadMixin, DynamicFormViewMixin,
|
||||
ExternalObjectMixin, ExtraContextMixin, FormExtraKwargsMixin,
|
||||
MultipleObjectMixin, ObjectActionMixin, ObjectNameMixin,
|
||||
RedirectionMixin, RestrictedQuerysetMixin, ViewPermissionCheckMixin
|
||||
)
|
||||
|
||||
from .settings import setting_paginate_by
|
||||
@@ -491,7 +488,9 @@ class MultipleObjectConfirmActionView(
|
||||
|
||||
|
||||
class SimpleView(ViewPermissionCheckMixin, ExtraContextMixin, TemplateView):
|
||||
pass
|
||||
"""
|
||||
Basic template view class with permission check and extra context
|
||||
"""
|
||||
|
||||
|
||||
class SingleObjectCreateView(
|
||||
@@ -657,9 +656,52 @@ class SingleObjectDetailView(
|
||||
return super(SingleObjectDetailView, self).get_queryset()
|
||||
|
||||
|
||||
class SingleObjectDownloadView(ViewPermissionCheckMixin, ObjectPermissionCheckMixin, VirtualDownloadView, SingleObjectMixin):
|
||||
TextIteratorIO = TextIteratorIO
|
||||
VirtualFile = VirtualFile
|
||||
class BaseDownloadView(DownloadMixin, ViewPermissionCheckMixin, View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.render_to_response()
|
||||
|
||||
|
||||
class SingleObjectDownloadView(
|
||||
RestrictedQuerysetMixin, SingleObjectMixin, BaseDownloadView
|
||||
):
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
return super(SingleObjectDownloadView, self).get(
|
||||
request, *args, **kwargs
|
||||
)
|
||||
|
||||
def get_download_file_object(self):
|
||||
return self.object.open()
|
||||
|
||||
def get_download_label(self):
|
||||
return force_text(self.object)
|
||||
|
||||
|
||||
class MultipleObjectDownloadView(
|
||||
RestrictedQuerysetMixin, MultipleObjectMixin, BaseDownloadView
|
||||
):
|
||||
"""
|
||||
View that support receiving multiple objects via a pk_list query.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
result = super(MultipleObjectDownloadView, self).__init__(*args, **kwargs)
|
||||
|
||||
if self.__class__.mro()[0].get_queryset != MultipleObjectDownloadView.get_queryset:
|
||||
raise ImproperlyConfigured(
|
||||
'%(cls)s is overloading the get_queryset method. Subclasses '
|
||||
'should implement the get_source_queryset method instead. ' % {
|
||||
'cls': self.__class__.__name__
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def get_queryset(self):
|
||||
try:
|
||||
return super(MultipleObjectDownloadView, self).get_queryset()
|
||||
except ImproperlyConfigured:
|
||||
self.queryset = self.get_source_queryset()
|
||||
return super(MultipleObjectDownloadView, self).get_queryset()
|
||||
|
||||
|
||||
class SingleObjectDynamicFormCreateView(
|
||||
|
||||
+24
-20
@@ -13,6 +13,7 @@ from mayan.apps.acls.classes import ModelPermission
|
||||
from mayan.apps.acls.models import AccessControlList
|
||||
from mayan.apps.permissions import Permission
|
||||
|
||||
from .compat import FileResponse
|
||||
from .exceptions import ActionError
|
||||
from .forms import DynamicForm
|
||||
from .literals import PK_LIST_SEPARATOR
|
||||
@@ -56,6 +57,29 @@ class DeleteExtraDataMixin(object):
|
||||
return HttpResponseRedirect(redirect_to=success_url)
|
||||
|
||||
|
||||
class DownloadMixin(object):
|
||||
as_attachment = True
|
||||
|
||||
def get_as_attachment(self):
|
||||
return self.as_attachment
|
||||
|
||||
def get_download_file_object(self):
|
||||
raise NotImplementedError(
|
||||
'Class must provide a .get_download_file_object() method that '
|
||||
'return a file like object.'
|
||||
)
|
||||
|
||||
def get_download_filename(self):
|
||||
return None
|
||||
|
||||
def render_to_response(self, **response_kwargs):
|
||||
return FileResponse(
|
||||
as_attachment=self.get_as_attachment(),
|
||||
filename=self.get_download_filename(),
|
||||
streaming_content=self.get_download_file_object()
|
||||
)
|
||||
|
||||
|
||||
class DynamicFormViewMixin(object):
|
||||
form_class = DynamicForm
|
||||
|
||||
@@ -345,26 +369,6 @@ class ObjectNameMixin(object):
|
||||
return object_name
|
||||
|
||||
|
||||
# TODO: Remove this mixin and replace with restricted queryset
|
||||
class ObjectPermissionCheckMixin(object):
|
||||
object_permission = None
|
||||
|
||||
def get_permission_object(self):
|
||||
return self.get_object()
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if self.object_permission:
|
||||
AccessControlList.objects.check_access(
|
||||
obj=self.get_permission_object(),
|
||||
permissions=(self.object_permission,),
|
||||
user=request.user
|
||||
)
|
||||
|
||||
return super(
|
||||
ObjectPermissionCheckMixin, self
|
||||
).dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class RedirectionMixin(object):
|
||||
action_cancel_redirect = None
|
||||
next_url = None
|
||||
|
||||
@@ -2,8 +2,6 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from django_downloadview import assert_download_response
|
||||
|
||||
from mayan.apps.acls.tests.mixins import ACLTestCaseMixin
|
||||
from mayan.apps.converter.tests.mixins import LayerTestCaseMixin
|
||||
from mayan.apps.permissions.tests.mixins import PermissionTestCaseMixin
|
||||
@@ -14,7 +12,7 @@ from mayan.apps.user_management.tests.mixins import UserTestMixin
|
||||
|
||||
from .mixins import (
|
||||
ClientMethodsTestCaseMixin, ConnectionsCheckTestCaseMixin,
|
||||
ContentTypeCheckTestCaseMixin, ModelTestCaseMixin,
|
||||
ContentTypeCheckTestCaseMixin, DownloadTestCaseMixin, ModelTestCaseMixin,
|
||||
OpenFileCheckTestCaseMixin, RandomPrimaryKeyModelMonkeyPatchMixin,
|
||||
SilenceLoggerTestCaseMixin, TempfileCheckTestCasekMixin,
|
||||
TestViewTestCaseMixin
|
||||
@@ -22,7 +20,8 @@ from .mixins import (
|
||||
|
||||
|
||||
class BaseTestCase(
|
||||
LayerTestCaseMixin, SilenceLoggerTestCaseMixin, ConnectionsCheckTestCaseMixin,
|
||||
LayerTestCaseMixin, SilenceLoggerTestCaseMixin,
|
||||
ConnectionsCheckTestCaseMixin, DownloadTestCaseMixin,
|
||||
RandomPrimaryKeyModelMonkeyPatchMixin, ACLTestCaseMixin,
|
||||
ModelTestCaseMixin, OpenFileCheckTestCaseMixin, PermissionTestCaseMixin,
|
||||
SmartSettingsTestCaseMixin, TempfileCheckTestCasekMixin, UserTestMixin,
|
||||
@@ -31,7 +30,6 @@ class BaseTestCase(
|
||||
"""
|
||||
This is the most basic test case class any test in the project should use.
|
||||
"""
|
||||
assert_download_response = assert_download_response
|
||||
|
||||
|
||||
class GenericViewTestCase(
|
||||
|
||||
@@ -18,12 +18,16 @@ from django.http import HttpResponse
|
||||
from django.template import Context, Template
|
||||
from django.test.utils import ContextList
|
||||
from django.urls import clear_url_caches, reverse
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.encoding import (
|
||||
DjangoUnicodeDecodeError, force_bytes, force_text
|
||||
)
|
||||
from django.utils.six import PY3
|
||||
|
||||
from mayan.apps.acls.classes import ModelPermission
|
||||
from mayan.apps.storage.settings import setting_temporary_directory
|
||||
|
||||
from ..compat import FileResponse
|
||||
|
||||
from .literals import (
|
||||
TEST_SERVER_HOST, TEST_SERVER_SCHEME, TEST_VIEW_NAME, TEST_VIEW_URL
|
||||
)
|
||||
@@ -141,6 +145,37 @@ class ContentTypeCheckTestCaseMixin(object):
|
||||
self.client = CustomClient()
|
||||
|
||||
|
||||
class DownloadTestCaseMixin(object):
|
||||
def assert_download_response(
|
||||
self, response, content=None, filename=None, is_attachment=None,
|
||||
mime_type=None
|
||||
):
|
||||
self.assertTrue(isinstance(response, FileResponse))
|
||||
|
||||
if filename:
|
||||
self.assertEqual(
|
||||
response[
|
||||
'Content-Disposition'
|
||||
].split('filename="')[1].split('"')[0], filename
|
||||
)
|
||||
|
||||
if content:
|
||||
response_content = b''.join(list(response))
|
||||
|
||||
try:
|
||||
response_content = force_text(response_content)
|
||||
except DjangoUnicodeDecodeError:
|
||||
"""Leave as bytes"""
|
||||
|
||||
self.assertEqual(response_content, content)
|
||||
|
||||
if is_attachment is not None:
|
||||
self.assertEqual(response['Content-Disposition'], 'attachment')
|
||||
|
||||
if mime_type:
|
||||
self.assertTrue(response['Content-Type'].startswith(mime_type))
|
||||
|
||||
|
||||
class EnvironmentTestCaseMixin(object):
|
||||
def setUp(self):
|
||||
super(EnvironmentTestCaseMixin, self).setUp()
|
||||
|
||||
Reference in New Issue
Block a user