diff --git a/nomad/app/v1/routers/entries.py b/nomad/app/v1/routers/entries.py index c442ea4293246de7b376b12ffc4fd4fefed8ab67..a53c6bd934cc0e3a5bdec8d79075cf91507f045b 100644 --- a/nomad/app/v1/routers/entries.py +++ b/nomad/app/v1/routers/entries.py @@ -48,8 +48,9 @@ from ..utils import ( create_download_stream_zipped, create_download_stream_raw_file, DownloadItem, create_responses) from ..models import ( - Aggregation, Pagination, PaginationResponse, MetadataPagination, TermsAggregation, WithQuery, WithQueryAndPagination, MetadataRequired, - MetadataResponse, Metadata, Files, Query, User, Owner, + Aggregation, Pagination, PaginationResponse, MetadataPagination, TermsAggregation, + WithQuery, WithQueryAndPagination, MetadataRequired, MetadataResponse, Metadata, + MetadataEditRequest, MetadataEditRequestResponse, Files, Query, User, Owner, QueryParameters, metadata_required_parameters, files_parameters, metadata_pagination_parameters, HTTPExceptionModel) @@ -280,6 +281,18 @@ _bad_path_response = status.HTTP_404_NOT_FOUND, { 'model': HTTPExceptionModel, 'description': strip('File or directory not found.')} +_bad_edit_request = status.HTTP_400_BAD_REQUEST, { + 'model': HTTPExceptionModel, + 'description': strip('Edit request could not be executed.')} + +_bad_edit_request_authorization = status.HTTP_401_UNAUTHORIZED, { + 'model': HTTPExceptionModel, + 'description': strip('Not enough permissions to execute edit request.')} + +_bad_edit_request_empty_query = status.HTTP_404_NOT_FOUND, { + 'model': HTTPExceptionModel, + 'description': strip('No matching entries found.')} + _raw_download_response = 200, { 'content': {'application/zip': {}}, 'description': strip(''' @@ -1328,3 +1341,35 @@ async def post_entry_metadata_edit( datamodel.Dataset.m_def.a_mongo.objects(dataset_id=dataset).delete() return data + + +@router.post( + '/edit', + tags=[metadata_tag], + summary='Edit the user metadata of a set of entries', + response_model=MetadataEditRequestResponse, + response_model_exclude_unset=True, + response_model_exclude_none=True, + responses=create_responses( + _bad_edit_request, _bad_edit_request_authorization, _bad_edit_request_empty_query)) +async def post_entries_edit( + request: Request, + data: MetadataEditRequest, + user: User = Depends(create_user_dependency(required=True))): + ''' + Updates the metadata of the specified entries. + + **Note:** + - Only admins can edit some of the fields. + - Only entry level attributes (like `comment`, `references` etc.) can be set using + this endpoint; upload level attributes (like `upload_name`, `coauthors`, embargo + settings, etc) need to be set through the endpoint **uploads/upload_id/edit**. + - If the upload is published, the only operation permitted using this endpoint is to + edit the members of datasets you own. + ''' + edit_request_json = await request.json() + response, status_code = proc.MetadataEditRequestHandler.edit_metadata( + edit_request_json=edit_request_json, upload_id=None, user=user) + if status_code != status.HTTP_200_OK and not data.verify_only: + raise HTTPException(status_code=status_code, detail=response.error) + return response diff --git a/nomad/app/v1/routers/uploads.py b/nomad/app/v1/routers/uploads.py index e0b54649af44c8f30ed14fbc8ea568b5b8de1f0d..fe3f68952e1952372cdf8a0461b1359dbd058ea2 100644 --- a/nomad/app/v1/routers/uploads.py +++ b/nomad/app/v1/routers/uploads.py @@ -975,13 +975,13 @@ async def post_upload_edit( - Only admins can edit some of the fields. - The embargo of a published upload is lifted by setting the `embargo_length` attribute to 0. - - If the upload is published, the only operation permitted for non-admin users is to - lift the embargo, i.e. set `embargo_length` to 0, and add/remove entries from the - user's datasets. + - If the upload is published, the only operations permitted using this endpoint is to + lift the embargo, i.e. set `embargo_length` to 0, and to edit the members of datasets + you own. - If a query is specified, it is not possible to edit upload level metadata (like `upload_name`, `coauthors`, etc.), as the purpose of queries is to select only a subset of the upload entries to edit, but changing upload level metadata would affect - all entries of the upload. + **all** entries of the upload. ''' edit_request_json = await request.json() response, status_code = MetadataEditRequestHandler.edit_metadata( diff --git a/tests/app/v1/routers/test_entries_edit.py b/tests/app/v1/routers/test_entries_edit.py index 48456d383b969e8a19d067f2e963c79ec4ddaee8..3089a0a173944a59346c5c15e72274151c63da68 100644 --- a/tests/app/v1/routers/test_entries_edit.py +++ b/tests/app/v1/routers/test_entries_edit.py @@ -17,6 +17,7 @@ # import pytest +from datetime import datetime from nomad import utils from nomad.search import search @@ -24,6 +25,9 @@ from nomad.datamodel import Dataset from nomad import processing as proc from tests.utils import ExampleData +from tests.app.v1.routers.common import assert_response +from tests.processing.test_edit_metadata import ( + assert_metadata_edited, all_coauthor_entry_metadata, all_admin_entry_metadata) logger = utils.get_logger(__name__) @@ -301,3 +305,122 @@ class TestEditRepo(): def test_admin_only(self, other_test_user): rv = self.perform_edit(main_author=other_test_user.user_id) assert rv.status_code != 200 + + +@pytest.mark.parametrize('user, kwargs', [ + pytest.param( + 'test_user', dict( + query={'upload_id': 'id_unpublished_w'}, + metadata=all_coauthor_entry_metadata, + affected_upload_ids=['id_unpublished_w']), + id='edit-all'), + pytest.param( + 'admin_user', dict( + query={'upload_id': 'id_published_w'}, + owner='all', + metadata=all_admin_entry_metadata, + affected_upload_ids=['id_published_w']), + id='protected-admin'), + pytest.param( + 'test_user', dict( + query={'upload_id': 'id_unpublished_w'}, + metadata=all_admin_entry_metadata, + expected_status_code=401), + id='protected-not-admin'), + pytest.param( + 'admin_user', dict( + query={'upload_id': 'id_published_w'}, + owner='all', + metadata=dict(comment='test comment'), + affected_upload_ids=['id_published_w']), + id='published-admin'), + pytest.param( + 'test_user', dict( + query={'upload_id': 'id_published_w'}, + metadata=dict(comment='test comment'), + expected_status_code=401), + id='published-not-admin'), + pytest.param( + None, dict( + owner='all', + query={'upload_id': 'id_unpublished_w'}, + metadata=dict(comment='test comment'), + expected_status_code=401), + id='no-credentials'), + pytest.param( + 'invalid', dict( + owner='all', + query={'upload_id': 'id_unpublished_w'}, + metadata=dict(comment='test comment'), + expected_status_code=401), + id='invalid-credentials'), + pytest.param( + 'other_test_user', dict( + query={'upload_id': 'id_unpublished_w'}, + metadata=dict(comment='test comment'), + expected_status_code=404), + id='no-access'), + pytest.param( + 'other_test_user', dict( + query={'upload_id': 'id_unpublished_w'}, + metadata=dict(comment='test comment'), + affected_upload_ids=['id_unpublished_w'], + add_coauthor=True), + id='coauthor-access'), + pytest.param( + 'test_user', dict( + query={'and': [{'upload_create_time:gt': '2021-01-01'}, {'published': False}]}, + metadata=dict(comment='a test comment'), + affected_upload_ids=['id_unpublished_w']), + id='compound-query-ok'), + pytest.param( + 'test_user', dict( + query={'upload_id': 'id_unpublished_w'}, + metadata=dict(upload_name='a test name'), + expected_status_code=400), + id='query-cannot-edit-upload-data'), + pytest.param( + 'test_user', dict( + query={'upload_create_time:lt': '2021-01-01'}, + metadata=dict(comment='a test comment'), + expected_status_code=404), + id='query-no-results')]) +def test_post_entries_edit( + client, proc_infra, example_data_writeable, a_dataset, test_auth_dict, test_users_dict, + user, kwargs): + ''' + Note, since the endpoint basically just forwards the request to + `MetadataEditRequestHandler.edit_metadata`, we only do very simple verification here, + the more extensive testnig is done in `tests.processing.test_edit_metadata`. + ''' + user_auth, _token = test_auth_dict[user] + user = test_users_dict.get(user) + query = kwargs.get('query') + owner = kwargs.get('owner', 'visible') + metadata = kwargs.get('metadata') + entries = kwargs.get('entries') + entries_key = kwargs.get('entries_key') + verify_only = kwargs.get('verify_only', False) + expected_status_code = kwargs.get('expected_status_code', 200) + if expected_status_code == 200 and not verify_only: + affected_upload_ids = kwargs.get('affected_upload_ids') + expected_metadata = kwargs.get('expected_metadata', metadata) + + add_coauthor = kwargs.get('add_coauthor', False) + if add_coauthor: + upload = proc.Upload.get(affected_upload_ids[0]) + upload.edit_upload_metadata( + edit_request_json={'metadata': {'coauthors': user.user_id}}, user_id=upload.main_author) + upload.block_until_complete() + + edit_request_json = dict( + query=query, owner=owner, metadata=metadata, entries=entries, entries_key=entries_key, + verify_only=verify_only) + url = 'entries/edit' + edit_start = datetime.utcnow().isoformat()[0:22] + response = client.post(url, headers=user_auth, json=edit_request_json) + assert_response(response, expected_status_code) + if expected_status_code == 200: + assert_metadata_edited( + user, None, query, metadata, entries, entries_key, verify_only, + expected_status_code, expected_metadata, affected_upload_ids, edit_start) diff --git a/tests/processing/test_edit_metadata.py b/tests/processing/test_edit_metadata.py index fb9a4c6ec8132330df3c09727a69c1e7e9747fef..7f9d573bac05695d611e232cbb369583ecb233f1 100644 --- a/tests/processing/test_edit_metadata.py +++ b/tests/processing/test_edit_metadata.py @@ -50,6 +50,9 @@ all_admin_metadata = dict( license='a license', main_author='lhofstadter') +all_admin_entry_metadata = { + k: v for k, v in all_admin_metadata.items() if k not in _mongo_upload_metadata} + def assert_edit_request(user, **kwargs): # Extract test parameters (lots of defaults) @@ -136,6 +139,8 @@ def convert_to_comparable_value(quantity, value, from_format, user): ''' if quantity.is_scalar: return convert_to_comparable_value_single(quantity, value, from_format, user) + if value is None and from_format == 'es': + return [] if type(value) != list: value = [value] return [convert_to_comparable_value_single(quantity, v, from_format, user) for v in value]