diff --git a/tailbone/api/master2.py b/tailbone/api/master2.py index a062343f..18fb3af0 100644 --- a/tailbone/api/master2.py +++ b/tailbone/api/master2.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -26,6 +26,7 @@ Tailbone Web API - Master View (v2) from __future__ import unicode_literals, absolute_import +from pyramid.response import FileResponse from cornice import resource, Service from tailbone.api import APIMasterView @@ -41,6 +42,8 @@ class APIMasterView2(APIMasterView): editable = True deletable = True supports_autocomplete = False + supports_download = False + supports_rawbytes = False @classmethod def establish_method(cls, method_name): @@ -85,6 +88,48 @@ class APIMasterView2(APIMasterView): self.Session.delete(obj) self.Session.flush() + ############################## + # download + ############################## + + def download(self): + """ + GET view allowing for download of a single file, which is attached to a + given record. + """ + obj = self.get_object() + + filename = self.request.GET.get('filename', None) + if not filename: + raise self.notfound() + path = self.download_path(obj, filename) + + response = self.file_response(path) + return response + + def download_path(self, obj, filename): + """ + Should return absolute path on disk, for the given object and filename. + Result will be used to return a file response to client. + """ + raise NotImplementedError + + def rawbytes(self): + """ + GET view allowing for direct access to the raw bytes of a file, which + is attached to a given record. Basically the same as 'download' except + this does not come as an attachment. + """ + obj = self.get_object() + + filename = self.request.GET.get('filename', None) + if not filename: + raise self.notfound() + path = self.download_path(obj, filename) + + response = self.file_response(path, attachment=False) + return response + @classmethod def defaults(cls, config): cls._defaults(config) @@ -137,5 +182,24 @@ class APIMasterView2(APIMasterView): if cls.supports_autocomplete: autocomplete = Service(name='{}.autocomplete'.format(route_prefix), path='{}/autocomplete'.format(collection_url_prefix)) - autocomplete.add_view('GET', 'autocomplete', klass=cls) + autocomplete.add_view('GET', 'autocomplete', klass=cls, + permission='{}.list'.format(permission_prefix)) config.add_cornice_service(autocomplete) + + # download + if cls.supports_download: + download = Service(name='{}.download'.format(route_prefix), + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}/download'.format(object_url_prefix)) + download.add_view('GET', 'download', klass=cls, + permission='{}.download'.format(permission_prefix)) + config.add_cornice_service(download) + + # rawbytes + if cls.supports_rawbytes: + rawbytes = Service(name='{}.rawbytes'.format(route_prefix), + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}/rawbytes'.format(object_url_prefix)) + rawbytes.add_view('GET', 'rawbytes', klass=cls, + permission='{}.download'.format(permission_prefix)) + config.add_cornice_service(rawbytes) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index aa662cf9..7d75e723 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -166,7 +166,7 @@ class View(object): return render_to_response('json', data, request=self.request) - def file_response(self, path, filename=None): + def file_response(self, path, filename=None, attachment=True): """ Returns a generic FileResponse from the given path """ @@ -174,11 +174,12 @@ class View(object): return self.notfound() response = FileResponse(path, request=self.request) response.content_length = os.path.getsize(path) - if not filename: - filename = os.path.basename(path) - if six.PY2: - filename = filename.encode('ascii', 'replace') - response.content_disposition = str('attachment; filename="{}"'.format(filename)) + if attachment: + if not filename: + filename = os.path.basename(path) + if six.PY2: + filename = filename.encode('ascii', 'replace') + response.content_disposition = str('attachment; filename="{}"'.format(filename)) return response def get_quickie_context(self):