diff --git a/nomad/api/__init__.py b/nomad/api/__init__.py index 5d04f83592f42a433b1569500e5f6b9a461bc866..328d5ba761f5810abe7faad996908127ab1c9dfc 100644 --- a/nomad/api/__init__.py +++ b/nomad/api/__init__.py @@ -29,7 +29,7 @@ There is a separate documentation for the API endpoints from a client perspectiv .. automodule:: nomad.api.admin """ from .app import app -from . import info, auth, admin, upload, repo, archive, raw +from . import info, auth, admin, upload, repo, archive, raw, mirror @app.before_first_request diff --git a/nomad/api/admin.py b/nomad/api/admin.py index 270951858d240b05ec67daf48c2bc6597df343b5..c2745391d1de50eeb5fd27725dcc033470734611 100644 --- a/nomad/api/admin.py +++ b/nomad/api/admin.py @@ -12,13 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -from flask import g, request +from flask import request from flask_restplus import abort, Resource, fields from nomad import infrastructure, config from .app import api -from .auth import login_really_required +from .auth import admin_login_required ns = api.namespace('admin', description='Administrative operations') @@ -29,7 +29,7 @@ class AdminResetResource(Resource): @api.doc('exec_reset_command') @api.response(200, 'Reset performed') @api.response(400, 'Reset not available/disabled') - @login_really_required + @admin_login_required def post(self): """ The ``reset`` command will attempt to clear the contents of all databased and @@ -37,9 +37,6 @@ class AdminResetResource(Resource): Nomad can be configured to disable reset and the command might not be available. """ - if not g.user.is_admin: - abort(401, message='Only the admin user can perform reset.') - if config.services.disable_reset: abort(400, message='Operation is disabled') @@ -53,7 +50,7 @@ class AdminRemoveResource(Resource): @api.doc('exec_remove_command') @api.response(200, 'Remove performed') @api.response(400, 'Remove not available/disabled') - @login_really_required + @admin_login_required def post(self): """ The ``remove``command will attempt to remove all databases. Expect the @@ -61,8 +58,6 @@ class AdminRemoveResource(Resource): Nomad can be configured to disable remove and the command might not be available. """ - if not g.user.is_admin: - abort(401, message='Only the admin user can perform remove.') if config.services.disable_reset: abort(400, message='Operation is disabled') @@ -77,21 +72,20 @@ pidprefix_model = api.model('PidPrefix', { }) +# TODO remove after migration @ns.route('/pidprefix') class AdminPidPrefixResource(Resource): @api.doc('exec_pidprefix_command') @api.response(200, 'Pid prefix set') @api.response(400, 'Bad pid prefix data') @api.expect(pidprefix_model) - @login_really_required + @admin_login_required def post(self): """ The ``pidprefix``command will set the pid counter to the given value. This might be useful while migrating data with old pids. """ - if not g.user.is_admin: - abort(401, message='Only the admin user can perform remove.') infrastructure.set_pid_prefix(**request.get_json()) diff --git a/nomad/api/auth.py b/nomad/api/auth.py index 165be56f84e3910515dd336ed14c7bb650ac433d..f8d401ca9b17557028d4b27ff310bc998f20954d 100644 --- a/nomad/api/auth.py +++ b/nomad/api/auth.py @@ -128,6 +128,24 @@ def login_really_required(func): return wrapper +def admin_login_required(func): + """ + A decorator for API endpoint implementations that should only work for the admin user. + """ + @api.response(401, 'Authentication required or not authorized as admin user. Only admin can access this endpoint.') + @api.doc(security=list(api.authorizations.keys())) + @login_really_required + def wrapper(*args, **kwargs): + if not g.user.is_admin: + abort(401, message='Only the admin user can perform reset.') + else: + return func(*args, **kwargs) + + wrapper.__name__ = func.__name__ + wrapper.__doc__ = func.__doc__ + return wrapper + + ns = api.namespace( 'auth', description='Authentication related endpoints.') diff --git a/nomad/api/common.py b/nomad/api/common.py index 9ee259a5704b93072ee71fdc00c6863075068359..87fdbdb5f37727e4b903599d7c9f19c0b405e0d2 100644 --- a/nomad/api/common.py +++ b/nomad/api/common.py @@ -52,3 +52,14 @@ def calc_route(ns, prefix: str = ''): })(func) ) return decorator + + +def upload_route(ns, prefix: str = ''): + """ A resource decorator for /<upload> based routes. """ + def decorator(func): + ns.route('%s/<string:upload_id>' % prefix)( + api.doc(params={ + 'upload_id': 'The unique id for the requested upload.' + })(func) + ) + return decorator diff --git a/nomad/api/mirror.py b/nomad/api/mirror.py new file mode 100644 index 0000000000000000000000000000000000000000..30ccb9ca56079273c58cd989fd82b6c36aeaa9de --- /dev/null +++ b/nomad/api/mirror.py @@ -0,0 +1,62 @@ +# Copyright 2018 Markus Scheidgen +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an"AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +The mirror API of the nomad@FAIRDI APIs. Allows to export upload metadata. +""" + +from flask_restplus import Resource, abort, fields + +from nomad import processing + +from .app import api +from .auth import admin_login_required +from .common import upload_route + +ns = api.namespace('mirror', description='Export upload (and all calc) metadata.') + + +mirror_upload_model = api.model('MirrorUpload', { + 'upload_id': fields.String(description='The id of the exported upload'), + 'upload': fields.String(description='The upload metadata as mongoengine json string'), + 'calcs': fields.List(fields.String, description='All upload calculation metadata as mongoengine json strings'), + 'upload_files_path': fields.String(description='The path to the local uploads file folder') +}) + + +@upload_route(ns) +class MirrorUploadResource(Resource): + @api.response(400, 'Not available for the given upload, e.g. upload not published.') + @api.response(404, 'The upload does not exist') + @api.marshal_with(mirror_upload_model, skip_none=True, code=200, description='Upload exported') + @api.doc('get_mirror_upload') + @admin_login_required + def get(self, upload_id): + """ + Export upload (and all calc) metadata for mirrors. + """ + try: + upload = processing.Upload.get(upload_id) + except KeyError: + abort(404, message='Upload with id %s does not exist.' % upload_id) + + if not upload.published: + abort(400, message='Only published uploads can be exported') + + return { + 'upload_id': upload.upload_id, + 'upload': upload.to_json(), + 'calcs': [calc.to_json() for calc in upload.calcs], + 'upload_files_path': upload.upload_files.os_path + }, 200 diff --git a/nomad/api/upload.py b/nomad/api/upload.py index 225cdd3a873869734f96b493c31a7f1e41ab5a49..d435626ce473c0aa863fc7a92926acf6eb3ab231 100644 --- a/nomad/api/upload.py +++ b/nomad/api/upload.py @@ -32,7 +32,7 @@ from nomad.processing import ProcessAlreadyRunning from .app import api, with_logger, RFC3339DateTime from .auth import login_really_required -from .common import pagination_request_parser, pagination_model +from .common import pagination_request_parser, pagination_model, upload_route ns = api.namespace( @@ -342,8 +342,7 @@ class ProxyUpload: return self.upload.__getattribute__(name) -@ns.route('/<string:upload_id>') -@api.doc(params={'upload_id': 'The unique id for the requested upload.'}) +@upload_route(ns) class UploadResource(Resource): @api.doc('get_upload') @api.response(404, 'Upload does not exist') diff --git a/tests/test_api.py b/tests/test_api.py index be639bda8bd6f4e14bc0413f6ba2b6249f4d5266..cf723f7860c26d0ea7d153c4a41ffe4ddf0a212c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1022,6 +1022,21 @@ class TestRaw(UploadFilesBasedTests): assert rv.status_code == 404 +class TestMirror: + + def test_upload(self, client, published, admin_user_auth, no_warn): + url = '/mirror/%s' % published.upload_id + rv = client.get(url, headers=admin_user_auth) + assert rv.status_code == 200 + + data = json.loads(rv.data) + assert data['upload_id'] == published.upload_id + assert json.loads(data['upload'])['_id'] == published.upload_id + assert Upload.from_json(data['upload']).upload_id == published.upload_id + assert len(data['calcs']) == len(published.calcs) + assert data['upload_files_path'] == published.upload_files.os_path + + def test_docs(client): rv = client.get('/docs/index.html') rv = client.get('/docs/introduction.html')