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:
Roberto Rosario
2019-12-12 19:39:44 -04:00
parent 826f7fddf2
commit a7b31fc171
24 changed files with 355 additions and 278 deletions
+78
View File
@@ -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)
+54 -12
View File
@@ -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
View File
@@ -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
+3 -5
View File
@@ -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(
+36 -1
View File
@@ -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()