diff --git a/backend/layers/business/business.py b/backend/layers/business/business.py index 8e22aaf615b1a..d55551d8e815d 100644 --- a/backend/layers/business/business.py +++ b/backend/layers/business/business.py @@ -257,6 +257,25 @@ def _assert_collection_version_unpublished( raise CollectionIsPublishedException([f"Collection version {collection_version_id.id} is published"]) return collection_version + def create_empty_dataset(self, collection_version_id: CollectionVersionId) -> Tuple[DatasetVersionId, DatasetId]: + """ + Creates an empty dataset that can be later used for ingestion + """ + self._assert_collection_version_unpublished(collection_version_id) + + new_dataset_version = self.database_provider.create_canonical_dataset(collection_version_id) + # adds new dataset version to collection version + self.database_provider.add_dataset_to_collection_version_mapping( + collection_version_id, new_dataset_version.version_id + ) + + self.database_provider.update_dataset_upload_status(new_dataset_version.version_id, DatasetUploadStatus.WAITING) + self.database_provider.update_dataset_processing_status( + new_dataset_version.version_id, DatasetProcessingStatus.PENDING + ) + + return (new_dataset_version.version_id, new_dataset_version.dataset_id) + # TODO: Alternatives: 1) return DatasetVersion 2) Return a new class def ingest_dataset( self, diff --git a/backend/layers/persistence/persistence_mock.py b/backend/layers/persistence/persistence_mock.py index bd833647c3787..d56124a0fa0df 100644 --- a/backend/layers/persistence/persistence_mock.py +++ b/backend/layers/persistence/persistence_mock.py @@ -357,7 +357,7 @@ def get_dataset_version_status(self, version_id: DatasetVersionId) -> DatasetSta return copy.deepcopy(self.datasets_versions[version_id.id].status) def get_dataset_mapped_version(self, dataset_id: DatasetId) -> Optional[DatasetVersion]: - cd = self.collections.get(dataset_id.id) + cd = self.datasets.get(dataset_id.id) if cd is not None: version = self.datasets_versions[cd.version_id.id] return self._update_dataset_version_with_canonical(version) diff --git a/backend/portal/api/curation/v1/curation/collections/collection_id/datasets/actions.py b/backend/portal/api/curation/v1/curation/collections/collection_id/datasets/actions.py index fe57759f899fe..395ed2f57bb99 100644 --- a/backend/portal/api/curation/v1/curation/collections/collection_id/datasets/actions.py +++ b/backend/portal/api/curation/v1/curation/collections/collection_id/datasets/actions.py @@ -1,20 +1,24 @@ from flask import g, make_response, jsonify from backend.api_server.db import dbconnect -from backend.common.corpora_orm import CollectionVisibility, ProcessingStatus -from backend.common.entities import Dataset from backend.common.utils.http_exceptions import MethodNotAllowedException -from backend.portal.api.app.v1.authorization import owner_or_allowed -from backend.portal.api.collections_common import get_collection_else_forbidden +from backend.layers.api.router import get_business_logic +from backend.layers.auth.user_info import UserInfo +from backend.portal.api.curation.v1.curation.collections.common import get_infered_collection_version_else_forbidden, is_owner_or_allowed_else_forbidden +from backend.layers.common.entities import CollectionVersionId @dbconnect def post(token_info: dict, collection_id: str): - db_session = g.db_session - collection = get_collection_else_forbidden(db_session, collection_id, owner=owner_or_allowed(token_info)) - if collection.visibility != CollectionVisibility.PRIVATE: + user_info = UserInfo(token_info) + business_logic = get_business_logic() + + collection_version = get_infered_collection_version_else_forbidden(collection_id) + is_owner_or_allowed_else_forbidden(collection_version, user_info) + + if collection_version.published_at is not None: raise MethodNotAllowedException("Collection must be PRIVATE Collection, or a revision of a PUBLIC Collection.") - dataset = Dataset.create( - db_session, collection=collection, processing_status={"processing_status": ProcessingStatus.INITIALIZED} - ) - return make_response(jsonify({"id": dataset.id}), 201) + + dataset_id, dataset_version_id = business_logic.create_empty_dataset(CollectionVersionId(collection_id)) + + return make_response(jsonify({"id": dataset_id.id}), 201) diff --git a/backend/portal/api/curation/v1/curation/collections/collection_id/datasets/dataset_id/actions.py b/backend/portal/api/curation/v1/curation/collections/collection_id/datasets/dataset_id/actions.py index 3b12dd1afcaf2..69941206fe6fd 100644 --- a/backend/portal/api/curation/v1/curation/collections/collection_id/datasets/dataset_id/actions.py +++ b/backend/portal/api/curation/v1/curation/collections/collection_id/datasets/dataset_id/actions.py @@ -1,41 +1,202 @@ -from flask import g, make_response, jsonify - -from backend.api_server.db import dbconnect -from backend.common.corpora_orm import DbCollection +from typing import Tuple +from flask import Response, jsonify, make_response +from backend.common.corpora_orm import IsPrimaryData +from backend.common.utils.exceptions import MaxFileSizeExceededException from backend.common.utils.http_exceptions import ( + ForbiddenHTTPException, + InvalidParametersHTTPException, + MethodNotAllowedException, NotFoundHTTPException, + TooLargeHTTPException, +) +from backend.layers.api.router import get_business_logic +from backend.layers.api.transform import ( + dataset_asset_to_response, + dataset_processing_status_to_response, + ontology_term_ids_to_response, +) +from backend.layers.auth.user_info import UserInfo +from backend.layers.business.business_interface import BusinessLogicInterface +from backend.layers.business.exceptions import ( + CollectionIsPublishedException, + CollectionNotFoundException, + CollectionUpdateException, + DatasetInWrongStatusException, + DatasetNotFoundException, + InvalidURIException, +) + +from backend.layers.common.entities import ( + CollectionId, + CollectionVersionId, + CollectionVersionWithDatasets, + DatasetArtifact, + DatasetArtifactType, + DatasetProcessingStatus, + DatasetValidationStatus, + DatasetVersion, + DatasetVersionId, ) -from backend.portal.api.app.v1.collections.collection_id.upload_links import upload_from_link -from backend.portal.api.collections_common import ( - get_dataset_else_error, - delete_dataset_common, +from backend.portal.api.curation.v1.curation.collections.common import ( + DATASET_ONTOLOGY_ELEMENTS, + DATASET_ONTOLOGY_ELEMENTS_PREVIEW, + get_infered_dataset_version, ) -from backend.portal.api.curation.v1.curation.collections.common import EntityColumns, reshape_dataset_for_curation_api -@dbconnect +is_primary_data_mapping = { + "PRIMARY": [True], + "SECONDARY": [False], + "BOTH": [True, False], +} + + +def _reshape_dataset_for_curation_api(d: DatasetVersion, preview=False) -> dict: + artifacts = [] + for artifact in d.artifacts: + if artifact.type in (DatasetArtifactType.H5AD, DatasetArtifactType.RDS): + artifacts.append(dataset_asset_to_response(artifact, d.dataset_id.id)) + + dataset = { + "assay": ontology_term_ids_to_response(d.metadata.assay), + "batch_condition": d.metadata.batch_condition, + "cell_count": d.metadata.cell_count, + "cell_type": ontology_term_ids_to_response(d.metadata.cell_type), + "dataset_assets": artifacts, + "development_stage": ontology_term_ids_to_response(d.metadata.development_stage), + "disease": ontology_term_ids_to_response(d.metadata.disease), + "donor_id": d.metadata.donor_id, + "explorer_url": "string", + "id": d.dataset_id.id, + "mean_genes_per_cell": d.metadata.mean_genes_per_cell, + "organism": ontology_term_ids_to_response(d.metadata.organism), + "processing_status": dataset_processing_status_to_response(d.status, d.dataset_id.id), + "processing_status_detail": "string", + "revised_at": "string", # TODO + "revision": 0, + "schema_version": d.metadata.schema_version, + "self_reported_ethnicity": ontology_term_ids_to_response(d.metadata.self_reported_ethnicity), + "sex": ontology_term_ids_to_response(d.metadata.sex), + "suspension_type": d.metadata.suspension_type, + "tissue": ontology_term_ids_to_response(d.metadata.tissue), + "x_approximate_distribution": d.metadata.x_approximate_distribution.upper(), + } + + if d.status: + if d.status.processing_status == DatasetProcessingStatus.FAILURE: + if d.status.validation_status == DatasetValidationStatus.INVALID: + dataset["processing_status_detail"] = d.status.validation_message + dataset["processing_status"] = "VALIDATION_FAILURE" + else: + dataset["processing_status"] = "PIPELINE_FAILURE" + else: + dataset["processing_status"] = d.status.processing_status + + dataset_ontology_elements = DATASET_ONTOLOGY_ELEMENTS_PREVIEW if preview else DATASET_ONTOLOGY_ELEMENTS + for ontology_element in dataset_ontology_elements: + if dataset_ontology_element := dataset.get(ontology_element): + if not isinstance(dataset_ontology_element, list): + # Package in array + dataset[ontology_element] = [dataset_ontology_element] + else: + dataset[ontology_element] = [] + + if not preview: # Add these fields only to full (and not preview) Dataset metadata response + dataset["revision_of"] = d.canonical_dataset.dataset_id.id + dataset["title"] = d.metadata.name + if d.metadata.is_primary_data is not None: + dataset["is_primary_data"] = is_primary_data_mapping.get(d.metadata.is_primary_data, []) + + return dataset + + def get(collection_id: str, dataset_id: str = None): - db_session = g.db_session - if not db_session.query(DbCollection.id).filter(DbCollection.id == collection_id).first(): + business_logic = get_business_logic() + + collection_version = business_logic.get_collection_version_from_canonical(CollectionId(collection_id)) + if collection_version is None: raise NotFoundHTTPException("Collection not found!") - dataset = get_dataset_else_error(db_session, dataset_id, collection_id) - response_body = reshape_dataset_for_curation_api(dataset.to_dict_keep(EntityColumns.columns_for_dataset)) + + version = get_infered_dataset_version(dataset_id) + if version is None: + raise NotFoundHTTPException("Dataset not found") + + response_body = _reshape_dataset_for_curation_api(version) return make_response(jsonify(response_body), 200) -@dbconnect +def _get_collection_and_dataset( + business_logic: BusinessLogicInterface, collection_id: CollectionVersionId, dataset_id: DatasetVersionId +) -> Tuple[CollectionVersionWithDatasets, DatasetVersion]: + dataset_version = business_logic.get_dataset_version(dataset_id) + if dataset_version is None: + raise ForbiddenHTTPException() + + collection_version = business_logic.get_collection_version(collection_id) + if collection_version is None: + raise ForbiddenHTTPException() + + return collection_version, dataset_version + + def delete(token_info: dict, collection_id: str, dataset_id: str = None): - db_session = g.db_session - dataset = get_dataset_else_error(db_session, dataset_id, collection_id, include_tombstones=True) - delete_dataset_common(db_session, dataset, token_info) - return "", 202 + + business_logic = get_business_logic() + user_info = UserInfo(token_info) + + collection_version, dataset_version = _get_collection_and_dataset( + business_logic, CollectionVersionId(collection_id), DatasetVersionId(dataset_id) + ) + + if not user_info.is_user_owner_or_allowed(collection_version.owner): + raise ForbiddenHTTPException("Unauthorized") + # End of duplicate block + + # TODO: deduplicate from ApiCommon. We need to settle the class/module level debate before can do that + if dataset_version.version_id not in [v.version_id for v in collection_version.datasets]: + raise ForbiddenHTTPException(f"Dataset {dataset_id} does not belong to a collection") + + try: + business_logic.remove_dataset_version(collection_version.version_id, dataset_version.version_id) + except CollectionUpdateException: + raise MethodNotAllowedException(detail="Cannot delete a public Dataset") + return Response(status=202) + # End of duplicate block def put(collection_id: str, dataset_id: str, body: dict, token_info: dict): - upload_from_link( - collection_id, - token_info, - body.get("url", body.get("link")), - dataset_id, + # TODO: deduplicate from ApiCommon. We need to settle the class/module level debate before can do that + url = body.get("url", body.get("link")) + business_logic = get_business_logic() + + collection_version, _ = _get_collection_and_dataset( + business_logic, CollectionVersionId(collection_id), DatasetVersionId(dataset_id) ) - return "", 202 + + if not UserInfo(token_info).is_user_owner_or_allowed(collection_version.owner): + raise ForbiddenHTTPException() + + try: + business_logic.ingest_dataset( + collection_version.version_id, + url, + None if dataset_id is None else DatasetVersionId(dataset_id), + ) + return Response(status=202) + except CollectionNotFoundException: + raise ForbiddenHTTPException() + except CollectionIsPublishedException: + raise ForbiddenHTTPException() + except DatasetNotFoundException: + raise NotFoundHTTPException() + except InvalidURIException: + raise InvalidParametersHTTPException(detail="The dropbox shared link is invalid.") + except MaxFileSizeExceededException: + raise TooLargeHTTPException() + except DatasetInWrongStatusException: + raise MethodNotAllowedException( + detail="Submission failed. A dataset cannot be updated while a previous update for the same dataset " + "is in progress. Please cancel the current submission by deleting the dataset, or wait until " + "the submission has finished processing." + ) + # End of duplicate block diff --git a/backend/portal/api/curation/v1/curation/collections/common.py b/backend/portal/api/curation/v1/curation/collections/common.py index b662e27c7ede0..22e83273d8880 100644 --- a/backend/portal/api/curation/v1/curation/collections/common.py +++ b/backend/portal/api/curation/v1/curation/collections/common.py @@ -23,6 +23,7 @@ CollectionVersion, CollectionVersionId, CollectionVersionWithDatasets, + DatasetId, DatasetArtifactType, DatasetProcessingStatus, DatasetValidationStatus, @@ -373,6 +374,17 @@ def get_infered_collection_version_else_forbidden(collection_id: str) -> Optiona raise ForbiddenHTTPException() return version +def get_infered_dataset_version(dataset_id: str) -> Optional[DatasetVersion]: + """ + Infer the dataset version from either a DatasetId or a DatasetVersionId and return the DatasetVersion. + :param dataset_id: identifies the dataset version + :return: The DatasetVersion if it exists. + """ + version = get_business_logic().get_dataset_version(DatasetVersionId(dataset_id)) + if version is None: + version = get_business_logic().get_dataset_version_from_canonical(DatasetId(dataset_id)) + return version + def is_owner_or_allowed_else_forbidden(collection_version, user_info): if not user_info.is_user_owner_or_allowed(collection_version.owner): diff --git a/tests/unit/backend/api_server/base_api_test.py b/tests/unit/backend/api_server/base_api_test.py index a688d5749c64b..17deac4fc7ed4 100644 --- a/tests/unit/backend/api_server/base_api_test.py +++ b/tests/unit/backend/api_server/base_api_test.py @@ -10,7 +10,7 @@ from tests.unit.backend.api_server.config import TOKEN_EXPIRES from tests.unit.backend.api_server.mock_auth import MockOauthServer from tests.unit.backend.fixtures.environment_setup import EnvironmentSetup -from unit.backend.layers.common.base_api_test import NewBaseTest +from tests.unit.backend.layers.common.base_api_test import NewBaseTest class BaseAPITest(NewBaseTest): @@ -87,6 +87,14 @@ def get_cxguser_token(user="owner"): class BaseAuthAPITest(BaseAPITest): def setUp(self): super().setUp() + + # TODO: this can be improved, but the current authorization method requires it + self.mock = patch( + "backend.common.corpora_config.CorporaAuthConfig.__getattr__", + return_value="mock_audience" + ) + self.mock.start() + self.mock_assert_authorized_token = patch( "backend.portal.api.app.v1.authentication.assert_authorized_token", side_effect=mock_assert_authorized_token, diff --git a/tests/unit/backend/layers/api/curation/collection/test_curation_upload_link.py b/tests/unit/backend/layers/api/curation/collection/test_curation_upload_link.py new file mode 100644 index 0000000000000..aed72a105dc1d --- /dev/null +++ b/tests/unit/backend/layers/api/curation/collection/test_curation_upload_link.py @@ -0,0 +1,132 @@ +import json +import unittest +from unittest.mock import patch + +from backend.common.corpora_orm import CollectionVisibility, ProcessingStatus +from backend.layers.common.entities import DatasetProcessingStatus, DatasetStatus, DatasetStatusKey +from tests.unit.backend.api_server.base_api_test import BaseAuthAPITest +from tests.unit.backend.layers.common.base_api_test import DatasetStatusUpdate + + +@patch( + "backend.common.utils.dl_sources.url.DropBoxURL.file_info", + return_value={"size": 1, "name": "file.h5ad"}, +) +@patch("backend.common.upload.start_upload_sfn") +class TestPutLink(BaseAuthAPITest): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.good_link = "https://www.dropbox.com/s/ow84zm4h0wkl409/test.h5ad?dl=0" + cls.dummy_link = "https://www.dropbox.com/s/12345678901234/test.h5ad?dl=0" + + def test__from_link__no_auth(self, *mocks): + """ + Calling PUT /datasets/:dataset_id should fail with 401 Unauthorized if the user is not authenticated + """ + dataset = self.generate_dataset(statuses=[DatasetStatusUpdate(DatasetStatusKey.PROCESSING, DatasetProcessingStatus.INITIALIZED)]) + body = {"link": self.good_link} + headers = None + response = self.app.put( + f"/curation/v1/collections/{dataset.collection_version_id}/datasets/{dataset.dataset_version_id}", json=body, headers=headers + ) + + self.assertEqual(401, response.status_code) + + def test__from_link__Not_Public(self, *mocks): + """ + Calling PUT /datasets/:dataset_id should fail with 403 Unauthorized + if trying to upload to a published collection + """ + dataset = self.generate_dataset( + statuses=[DatasetStatusUpdate(DatasetStatusKey.PROCESSING, DatasetProcessingStatus.INITIALIZED)], + publish=True, + ) + body = {"link": self.good_link} + headers = self.make_owner_header() + response = self.app.put( + f"/curation/v1/collections/{dataset.collection_version_id}/datasets/{dataset.dataset_version_id}", json=body, headers=headers + ) + + self.assertEqual(403, response.status_code) + + def test__from_link__Not_Owner(self, *mocks): + """ + Calling PUT /datasets/:dataset_id should fail with 403 Unauthorized + if the authenticated user is not the owner of a collection + """ + + dataset = self.generate_dataset( + statuses=[DatasetStatusUpdate(DatasetStatusKey.PROCESSING, DatasetProcessingStatus.INITIALIZED)], + ) + body = {"link": self.dummy_link} + headers = self.make_not_owner_header() + response = self.app.put( + f"/curation/v1/collections/{dataset.collection_version_id}/datasets/{dataset.dataset_version_id}", json=body, headers=headers + ) + + self.assertEqual(403, response.status_code) + + def test__new_from_link__OK(self, *mocks): + """ + Calling PUT /datasets/:dataset_id should succeed if a valid link is uploaded by the owner of the collection + """ + + dataset = self.generate_dataset( + statuses=[DatasetStatusUpdate(DatasetStatusKey.PROCESSING, DatasetProcessingStatus.INITIALIZED)], + ) + body = {"link": self.good_link} + headers = self.make_owner_header() + response = self.app.put( + f"/curation/v1/collections/{dataset.collection_version_id}/datasets/{dataset.dataset_version_id}", json=body, headers=headers + ) + self.assertEqual(202, response.status_code) + + def test__new_from_link__Super_Curator(self, *mocks): + """ + Calling PUT /datasets/:dataset_id should succeed if a valid link is uploaded by a super curator + """ + + dataset = self.generate_dataset( + statuses=[DatasetStatusUpdate(DatasetStatusKey.PROCESSING, DatasetProcessingStatus.INITIALIZED)], + ) + body = {"link": self.good_link} + headers = self.make_super_curator_header() + response = self.app.put( + f"/curation/v1/collections/{dataset.collection_version_id}/datasets/{dataset.dataset_version_id}", json=body, headers=headers + ) + self.assertEqual(202, response.status_code) + + def test__existing_from_link__OK(self, *mocks): + """ + Calling PUT /datasets/:dataset_id on an existing dataset_id + should succeed if a valid link is uploaded by the owner of the collection + """ + dataset = self.generate_dataset( + statuses=[DatasetStatusUpdate(DatasetStatusKey.PROCESSING, DatasetProcessingStatus.SUCCESS)], + ) + body = {"link": self.good_link} + headers = self.make_owner_header() + response = self.app.put( + f"/curation/v1/collections/{dataset.collection_version_id}/datasets/{dataset.dataset_version_id}", json=body, headers=headers + ) + self.assertEqual(202, response.status_code) + + def test__existing_from_link__Super_Curator(self, *mocks): + """ + Calling PUT /datasets/:dataset_id on an existing dataset_id + should succeed if a valid link is uploaded by a super curator + """ + dataset = self.generate_dataset( + statuses=[DatasetStatusUpdate(DatasetStatusKey.PROCESSING, DatasetProcessingStatus.SUCCESS)], + ) + body = {"link": self.good_link} + headers = self.make_super_curator_header() + response = self.app.put( + f"/curation/v1/collections/{dataset.collection_version_id}/datasets/{dataset.dataset_version_id}", json=body, headers=headers + ) + self.assertEqual(202, response.status_code) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/backend/layers/api/curation/collection/test_dataset.py b/tests/unit/backend/layers/api/curation/collection/test_dataset.py new file mode 100644 index 0000000000000..5fa66f2c2cbe1 --- /dev/null +++ b/tests/unit/backend/layers/api/curation/collection/test_dataset.py @@ -0,0 +1,121 @@ +import unittest + +from backend.common.corpora_orm import CollectionVisibility, IsPrimaryData, UploadStatus +from backend.layers.common.entities import DatasetStatusKey, DatasetUploadStatus +from tests.unit.backend.api_server.base_api_test import BaseAuthAPITest +from tests.unit.backend.layers.common.base_api_test import DatasetStatusUpdate + + +class TestDeleteDataset(BaseAuthAPITest): + def test__delete_dataset(self): + auth_credentials = [ + (self.make_super_curator_header, "super", 202), + (self.make_owner_header, "owner", 202), + (None, "none", 401), + (self.make_not_owner_header, "not_owner", 403), + ] + for auth, auth_description, expected_status_code in auth_credentials: + with self.subTest(f"{auth_description} {expected_status_code}"): + + dataset = self.generate_dataset( + statuses=[DatasetStatusUpdate(DatasetStatusKey.UPLOAD, DatasetUploadStatus.UPLOADING)], + publish=False + ) + + test_url = f"/curation/v1/collections/{dataset.collection_version_id}/datasets/{dataset.dataset_version_id}" + headers = auth() if callable(auth) else auth + response = self.app.delete(test_url, headers=headers) + self.assertEqual(expected_status_code, response.status_code) + + +class TestGetDatasets(BaseAuthAPITest): + def test_get_dataset_in_a_collection_200(self): + dataset = self.generate_dataset(name="test") + test_url = f"/curation/v1/collections/{dataset.collection_id}/datasets/{dataset.dataset_version_id}" + + response = self.app.get(test_url) + print(response) + self.assertEqual(200, response.status_code) + self.assertEqual(dataset.dataset_id, response.json["id"]) + + def test_get_dataset_shape(self): + dataset = self.generate_dataset(name="test") + test_url = f"/curation/v1/collections/{dataset.collection_id}/datasets/{dataset.dataset_version_id}" + response = self.app.get(test_url) + print(response.json) + self.assertEqual("test", response.json["title"]) + + def test_get_dataset_is_primary_data_shape(self): + tests = [ + ("PRIMARY", [True]), + ("SECONDARY", [False]), + ("BOTH", [True, False]), + ] + for is_primary_data, result in tests: + with self.subTest(f"{is_primary_data}=={result}"): + metadata = self.sample_dataset_metadata + metadata.is_primary_data=is_primary_data + dataset = self.generate_dataset(metadata=metadata) + test_url = f"/curation/v1/collections/{dataset.collection_id}/datasets/{dataset.dataset_version_id}" + response = self.app.get(test_url) + self.assertEqual(result, response.json["is_primary_data"]) + + def test_get_nonexistent_dataset_404(self): + collection = self.generate_unpublished_collection() + test_url = f"/curation/v1/collections/{collection.collection_id}/datasets/1234-1243-2134-234-1342" + response = self.app.get(test_url) + self.assertEqual(404, response.status_code) + + def test_get_datasets_nonexistent_collection_404(self): + test_url = "/curation/v1/collections/nonexistent/datasets/1234-1243-2134-234-1342" + headers = self.make_owner_header() + response = self.app.get(test_url, headers=headers) + self.assertEqual(404, response.status_code) + + +class TestPostDataset(BaseAuthAPITest): + + def test_post_datasets_nonexistent_collection_403(self): + test_url = "/curation/v1/collections/nonexistent/datasets" + headers = self.make_owner_header() + response = self.app.post(test_url, headers=headers) + self.assertEqual(403, response.status_code) + + def test_post_datasets_201(self): + collection = self.generate_unpublished_collection() + test_url = f"/curation/v1/collections/{collection.version_id}/datasets" + headers = self.make_owner_header() + response = self.app.post(test_url, headers=headers) + self.assertEqual(201, response.status_code) + self.assertTrue(response.json["id"]) + + def test_post_datasets_super(self): + collection = self.generate_unpublished_collection() + test_url = f"/curation/v1/collections/{collection.version_id}/datasets" + headers = self.make_super_curator_header() + response = self.app.post(test_url, headers=headers) + self.assertEqual(201, response.status_code) + + def test_post_datasets_not_owner_201(self): + collection = self.generate_collection() + test_url = f"/curation/v1/collections/{collection.version_id}/datasets" + headers = self.make_not_owner_header() + response = self.app.post(test_url, headers=headers) + self.assertEqual(403, response.status_code) + + def test_post_datasets_public_collection_405(self): + collection = self.generate_collection(visibility="PUBLIC") + test_url = f"/curation/v1/collections/{collection.version_id}/datasets" + headers = self.make_owner_header() + response = self.app.post(test_url, headers=headers) + self.assertEqual(405, response.status_code) + + def test_post_datasets_no_auth_401(self): + collection = self.generate_collection(visibility="PUBLIC") + test_url = f"/curation/v1/collections/{collection.version_id}/datasets" + response = self.app.post(test_url) + self.assertEqual(401, response.status_code) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/backend/layers/business/test_business.py b/tests/unit/backend/layers/business/test_business.py index 10bb888cd301b..749d78acbf7c6 100644 --- a/tests/unit/backend/layers/business/test_business.py +++ b/tests/unit/backend/layers/business/test_business.py @@ -503,6 +503,24 @@ def test_update_collection_change_doi(self): class TestUpdateCollectionDatasets(BaseBusinessLogicTestCase): + + def test_add_empty_dataset_ok(self): + """ + An empty dataset can be added to a collection when `create_empty_dataset` is called. + The resulting dataset should be empty and in a state ready for processing. + """ + version = self.initialize_empty_unpublished_collection() + url = "http://test/dataset.url" + + new_dataset_version_id, _ = self.business_logic.create_empty_dataset(version.version_id) + + new_dataset_version = self.database_provider.get_dataset_version(new_dataset_version_id) + self.assertIsNotNone(new_dataset_version) + self.assertIsNone(new_dataset_version.metadata) + self.assertEqual(new_dataset_version.collection_id, version.collection_id) + self.assertEqual(new_dataset_version.status.upload_status, DatasetUploadStatus.WAITING) + self.assertEqual(new_dataset_version.status.processing_status, DatasetProcessingStatus.PENDING) + def test_add_dataset_to_unpublished_collection_ok(self): """ A dataset can be added to a collection when `ingest_dataset` is called. diff --git a/tests/unit/backend/layers/common/base_api_test.py b/tests/unit/backend/layers/common/base_api_test.py index c0c411bce5533..14140caa62431 100644 --- a/tests/unit/backend/layers/common/base_api_test.py +++ b/tests/unit/backend/layers/common/base_api_test.py @@ -249,6 +249,7 @@ def generate_dataset( owner: str = "test_user_id", collection_version: Optional[CollectionVersion] = None, metadata: Optional[DatasetMetadata] = None, + name: Optional[str] = None, statuses: List[DatasetStatusUpdate] = [], validation_message: str = None, artifacts: List[DatasetArtifactUpdate] = [], @@ -264,6 +265,8 @@ def generate_dataset( ) if not metadata: metadata = copy.deepcopy(self.sample_dataset_metadata) + if name is not None: + metadata.name = name self.business_logic.set_dataset_metadata(dataset_version_id, metadata) for status in statuses: self.business_logic.update_dataset_version_status(dataset_version_id, status.status_key, status.status)