From 2e5b8f997da47c01e71344834d3ed7977e5e98f3 Mon Sep 17 00:00:00 2001 From: Lars Holm Nielsen Date: Mon, 21 Mar 2016 06:59:48 +0100 Subject: [PATCH] views: records-ui file download view * Adds pluggable file download view for Records-UI. Signed-off-by: Lars Holm Nielsen --- invenio_files_rest/views.py | 90 ++++++++++++++++++++++++-------- setup.py | 1 + tests/conftest.py | 27 ++++++---- tests/test_views_records_ui.py | 95 ++++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 31 deletions(-) create mode 100644 tests/test_views_records_ui.py diff --git a/invenio_files_rest/views.py b/invenio_files_rest/views.py index dfd4bcf6..82cf6450 100644 --- a/invenio_files_rest/views.py +++ b/invenio_files_rest/views.py @@ -45,6 +45,46 @@ ) +def file_download_ui(pid, record, **kwargs): + """File download view for a given record. + + Plug this method into your ``RECORDS_UI_ENDPOINTS`` configuration: + + .. code-block:: python + + RECORDS_UI_ENDPOINTS = dict( + recid=dict( + # ... + route='/records/', + view_imp='invenio_files_rest.views.file_download_ui', + ) + ) + """ + # Extract file from record. + fileobj = None + filename = request.view_args.get('filename') + + for f in record.get('files', []): + if filename and filename == f.get('filename'): + fileobj = f + break + + if fileobj is None: + abort(404) + + bucket_id = fileobj['bucket'] + key = fileobj['filename'] + + return ObjectResource.send_object( + bucket_id, key, + expected_chksum=fileobj.get('checksum'), + logger_data=dict( + bucket_id=bucket_id, + pid_type=pid.pid_type, + pid_value=pid.pid_value, + )) + + class BucketCollectionResource(ContentNegotiatedMethodView): """"Bucket collection resource.""" @@ -400,6 +440,34 @@ def __init__(self, serializers=None, *args, **kwargs): **kwargs ) + @classmethod + def send_object(cls, bucket_id, key, version_id=None, expected_chksum=None, + logger_data=None): + """Send an object for a given bucket.""" + bucket = Bucket.get(bucket_id) + if bucket is None: + abort(404, 'Bucket does not exist.') + + permission = current_permission_factory(bucket, action='objects-read') + + if permission is not None and not permission.can(): + if current_user.is_authenticated: + abort(403, 'You do not have permissions to download the file.') + # TODO: Send user to login page. (not for REST API) + abort(401) + + obj = ObjectVersion.get(bucket_id, key, version_id=version_id) + if obj is None: + abort(404, 'Object does not exist.') + + # TODO: implement access control + # TODO: implement support for tokens + if expected_chksum and obj.file.checksum != expected_chksum: + current_app.logger.warning( + 'File checksum mismatch detected.', extra=logger_data) + + return obj.file.send_file() + @use_kwargs(get_args) def get(self, bucket_id, key, version_id=None, **kwargs): """Get object. @@ -437,27 +505,7 @@ def get(self, bucket_id, key, version_id=None, **kwargs): :statuscode 403: access denied :statuscode 404: Object does not exist """ - # TODO: Support partial range requests. - # TODO: Support for access token - # TODO: Exception if file is not found (deleted by hand or accident) - - # Retrieve bucket. - bucket = Bucket.get(bucket_id) - if bucket is None: - abort(404, 'Bucket does not exist.') - - permission = current_permission_factory(bucket, action='objects-read') - - if permission is not None and not permission.can(): - if current_user.is_authenticated: - abort(403) - abort(401) - - obj = ObjectVersion.get(bucket_id, key, version_id=version_id) - if obj is None: - abort(404, 'Object does not exist.') - - return obj.file.send_file() + return self.send_object(bucket_id, key, version_id=version_id) @use_kwargs(put_args) def put(self, bucket_id, key, content_length=None, content_md5=None): diff --git a/setup.py b/setup.py index 0c8db630..6e2a642f 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ 'invenio-accounts>=1.0.0a2', 'invenio-admin>=1.0.0a3', 'invenio-celery>=1.0.0a4', + 'invenio-records-ui>=1.0.0a5', 'isort>=4.2.2', 'mock>=1.3.0', 'pep257>=0.7.0', diff --git a/tests/conftest.py b/tests/conftest.py index 5fbdcabd..9eb2b6df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,8 +54,8 @@ from invenio_files_rest.views import blueprint -@pytest.yield_fixture(scope='session', autouse=True) -def app(request): +@pytest.fixture(scope='session', autouse=True) +def base_app(): """Flask application fixture.""" app_ = Flask('testapp') app_.config.update( @@ -78,14 +78,21 @@ def app(request): InvenioDB(app_) Babel(app_) Menu(app_) - InvenioAccounts(app_) - InvenioAccess(app_) - app_.register_blueprint(accounts_blueprint) - InvenioFilesREST(app_) - app_.register_blueprint(blueprint) - - with app_.app_context(): - yield app_ + + return app_ + + +@pytest.yield_fixture(scope='session', autouse=True) +def app(base_app): + """Flask application fixture.""" + InvenioAccounts(base_app) + InvenioAccess(base_app) + base_app.register_blueprint(accounts_blueprint) + InvenioFilesREST(base_app) + base_app.register_blueprint(blueprint) + + with base_app.app_context(): + yield base_app @pytest.yield_fixture() diff --git a/tests/test_views_records_ui.py b/tests/test_views_records_ui.py new file mode 100644 index 00000000..b3d28836 --- /dev/null +++ b/tests/test_views_records_ui.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2015, 2016 CERN. +# +# Invenio is free software; you can redistribute it +# and/or modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# Invenio is distributed in the hope that it will be +# useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Invenio; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, +# MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. + + +"""Records-UI custom view func tests.""" + +from __future__ import absolute_import, print_function + +import uuid + +from flask import url_for +from invenio_pidstore import InvenioPIDStore +from invenio_pidstore.models import PersistentIdentifier, PIDStatus +from invenio_records import InvenioRecords +from invenio_records.api import Record +from invenio_records_ui import InvenioRecordsUI + + +def test_file_download_ui(base_app, objects, db): + """Test get buckets.""" + app = base_app + app.config.update(dict( + RECORDS_UI_DEFAULT_PERMISSION_FACTORY=None, # No permission checking + RECORDS_UI_ENDPOINTS=dict( + recid=dict( + pid_type='recid', + route='/records/', + ), + recid_files=dict( + pid_type='recid', + route='/records//files/', + view_imp='invenio_files_rest.views.file_download_ui', + ), + ) + )) + InvenioRecords(app) + InvenioPIDStore(app) + InvenioRecordsUI(app) + + obj1 = objects[0] + + with app.app_context(): + # Record 1 - Live record + rec_uuid = uuid.uuid4() + PersistentIdentifier.create( + 'recid', '1', object_type='rec', object_uuid=rec_uuid, + status=PIDStatus.REGISTERED) + Record.create({ + 'title': 'Registered', + 'recid': 1, + 'files': [ + {'filename': obj1.key, 'bucket': str(obj1.bucket_id), + 'checksum': 'invalid'}, + ] + }, id_=rec_uuid) + db.session.commit() + + main_url = url_for('invenio_records_ui.recid', pid_value='1') + file_url = url_for( + 'invenio_records_ui.recid_files', pid_value='1', filename=obj1.key) + no_file_url = url_for( + 'invenio_records_ui.recid_files', pid_value='1', filename='') + invalid_file_url = url_for( + 'invenio_records_ui.recid_files', pid_value='1', filename='no') + + with app.test_client() as client: + res = client.get(main_url) + assert res.status_code == 200 + res = client.get(file_url) + assert res.status_code == 200 + res = client.get(no_file_url) + assert res.status_code == 404 + res = client.get(invalid_file_url) + assert res.status_code == 404