diff --git a/gui/src/components/api.js b/gui/src/components/api.js index 6ebd3100bed0e6128e6db889cab1d67f93b97034..8dae504c8f4e6449ad5dab18c3d1962a84971525 100644 --- a/gui/src/components/api.js +++ b/gui/src/components/api.js @@ -377,7 +377,7 @@ class Api { } } }) - return this.post('entries/edit_old', edit) + return this.post('entries/edit_v0', edit) } } diff --git a/nomad/app/v1/models.py b/nomad/app/v1/models.py index e6b10a4180d279e5b26f41c4e0ff291c8bc48c99..b1f7c9453f28f7ec4198ca0cb8d1e91a56ebd2bc 100644 --- a/nomad/app/v1/models.py +++ b/nomad/app/v1/models.py @@ -1098,19 +1098,6 @@ class MetadataEditRequest(WithQuery): encountered errors etc.''')) -class MetadataEditRequestResponse(MetadataEditRequest): - error: Optional[str] = Field( - description=strip(''' - An error description, if the validation failed, otherwise None.''')) - feedback: Dict[str, str] = Field( - default={}, - description=strip(''' - A dictionary specifying strings with feedback for specific actuins. - The quantity name is used as key. The feedback may describe an error or just be - an informative message. If multiple errors occur for the same quantity, only - one error message is shown.''')) - - class Files(BaseModel): ''' Configures the download of files. ''' compress: Optional[bool] = Field( diff --git a/nomad/app/v1/routers/entries.py b/nomad/app/v1/routers/entries.py index a53c6bd934cc0e3a5bdec8d79075cf91507f045b..a6f73db67d661f742a370b929ebd44c11d0bb67f 100644 --- a/nomad/app/v1/routers/entries.py +++ b/nomad/app/v1/routers/entries.py @@ -50,7 +50,7 @@ from ..utils import ( from ..models import ( Aggregation, Pagination, PaginationResponse, MetadataPagination, TermsAggregation, WithQuery, WithQueryAndPagination, MetadataRequired, MetadataResponse, Metadata, - MetadataEditRequest, MetadataEditRequestResponse, Files, Query, User, Owner, + MetadataEditRequest, Files, Query, User, Owner, QueryParameters, metadata_required_parameters, files_parameters, metadata_pagination_parameters, HTTPExceptionModel) @@ -1174,7 +1174,7 @@ _editable_quantities = { @router.post( - '/edit_old', + '/edit_v0', tags=[metadata_tag], summary='Edit the user metadata of a set of entries', response_model=EntryMetadataEditResponse, @@ -1347,7 +1347,7 @@ async def post_entry_metadata_edit( '/edit', tags=[metadata_tag], summary='Edit the user metadata of a set of entries', - response_model=MetadataEditRequestResponse, + response_model=MetadataEditRequest, response_model_exclude_unset=True, response_model_exclude_none=True, responses=create_responses( @@ -1365,11 +1365,15 @@ async def post_entries_edit( 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 the entries in datasets that where created by the current user. ''' 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 + try: + verified_json = proc.MetadataEditRequestHandler.edit_metadata( + edit_request_json=edit_request_json, upload_id=None, user=user) + return verified_json + except RequestValidationError as e: + raise # A problem which we have handled explicitly. Fastapi does json conversion. + except Exception as e: + # The upload is processing or some kind of unexpected error has occured + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/nomad/app/v1/routers/uploads.py b/nomad/app/v1/routers/uploads.py index fe3f68952e1952372cdf8a0461b1359dbd058ea2..1669ff1a6f85cb71b30308df83a48ef4e3e44899 100644 --- a/nomad/app/v1/routers/uploads.py +++ b/nomad/app/v1/routers/uploads.py @@ -25,6 +25,7 @@ from fastapi import ( APIRouter, Request, File, UploadFile, status, Depends, Path, Query as FastApiQuery, HTTPException) from fastapi.responses import StreamingResponse +from fastapi.exceptions import RequestValidationError from nomad import utils, config, files, datamodel from nomad.files import UploadFiles, StagingUploadFiles, UploadBundle, is_safe_relative_path @@ -35,7 +36,7 @@ from nomad.search import search from .auth import create_user_dependency, generate_upload_token from ..models import ( MetadataPagination, User, Direction, Pagination, PaginationResponse, HTTPExceptionModel, - Files, files_parameters, WithQuery, MetadataEditRequest, MetadataEditRequestResponse) + Files, files_parameters, WithQuery, MetadataEditRequest) from ..utils import ( parameter_dependency_from_model, create_responses, DownloadItem, create_download_stream_zipped, create_download_stream_raw_file, create_stream_from_string) @@ -957,7 +958,7 @@ async def put_upload_metadata( @router.post( '/{upload_id}/edit', tags=[metadata_tag], summary='Updates the metadata of the specified upload.', - response_model=MetadataEditRequestResponse, + response_model=MetadataEditRequest, responses=create_responses(_upload_not_found, _not_authorized_to_upload, _bad_request), response_model_exclude_unset=True, response_model_exclude_none=True) @@ -976,19 +977,23 @@ async def post_upload_edit( - The embargo of a published upload is lifted by setting the `embargo_length` attribute to 0. - 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. + lift the embargo, i.e. set `embargo_length` to 0, and to edit the entries in datasets + that where created by the current user. - 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. ''' edit_request_json = await request.json() - response, status_code = MetadataEditRequestHandler.edit_metadata( - edit_request_json=edit_request_json, upload_id=upload_id, 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 + try: + verified_json = MetadataEditRequestHandler.edit_metadata( + edit_request_json=edit_request_json, upload_id=upload_id, user=user) + return verified_json + except RequestValidationError as e: + raise # A problem which we have handled explicitly. Fastapi does json conversion. + except Exception as e: + # The upload is processing or some kind of unexpected error has occured + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @router.delete( @@ -1139,6 +1144,41 @@ async def post_upload_action_process( data=_upload_to_pydantic(upload)) +@router.post( + '/{upload_id}/action/lift-embargo', tags=[action_tag], + summary='Lifts the embargo of an upload.', + response_model=UploadProcDataResponse, + responses=create_responses(_upload_not_found, _not_authorized_to_upload, _bad_request), + response_model_exclude_unset=True, + response_model_exclude_none=True) +async def post_upload_action_lift_embargo( + upload_id: str = Path( + ..., + description='The unique id of the upload to lift the embargo for.'), + user: User = Depends(create_user_dependency(required=True))): + ''' Lifts the embargo of an upload. ''' + upload = _get_upload_with_write_access( + upload_id, user, include_published=True, published_requires_admin=False) + _check_upload_not_processing(upload) + if not upload.published: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=strip(''' + Upload is not published, no embargo to lift.''')) + if not upload.with_embargo: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=strip(''' + Upload has no embargo.''')) + # Lift the embargo using MetadataEditRequestHandler.edit_metadata + try: + MetadataEditRequestHandler.edit_metadata( + edit_request_json={'metadata': {'embargo_length': 0}}, upload_id=upload_id, user=user) + upload.reload() + return UploadProcDataResponse( + upload_id=upload_id, + data=_upload_to_pydantic(upload)) + except Exception as e: + # Should only happen if the upload just started processing or something unexpected happens + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + @router.get( '/{upload_id}/bundle', tags=[bundle_tag], summary='Gets an *upload bundle* for the specified upload.', @@ -1471,9 +1511,10 @@ def _get_upload_with_write_access( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=strip(''' The specified upload_id was not found.''')) upload = mongodb_query.first() - if upload.main_author != str(user.user_id) and not user.is_admin: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=strip(''' - You do not have write access to the specified upload.''')) + if not user.is_admin and upload.main_author != str(user.user_id): + if not upload.coauthors or str(user.user_id) not in upload.coauthors: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=strip(''' + You do not have write access to the specified upload.''')) if upload.published: if not include_published: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=strip(''' diff --git a/nomad/cli/client/integrationtests.py b/nomad/cli/client/integrationtests.py index 6ea53fe4adb4f658cd13e5cc7610efa930085e24..fe65b943280f09cc35273fa0d6b238772869d121 100644 --- a/nomad/cli/client/integrationtests.py +++ b/nomad/cli/client/integrationtests.py @@ -180,7 +180,7 @@ def integrationtests(auth: api.Auth, skip_parsers: bool, skip_publish: bool, ski 'datasets': [{'value': dataset}]} response = api.post( - 'entries/edit_old', + 'entries/edit_v0', data=json.dumps(dict(actions=actions, **query_request_params)), auth=auth) assert response.status_code == 200, response.text diff --git a/nomad/processing/data.py b/nomad/processing/data.py index 44e1b41e2ee2336ace6899553cc51b91f24e346e..1afddfbf8282046639207161f695ad8fda95f71b 100644 --- a/nomad/processing/data.py +++ b/nomad/processing/data.py @@ -43,11 +43,13 @@ import yaml import json from functools import lru_cache import requests +from fastapi.exceptions import RequestValidationError +from pydantic.error_wrappers import ErrorWrapper from nomad import utils, config, infrastructure, search, datamodel, metainfo, parsing, client from nomad.files import ( PathObject, UploadFiles, PublicUploadFiles, StagingUploadFiles, UploadBundle, create_tmp_dir) -from nomad.processing.base import Proc, process, ProcessStatus, ProcessFailure +from nomad.processing.base import Proc, process, ProcessStatus, ProcessFailure, ProcessAlreadyRunning from nomad.parsing import Parser from nomad.parsing.parsers import parser_dict, match_parser from nomad.normalizing import normalizers @@ -58,8 +60,7 @@ from nomad.archive import ( write_partial_archive_to_mongo, delete_partial_archives_from_mongo) from nomad.datamodel.encyclopedia import EncyclopediaMetadata from nomad.app.v1.models import ( - MetadataEditRequest, MetadataEditRequestResponse, And, - Aggregation, TermsAggregation, MetadataPagination, MetadataRequired) + MetadataEditRequest, And, Aggregation, TermsAggregation, MetadataPagination, MetadataRequired) from nomad.search import update_metadata as es_update_metadata section_metadata = datamodel.EntryArchive.metadata.name @@ -148,7 +149,7 @@ class MetadataEditRequestHandler: @classmethod def edit_metadata( cls, edit_request_json: Dict[str, Any], upload_id: str, - user: datamodel.User) -> Tuple[MetadataEditRequestResponse, int]: + user: datamodel.User) -> Dict[str, Any]: ''' Method to verify and execute a generic request to edit metadata from a certain user. The request is specified as a json dictionary. Optionally, the request could be restricted @@ -161,32 +162,37 @@ class MetadataEditRequestHandler: for execution, by initiating the @process :func:`edit_upload_metadata` for each affected upload. - The method returns a :class:`MetadataEditRequestResponse` with feedback about how it - went, and a html style status code (int, 200 if successful, otherwise an error code). - This method is designed to not raise any exceptions (rather, errors are reported - by return values). + The method returns a json dictionary with verified data (references resolved to explicit + IDs, list actions always expressed as dicts with "op" and "values", etc), or raises + an exception, namely: + - A :class:`ValidationError` if the request json can't be parsed by pydantic + - A :class:`RequestValidationError` with information about validation failures and + their location (most errors should be of this type, provided that the json is valid) + - A :class:`ProcessAlreadyRunning` exception if one of the affected uploads has + a running process + - Some other type of exception, if something goes wrong unexpectedly (should hopefully + never happen) ''' logger = utils.get_logger('nomad.processing.edit_metadata') handler = MetadataEditRequestHandler( logger, user, edit_request_json=edit_request_json, upload_id=upload_id) # Validate the request - handler.validate_request() + handler.validate_request() # Should raise errors if something looks wrong - if not handler.error and not edit_request_json.get('verify_only'): + if not edit_request_json.get('verify_only'): # Check if any of the affected uploads are processing for upload in handler.affected_uploads: upload.reload() if upload.process_running: - handler._fatal_error(f'Upload {upload.upload_id} is currently processing') - return handler.create_request_response(), 400 + raise ProcessAlreadyRunning(f'Upload {upload.upload_id} is currently processing') # Looks good, try to trigger processing for upload in handler.affected_uploads: - try: - upload.edit_upload_metadata(edit_request_json, user.user_id) # Trigger the process - except Exception as e: - handler._fatal_error(f'Failed to start process for upload {upload.upload_id}: {e}') - return handler.create_request_response(), 400 - return handler.create_request_response(), handler.status_code + upload.edit_upload_metadata(edit_request_json, user.user_id) # Trigger the process + # All went well, return a verified json as response + verified_json = copy.deepcopy(handler.edit_request_json) + verified_json['metadata'] = handler.root_metadata + verified_json['entries'] = handler.entries_metadata + return verified_json def __init__( self, logger, user: datamodel.User, @@ -203,12 +209,10 @@ class MetadataEditRequestHandler: self.upload_files = upload_files self.upload_id = upload_id - self.status_code = 200 # A html style status code - self.error: str = None # A description of the last error, if any. - self.feedback: Dict[str, str] = {} # { quantity_name: feedback_msg } - self.quantities_attempted_to_edit: Set[str] = set() # Quantities user has attempted to edit + self.errors: List[ErrorWrapper] = [] # A list of all encountered errors, if any + self.edit_attempt_locs: List[Tuple[str, ...]] = [] # locs where user has attempted to edit something self.required_auth_level = AuthLevel.none # Maximum required auth level for the edit - self.required_auth_level_quantity: str = None # An example of an edited quantity which requires this auth level + self.required_auth_level_locs: List[Tuple[str, ...]] = [] # locs where maximal auth level is needed self.encountered_users: Dict[str, str] = {} # { ref: user_id | None }, ref = user_id | username | email self.encountered_datasets: Dict[str, datamodel.Dataset] = {} # { ref : dataset | None }, ref = dataset_id | dataset_name self.root_metadata: Dict[str, Any] = None # The metadata specified at the top/root level @@ -222,42 +226,41 @@ class MetadataEditRequestHandler: pass # TODO def validate_request(self): - ''' Validates the provided :class:`MetadataEditRequest` ''' - try: - self.edit_request = MetadataEditRequest(**self.edit_request_json) - except Exception as e: - return self._fatal_error(f'Failed to parse request json: {e}') + ''' Validates the provided edit_request_json. ''' + # Validate the request json. Will raise ValidationError if json is malformed + self.edit_request = MetadataEditRequest(**self.edit_request_json) try: if not self.upload_id and not self.edit_request.query: - return self._fatal_error('Must specify `query`') + return self._loc_error('Must specify `query`', 'query') if self.edit_request.entries and not self.edit_request.entries_key: - return self._fatal_error('Must specify `entries_key` when specifying `entries`') + return self._loc_error('Must specify `entries_key` when specifying `entries`', 'entries_key') can_edit_upload_fields = bool(self.upload_id and not self.edit_request.query) if self.edit_request.metadata: self.root_metadata = self._verify_metadata_edit_actions( - self.edit_request.metadata.dict(), can_edit_upload_fields) + self.edit_request_json['metadata'], ('metadata',), can_edit_upload_fields) if self.edit_request.entries: - for key, entry_metadata in self.edit_request.entries.items(): - verified_metadata = self._verify_metadata_edit_actions(entry_metadata.dict(), False) + for key, entry_metadata in self.edit_request_json['entries'].items(): + verified_metadata = self._verify_metadata_edit_actions( + entry_metadata, ('entries', key), False) self.entries_metadata[key] = verified_metadata - if not self.quantities_attempted_to_edit: - return self._fatal_error('No fields to update specified') + if not self.edit_attempt_locs: + return self._loc_error('No fields to update specified', 'metadata') if self.required_auth_level == AuthLevel.admin and not self.user.is_admin: - return self._quantity_error( - self.required_auth_level_quantity, 'Admin rights required', 401) - - embargo_length: int = None - if can_edit_upload_fields and self.edit_request.metadata: - embargo_length = self.edit_request.metadata.embargo_length # type: ignore + for loc in self.required_auth_level_locs: + self._loc_error('Admin rights required', loc) + return + embargo_length: int = self.root_metadata.get('embargo_length') try: self.affected_uploads = self._find_request_uploads() except Exception as e: - return self._fatal_error('Could not evaluate query: ' + str(e)) + return self._loc_error('Could not evaluate query: ' + str(e), 'query') if not self.affected_uploads: - return self._fatal_error('No matching uploads/entries found', 404) + if self.edit_request.query: + return self._loc_error('No matching entries found', 'query') + return self._loc_error('No matching upload found', 'upload_id') for upload in self.affected_uploads: # Check permissions coauthor = upload.coauthors and self.user.user_id in upload.coauthors @@ -272,24 +275,33 @@ class MetadataEditRequestHandler: else: assert False, 'Invalid required_auth_level' # Should not happen if not has_access: - return self._quantity_error( - self.required_auth_level_quantity, - f'Editing quantity requires {self.required_auth_level} ' - f' access for upload {upload.upload_id}', 401) + for loc in self.required_auth_level_locs: + self._loc_error( + f'{self.required_auth_level} access required for upload ' + f'{upload.upload_id}', loc) + return # Other checks if embargo_length is not None: if upload.published and not admin and embargo_length != 0: - self._quantity_error( - 'embargo_length', - f'Upload {upload.upload_id} is published, embargo can only be lifted') + self._loc_error( + f'Upload {upload.upload_id} is published, embargo can only be lifted', + ('metadata', 'embargo_length')) if upload.published and not admin: - for quantity_name in self.quantities_attempted_to_edit: - if quantity_name not in ('embargo_length', 'datasets'): - self._quantity_error( - quantity_name, - f'Cannot update, upload {upload.upload_id} is published.', 401) + has_invalid_edit = False + for edit_loc in self.edit_attempt_locs: + if edit_loc[-1] not in ('embargo_length', 'datasets'): + has_invalid_edit = True + self._loc_error( + f'Cannot update, upload {upload.upload_id} is published.', edit_loc) + if has_invalid_edit: + return except Exception as e: - return self._fatal_error(f'Unexpected exception: {e}') + # Something unexpected has gone wrong + self.logger.error(e) + raise + finally: + if self.errors: + raise RequestValidationError(errors=self.errors) def get_upload_metadata_to_set(self, upload: 'Upload') -> Dict[str, Any]: ''' @@ -324,22 +336,14 @@ class MetadataEditRequestHandler: pass # TODO return rv - def _quantity_error(self, quantity_name: str, msg: str, error_code: int = 400): - ''' Registers an error message for a specific quantity. ''' - self.status_code = error_code - self.error = f'Error for quantity `{quantity_name}`: {msg}' - self.feedback[quantity_name] = msg - self.logger.error(self.error) - - def _fatal_error(self, error: str, status_code: int = 400): - ''' Registers a general, unrecoverable error, not related to a specific quantity ''' - self.error = error - self.status_code = status_code - self.logger.error(error) + def _loc_error(self, msg: str, loc: Union[str, Tuple[str, ...]]): + ''' Registers a located error. ''' + self.errors.append(ErrorWrapper(Exception(msg), loc=loc)) + self.logger.error(msg, loc=loc) def _verify_metadata_edit_actions( - self, metadata_edit_actions: Dict[str, Any], can_edit_upload_fields: bool, - auth_level: AuthLevel = None) -> Dict[str, Any]: + self, metadata_edit_actions: Dict[str, Any], loc: Tuple[str, ...], + can_edit_upload_fields: bool, auth_level: AuthLevel = None) -> Dict[str, Any]: ''' Performs *basic* validation of a dictionary with metadata edit actions, and returns a dictionary with the same structure, but containing only the *verified* actions. Verified @@ -357,36 +361,36 @@ class MetadataEditRequestHandler: for quantity_name, action in metadata_edit_actions.items(): if action is not None: success, verified_action = self._verify_metadata_edit_action( - quantity_name, action, can_edit_upload_fields, auth_level) + quantity_name, action, loc + (quantity_name,), can_edit_upload_fields, auth_level) if success: rv[quantity_name] = verified_action return rv def _verify_metadata_edit_action( - self, quantity_name: str, action: Any, can_edit_upload_fields: bool, - auth_level: AuthLevel) -> Tuple[bool, Any]: + self, quantity_name: str, action: Any, loc: Tuple[str, ...], + can_edit_upload_fields: bool, auth_level: AuthLevel) -> Tuple[bool, Any]: ''' Performs basic validation of a single action. Returns (success, verified_action). ''' definition = _editable_metadata.get(quantity_name) if not definition: - self._quantity_error(quantity_name, 'Unknown quantity') + self._loc_error('Unknown quantity', loc) return False, None - self.quantities_attempted_to_edit.add(quantity_name) + self.edit_attempt_locs.append(loc) field_auth_level = getattr(definition, 'a_auth_level', AuthLevel.coauthor) if auth_level is not None: # Our auth level is known, check it immediately if field_auth_level > auth_level: - self._quantity_error(quantity_name, f'Requires {field_auth_level} privileges') + self._loc_error(f'{field_auth_level} privileges required', loc) return False, None if field_auth_level > self.required_auth_level: self.required_auth_level = field_auth_level - self.required_auth_level_quantity = quantity_name + self.required_auth_level_locs = [loc] if quantity_name in _mongo_upload_metadata and not can_edit_upload_fields: - self._quantity_error(quantity_name, 'Quantity can only be edited on the upload level') + self._loc_error('Quantity can only be edited on the upload level', loc) return False, None try: @@ -401,9 +405,8 @@ class MetadataEditRequestHandler: values = action['values'] assert op in ('set', 'add', 'remove'), 'op should be `set`, `add` or `remove`' if quantity_name == 'datasets' and op == 'set': - self._quantity_error( - quantity_name, - 'Only `add` and `remove` operations permitted for datasets') + self._loc_error( + 'Only `add` and `remove` operations permitted for datasets', loc) return False, None else: op = 'set' @@ -414,7 +417,7 @@ class MetadataEditRequestHandler: verified_values = [self._verified_value(definition, v) for v in values] return True, dict(op=op, values=verified_values) except Exception as e: - self._quantity_error(quantity_name, str(e)) + self._loc_error(str(e), loc) return False, None def _verified_value( @@ -591,21 +594,6 @@ class MetadataEditRequestHandler: for entry in Calc.objects(upload_id=upload.upload_id): yield entry - def create_request_response(self) -> MetadataEditRequestResponse: - ''' Creates a :class:`MetadataEditRequestResponse` with the validation results. ''' - # Create response by updating the request json with the verified values when possible - verified_json = copy.deepcopy(self.edit_request_json) - if self.root_metadata: - verified_json['metadata'].update(self.root_metadata) - if self.entries_metadata: - for key, entry_metadata in verified_json['entries'].items(): - entry_metadata.update(self.entries_metadata.get(key, {})) - # Create response - response = MetadataEditRequestResponse(**verified_json) - response.error = self.error - response.feedback = self.feedback - return response - class Calc(Proc): ''' @@ -2031,8 +2019,7 @@ class Upload(Proc): # Validate the request (the @process could have been invoked directly, without previous validation) handler = MetadataEditRequestHandler( logger, user, edit_request_json=edit_request_json, upload_id=self.upload_id) - handler.validate_request() - assert not handler.error, handler.error + handler.validate_request() # Should raise errors if something looks wrong # Upload level metadata old_with_embargo = self.with_embargo diff --git a/tests/app/v1/routers/test_entries_edit.py b/tests/app/v1/routers/test_entries_edit.py index 3089a0a173944a59346c5c15e72274151c63da68..baeb3ef66e5775e364d8fecb440e989357241e50 100644 --- a/tests/app/v1/routers/test_entries_edit.py +++ b/tests/app/v1/routers/test_entries_edit.py @@ -86,7 +86,7 @@ class TestEditRepo(): if verify: data.update(verify=verify) - return self.api.post('entries/edit_old', headers=self.test_user_auth, json=data) + return self.api.post('entries/edit_v0', headers=self.test_user_auth, json=data) def assert_edit(self, rv, quantity: str, success: bool, message: bool, status_code: int = 200): data = rv.json() @@ -325,7 +325,7 @@ class TestEditRepo(): 'test_user', dict( query={'upload_id': 'id_unpublished_w'}, metadata=all_admin_entry_metadata, - expected_status_code=401), + expected_error_loc=('metadata', 'entry_create_time')), id='protected-not-admin'), pytest.param( 'admin_user', dict( @@ -338,7 +338,7 @@ class TestEditRepo(): 'test_user', dict( query={'upload_id': 'id_published_w'}, metadata=dict(comment='test comment'), - expected_status_code=401), + expected_error_loc=('metadata', 'comment')), id='published-not-admin'), pytest.param( None, dict( @@ -358,7 +358,7 @@ class TestEditRepo(): 'other_test_user', dict( query={'upload_id': 'id_unpublished_w'}, metadata=dict(comment='test comment'), - expected_status_code=404), + expected_error_loc=('query',)), id='no-access'), pytest.param( 'other_test_user', dict( @@ -377,13 +377,13 @@ class TestEditRepo(): 'test_user', dict( query={'upload_id': 'id_unpublished_w'}, metadata=dict(upload_name='a test name'), - expected_status_code=400), + expected_error_loc=('metadata', 'upload_name')), 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), + expected_error_loc=('query',)), id='query-no-results')]) def test_post_entries_edit( client, proc_infra, example_data_writeable, a_dataset, test_auth_dict, test_users_dict, @@ -401,10 +401,10 @@ def test_post_entries_edit( 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) + expected_error_loc = kwargs.get('expected_error_loc') + expected_status_code = kwargs.get('expected_status_code') + 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: @@ -419,8 +419,14 @@ def test_post_entries_edit( 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: + if expected_error_loc: + assert_response(response, 422) + error_locs = [tuple(d['loc']) for d in response.json()['detail']] + assert expected_error_loc in error_locs + elif expected_status_code not in (None, 200): + assert_response(response, expected_status_code) + else: + assert_response(response, 200) assert_metadata_edited( user, None, query, metadata, entries, entries_key, verify_only, - expected_status_code, expected_metadata, affected_upload_ids, edit_start) + expected_metadata, affected_upload_ids, edit_start) diff --git a/tests/app/v1/routers/test_uploads.py b/tests/app/v1/routers/test_uploads.py index 545852ee1468a7b68204a128ffb591ebdf111d75..550285c07df0ffb8617e39c04a46676a4250599d 100644 --- a/tests/app/v1/routers/test_uploads.py +++ b/tests/app/v1/routers/test_uploads.py @@ -1064,12 +1064,12 @@ def test_put_upload_metadata( pytest.param( 'test_user', 'id_unpublished_w', dict( metadata=dict(main_author='lhofstadter'), - expected_status_code=401), + expected_error_loc=('metadata', 'main_author')), id='protected-not-admin'), pytest.param( 'test_user', 'silly_value', dict( metadata=dict(upload_name='test_name'), - expected_status_code=404), + expected_error_loc=('upload_id',)), id='bad-upload_id'), pytest.param( 'admin_user', 'id_published_w', dict( @@ -1078,7 +1078,7 @@ def test_put_upload_metadata( pytest.param( 'test_user', 'id_published_w', dict( metadata=dict(upload_name='test_name'), - expected_status_code=401), + expected_error_loc=('metadata', 'upload_name')), id='published-not-admin'), pytest.param( None, 'id_unpublished_w', dict( @@ -1093,7 +1093,7 @@ def test_put_upload_metadata( pytest.param( 'other_test_user', 'id_unpublished_w', dict( metadata=dict(upload_name='test_name'), - expected_status_code=401), + expected_error_loc=('metadata', 'upload_name')), id='no-access'), pytest.param( 'other_test_user', 'id_unpublished_w', dict( @@ -1120,14 +1120,14 @@ def test_put_upload_metadata( query={'and': [{'upload_create_time:gt': '2021-01-01'}, {'published': False}]}, owner='user', metadata=dict(upload_name='a test name'), - expected_status_code=400), + expected_error_loc=('metadata', 'upload_name')), id='query-cannot-edit-upload-data'), pytest.param( 'test_user', 'id_unpublished_w', dict( query={'upload_create_time:lt': '2021-01-01'}, owner='user', metadata=dict(comment='a test comment'), - expected_status_code=404), + expected_error_loc=('query',)), id='query-no-results')]) def test_post_upload_edit( client, proc_infra, example_data_writeable, a_dataset, test_auth_dict, test_users_dict, @@ -1145,16 +1145,17 @@ def test_post_upload_edit( 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', [upload_id]) - expected_metadata = kwargs.get('expected_metadata', metadata) + expected_error_loc = kwargs.get('expected_error_loc') + expected_status_code = kwargs.get('expected_status_code') + affected_upload_ids = kwargs.get('affected_upload_ids', [upload_id]) + expected_metadata = kwargs.get('expected_metadata', metadata) add_coauthor = kwargs.get('add_coauthor', False) if add_coauthor: upload = Upload.get(upload_id) - upload.coauthors = [user.user_id] - upload.save() + 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, @@ -1162,11 +1163,17 @@ def test_post_upload_edit( url = f'uploads/{upload_id}/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: + if expected_error_loc: + assert_response(response, 422) + error_locs = [tuple(d['loc']) for d in response.json()['detail']] + assert expected_error_loc in error_locs + elif expected_status_code not in (None, 200): + assert_response(response, expected_status_code) + else: + assert_response(response, 200) assert_metadata_edited( user, upload_id, query, metadata, entries, entries_key, verify_only, - expected_status_code, expected_metadata, affected_upload_ids, edit_start) + expected_metadata, affected_upload_ids, edit_start) @pytest.mark.parametrize('mode, source_path, query_args, user, use_upload_token, test_limit, accept_json, expected_status_code', [ @@ -1405,6 +1412,38 @@ def test_post_upload_action_process( assert_processing(client, upload_id, test_auth_dict['test_user'][0], check_files=False, published=True) +@pytest.mark.parametrize('upload_id, user, preprocess, expected_status_code', [ + pytest.param('id_published_w', 'test_user', None, 200, id='ok'), + pytest.param('id_published_w', 'other_test_user', None, 401, id='no-access'), + pytest.param('id_published_w', 'other_test_user', 'make-coauthor', 200, id='ok-coauthor'), + pytest.param('id_published_w', None, None, 401, id='no-credentials'), + pytest.param('id_published_w', 'invalid', None, 401, id='invalid-credentials'), + pytest.param('id_unpublished_w', 'test_user', None, 400, id='not-published'), + pytest.param('id_published_w', 'test_user', 'lift', 400, id='already-lifted')]) +def test_post_upload_action_lift_embargo( + client, proc_infra, example_data_writeable, test_auth_dict, test_users_dict, + upload_id, user, preprocess, expected_status_code): + + user_auth, __token = test_auth_dict[user] + user = test_users_dict.get(user) + + if preprocess: + if preprocess == 'lift': + metadata = {'embargo_length': 0} + elif preprocess == 'make-coauthor': + metadata = {'coauthors': user.user_id} + upload = Upload.get(upload_id) + upload.edit_upload_metadata(dict(metadata=metadata), config.services.admin_user_id) + upload.block_until_complete() + + response = perform_post_upload_action(client, user_auth, upload_id, 'lift-embargo') + assert_response(response, expected_status_code) + if expected_status_code == 200: + assert_metadata_edited( + user, upload_id, None, None, None, None, False, + {'embargo_length': 0}, [upload_id], None) + + @pytest.mark.parametrize('upload_id, user, expected_status_code', [ pytest.param('id_unpublished_w', 'test_user', 200, id='delete-own'), pytest.param('id_unpublished_w', 'other_test_user', 401, id='delete-others-not-admin'), diff --git a/tests/processing/test_edit_metadata.py b/tests/processing/test_edit_metadata.py index 7f9d573bac05695d611e232cbb369583ecb233f1..4408e68ac34c1111138cd9ad5625c8326f55f237 100644 --- a/tests/processing/test_edit_metadata.py +++ b/tests/processing/test_edit_metadata.py @@ -18,6 +18,8 @@ import pytest from datetime import datetime +from fastapi.exceptions import RequestValidationError + from nomad import datamodel, metainfo from nomad.processing import Upload, MetadataEditRequestHandler from nomad.processing.data import _editable_metadata, _mongo_upload_metadata @@ -63,34 +65,37 @@ def assert_edit_request(user, **kwargs): entries = kwargs.get('entries') entries_key = kwargs.get('entries_key', 'calc_id') 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', [upload_id]) - expected_metadata = kwargs.get('expected_metadata', metadata) + expected_error_loc = kwargs.get('expected_error_loc') + affected_upload_ids = kwargs.get('affected_upload_ids', [upload_id]) + expected_metadata = kwargs.get('expected_metadata', metadata) # Perform edit request edit_request_json = dict( query=query, owner=owner, metadata=metadata, entries=entries, entries_key=entries_key, verify=verify_only) edit_start = datetime.utcnow().isoformat()[0:22] - _response, status_code = MetadataEditRequestHandler.edit_metadata(edit_request_json, upload_id, user) + try: + MetadataEditRequestHandler.edit_metadata(edit_request_json, upload_id, user) + except RequestValidationError as e: + error_locs = [error_dict['loc'] for error_dict in e.errors()] # Validate result - assert status_code == expected_status_code, 'Wrong status code returned' - if status_code == 200 and not verify_only: + if expected_error_loc: + assert expected_error_loc in error_locs + if not expected_error_loc and not verify_only: assert_metadata_edited( user, upload_id, query, metadata, entries, entries_key, verify_only, - expected_status_code, expected_metadata, affected_upload_ids, edit_start) + expected_metadata, affected_upload_ids, edit_start) def assert_metadata_edited( user, upload_id, query, metadata, entries, entries_key, verify_only, - expected_status_code, expected_metadata, affected_upload_ids, edit_start): + expected_metadata, affected_upload_ids, edit_start): for upload_id in affected_upload_ids: upload = Upload.get(upload_id) upload.block_until_complete() for entry in upload.calcs: assert entry.last_edit_time - assert entry.last_edit_time.isoformat()[0:22] >= edit_start + assert edit_start is None or entry.last_edit_time.isoformat()[0:22] >= edit_start entry_metadata_mongo = entry.mongo_metadata(upload).m_to_dict() entry_metadata_es = search(owner=None, query={'calc_id': entry.calc_id}).data[0] values_to_check = expected_metadata @@ -189,22 +194,22 @@ def convert_to_comparable_value_single(quantity, value, format, user): pytest.param( dict( metadata=dict(external_db='bad value'), - expected_status_code=400), + expected_error_loc=('metadata', 'external_db')), id='bad-external_db'), pytest.param( dict( metadata=dict(coauthors='silly value'), - expected_status_code=400), + expected_error_loc=('metadata', 'coauthors')), id='bad-coauthor-ref'), pytest.param( dict( metadata=dict(reviewers='silly value'), - expected_status_code=400), + expected_error_loc=('metadata', 'reviewers')), id='bad-reviewer-ref'), pytest.param( dict( metadata=dict(datasets=['silly value']), - expected_status_code=400), + expected_error_loc=('metadata', 'datasets')), id='bad-dataset-ref'), pytest.param( dict( @@ -225,7 +230,7 @@ def convert_to_comparable_value_single(quantity, value, format, user): owner='user', upload_id=None, metadata=dict(comment='new comment'), - expected_status_code=404), + expected_error_loc=('query',)), id='query-no-results'), pytest.param( dict( @@ -233,7 +238,7 @@ def convert_to_comparable_value_single(quantity, value, format, user): owner='user', upload_id=None, metadata=dict(comment='new comment'), - expected_status_code=401), + expected_error_loc=('metadata', 'comment')), id='query-contains-published')]) def test_edit_metadata(proc_infra, purged_app, example_data_writeable, a_dataset, test_users_dict, kwargs): kwargs['user'] = test_users_dict[kwargs.get('user', 'test_user')] @@ -265,7 +270,7 @@ def test_admin_quantities(proc_infra, example_data_writeable, test_user, other_t # try to do the same as a non-admin for k, v in all_admin_metadata.items(): assert_edit_request( - user=test_user, upload_id='id_unpublished_w', metadata={k: v}, expected_status_code=401) + user=test_user, upload_id='id_unpublished_w', metadata={k: v}, expected_error_loc=('metadata', k)) def test_query_cannot_set_upload_attributes(proc_infra, example_data_writeable, a_dataset, test_user): @@ -277,7 +282,7 @@ def test_query_cannot_set_upload_attributes(proc_infra, example_data_writeable, assert_edit_request( user=test_user, query=query, owner='user', upload_id=upload_id, metadata={k: v}, - expected_status_code=400) + expected_error_loc=('metadata', k)) # Attempting to edit an entry level attribute with query should always succeed assert_edit_request( user=test_user, query=query, owner='user', upload_id=None,