diff --git a/src/mavedb/lib/validation/constants/general.py b/src/mavedb/lib/validation/constants/general.py index 92b4fd5b..d994dfcc 100644 --- a/src/mavedb/lib/validation/constants/general.py +++ b/src/mavedb/lib/validation/constants/general.py @@ -44,6 +44,7 @@ variant_count_data = "count_data" required_score_column = "score" +multi_value_keys = ["molecular mechanism assessed"] valid_dataset_columns = [score_columns, count_columns] valid_variant_columns = [variant_score_data, variant_count_data] diff --git a/src/mavedb/lib/validation/keywords.py b/src/mavedb/lib/validation/keywords.py index 305a7c5a..7d90ac04 100644 --- a/src/mavedb/lib/validation/keywords.py +++ b/src/mavedb/lib/validation/keywords.py @@ -1,5 +1,6 @@ from typing import Optional +from mavedb.lib.validation.constants.general import multi_value_keys from mavedb.lib.validation.exceptions import ValidationError from mavedb.lib.validation.utilities import is_null @@ -7,7 +8,7 @@ def validate_code(key: str, label: str, code: Optional[str]): # TODO(#511) Re-enable the Gene Ontology code requirement. pass - # if key.lower() == "phenotypic assay mechanism" and label.lower() != "other": + # if key.lower() == "molecular mechanism assessed" and label.lower() != "other": # # The Gene Ontology accession is a unique seven digit identifier prefixed by GO:. # # e.g. GO:0005739, GO:1904659, or GO:0016597. # if code is None or not re.match(r"^GO:\d{7}$", code): @@ -26,9 +27,12 @@ def validate_duplicates(keywords: list): keys = [] labels = [] for k in keywords: - keys.append(k.keyword.key.lower()) # k: ExperimentControlledKeywordCreate object - if k.keyword.label.lower() != "other": - labels.append(k.keyword.label.lower()) + key = k.keyword.key.lower() + label = k.keyword.label.lower() + if key not in multi_value_keys: + keys.append(key) + if label != "other": + labels.append(label) keys_set = set(keys) labels_set = set(labels) diff --git a/src/mavedb/routers/experiments.py b/src/mavedb/routers/experiments.py index 2064196b..165058ab 100644 --- a/src/mavedb/routers/experiments.py +++ b/src/mavedb/routers/experiments.py @@ -459,6 +459,7 @@ async def update_experiment( item.raw_read_identifiers = raw_read_identifiers if item_update.keywords: + keywords: list[ExperimentControlledKeywordAssociation] = [] all_labels_none = all(k.keyword.label is None for k in item_update.keywords) if all_labels_none is False: # Users may choose part of keywords from dropdown menu. Remove not chosen keywords from the list. @@ -467,10 +468,18 @@ async def update_experiment( validate_keyword_list(filtered_keywords) except ValidationError as e: raise HTTPException(status_code=422, detail=str(e)) - try: - await item.set_keywords(db, filtered_keywords) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Invalid keywords: {str(e)}") + for upload_keyword in filtered_keywords: + try: + description = upload_keyword.description + controlled_keyword = search_keyword(db, upload_keyword.keyword.key, upload_keyword.keyword.label) + experiment_controlled_keyword = ExperimentControlledKeywordAssociation( + controlled_keyword=controlled_keyword, + description=description, + ) + keywords.append(experiment_controlled_keyword) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + item.keyword_objs = keywords item.modified_by = user_data.user diff --git a/tests/helpers/constants.py b/tests/helpers/constants.py index 1a219f17..02a013e8 100644 --- a/tests/helpers/constants.py +++ b/tests/helpers/constants.py @@ -427,21 +427,35 @@ "special": False, "description": "Description", }, - {"key": "Delivery method", "label": "Other", "special": False, "description": "Description"}, + {"key": "Delivery Method", "label": "Other", "special": False, "description": "Description"}, { - "key": "Phenotypic Assay Mechanism", + "key": "Molecular Mechanism Assessed", "label": "Other", "code": None, "special": False, "description": "Description", }, { - "key": "Phenotypic Assay Mechanism", - "label": "Label", + "key": "Molecular Mechanism Assessed", + "label": "Sodium channel activity", "code": "GO:1234567", "special": False, "description": "Description", }, + { + "key": "Molecular Mechanism Assessed", + "label": "Calcium-mediated signaling", + "code": "GO:1134567", + "special": False, + "description": "Description", + }, + { + "key": "Phenotypic Assay Profiling Strategy", + "label": "Shotgun sequencing", + "code": None, + "special": False, + "description": "Description", + }, ] TEST_KEYWORDS = [ @@ -470,7 +484,7 @@ }, }, { - "keyword": {"key": "Delivery method", "label": "Other", "special": False, "description": "Description"}, + "keyword": {"key": "Delivery Method", "label": "Other", "special": False, "description": "Description"}, "description": "Details of delivery method", }, ] @@ -492,7 +506,7 @@ "methodText": "Methods", "keywords": [ { - "keyword": {"key": "Delivery method", "label": "Other", "special": False, "description": "Description"}, + "keyword": {"key": "Delivery Method", "label": "Other", "special": False, "description": "Description"}, "description": "Details of delivery method", }, ], @@ -572,7 +586,7 @@ "keywords": [ { "recordType": "ExperimentControlledKeyword", - "keyword": {"key": "Delivery method", "label": "Other", "special": False, "description": "Description"}, + "keyword": {"key": "Delivery Method", "label": "Other", "special": False, "description": "Description"}, "description": "Details of delivery method", }, ], @@ -587,6 +601,51 @@ "numScoreSets": 0, # NOTE: This is context-dependent and may need overriding per test } +TEST_EXPERIMENT_WITH_UPDATE_KEYWORD_RESPONSE = { + "recordType": "Experiment", + "title": "Test Experiment Title", + "shortDescription": "Test experiment", + "abstractText": "Abstract", + "methodText": "Methods", + "createdBy": { + "recordType": "User", + "firstName": TEST_USER["first_name"], + "lastName": TEST_USER["last_name"], + "orcidId": TEST_USER["username"], + }, + "modifiedBy": { + "recordType": "User", + "firstName": TEST_USER["first_name"], + "lastName": TEST_USER["last_name"], + "orcidId": TEST_USER["username"], + }, + "creationDate": date.today().isoformat(), + "modificationDate": date.today().isoformat(), + "scoreSetUrns": [], + "contributors": [], + "keywords": [ + { + "recordType": "ExperimentControlledKeyword", + "keyword": { + "key": "Phenotypic Assay Profiling Strategy", + "label": "Shotgun sequencing", + "special": False, + "description": "Description" + }, + "description": "Details of phenotypic assay profiling strategy", + }, + ], + "doiIdentifiers": [], + "primaryPublicationIdentifiers": [], + "secondaryPublicationIdentifiers": [], + "rawReadIdentifiers": [], + # keys to be set after receiving response + "urn": None, + "experimentSetUrn": None, + "officialCollections": [], + "numScoreSets": 0, # NOTE: This is context-dependent and may need overriding per test +} + TEST_EXPERIMENT_WITH_KEYWORD_HAS_DUPLICATE_OTHERS_RESPONSE = { "recordType": "Experiment", "title": "Test Experiment Title", @@ -622,7 +681,7 @@ }, { "recordType": "ExperimentControlledKeyword", - "keyword": {"key": "Delivery method", "label": "Other", "special": False, "description": "Description"}, + "keyword": {"key": "Delivery Method", "label": "Other", "special": False, "description": "Description"}, "description": "Description", }, ], diff --git a/tests/routers/test_experiments.py b/tests/routers/test_experiments.py index cd4a54ad..d2bddc58 100644 --- a/tests/routers/test_experiments.py +++ b/tests/routers/test_experiments.py @@ -28,6 +28,7 @@ TEST_EXPERIMENT_WITH_KEYWORD, TEST_EXPERIMENT_WITH_KEYWORD_HAS_DUPLICATE_OTHERS_RESPONSE, TEST_EXPERIMENT_WITH_KEYWORD_RESPONSE, + TEST_EXPERIMENT_WITH_UPDATE_KEYWORD_RESPONSE, TEST_MEDRXIV_IDENTIFIER, TEST_MINIMAL_EXPERIMENT, TEST_MINIMAL_EXPERIMENT_RESPONSE, @@ -292,13 +293,243 @@ def test_cannot_create_experiment_that_keywords_has_wrong_combination4(client, s ) +# Test the validator of Endogenous locus keywords +def test_create_experiment_that_keywords_has_endogenous(client, setup_router_db): + """ + Test src/mavedb/lib/validation/keywords.validate_keyword_keys function + if users choose endogenous locus library method in Variant Library Creation Method + """ + keywords = { + "keywords": [ + { + "keyword": { + "key": "Variant Library Creation Method", + "label": "Endogenous locus library method", + "special": False, + "description": "Description", + }, + }, + { + "keyword": { + "key": "Endogenous Locus Library Method System", + "label": "SaCas9", + "special": False, + "description": "Description", + }, + }, + { + "keyword": { + "key": "Endogenous Locus Library Method Mechanism", + "label": "Base editor", + "special": False, + "description": "Description", + }, + }, + ] + } + experiment = {**TEST_MINIMAL_EXPERIMENT, **keywords} + response = client.post("/api/v1/experiments/", json=experiment) + assert response.status_code == 200 + + +def test_cannot_create_experiment_that_keywords_has_endogenous_without_method_mechanism(client, setup_router_db): + """ + Test src/mavedb/lib/validation/keywords.validate_keyword_keys function + Choose endogenous locus library method in Variant Library Creation Method, + but miss the endogenous locus library method mechanism + """ + incomplete_keywords = { + "keywords": [ + { + "keyword": { + "key": "Variant Library Creation Method", + "label": "Endogenous locus library method", + "special": False, + "description": "Description", + }, + }, + { + "keyword": { + "key": "Endogenous Locus Library Method System", + "label": "SaCas9", + "special": False, + "description": "Description", + }, + }, + ] + } + experiment = {**TEST_MINIMAL_EXPERIMENT, **incomplete_keywords} + response = client.post("/api/v1/experiments/", json=experiment) + assert response.status_code == 422 + response_data = response.json() + assert ( + response_data["detail"] + == "If 'Variant Library Creation Method' is 'Endogenous locus library method', " + "both 'Endogenous Locus Library Method System' and 'Endogenous Locus Library Method Mechanism' " + "must be present." + ) + + +def test_cannot_create_experiment_that_keywords_has_endogenous_without_method_system(client, setup_router_db): + """ + Test src/mavedb/lib/validation/keywords.validate_keyword_keys function + Choose endogenous locus library method in Variant Library Creation Method, + but miss the endogenous locus library method system + """ + incomplete_keywords = { + "keywords": [ + { + "keyword": { + "key": "Variant Library Creation Method", + "label": "Endogenous locus library method", + "special": False, + "description": "Description", + }, + }, + { + "keyword": { + "key": "Endogenous Locus Library Method Mechanism", + "label": "Base editor", + "special": False, + "description": "Description", + }, + }, + ] + } + experiment = {**TEST_MINIMAL_EXPERIMENT, **incomplete_keywords} + response = client.post("/api/v1/experiments/", json=experiment) + assert response.status_code == 422 + response_data = response.json() + assert ( + response_data["detail"] + == "If 'Variant Library Creation Method' is 'Endogenous locus library method', " + "both 'Endogenous Locus Library Method System' and 'Endogenous Locus Library Method Mechanism' " + "must be present." + ) + + +# Test the validator of in vitro keywords +def test_create_experiment_that_keywords_has_in_vitro(client, setup_router_db): + """ + Test src/mavedb/lib/validation/keywords.validate_keyword_keys function + if users choose in vitro construct library method in Variant Library Creation Method + """ + keywords = { + "keywords": [ + { + "keyword": { + "key": "Variant Library Creation Method", + "label": "In vitro construct library method", + "special": False, + "description": "Description", + }, + }, + { + "keyword": { + "key": "In Vitro Construct Library Method System", + "label": "Oligo-directed mutagenic PCR", + "special": False, + "description": "Description", + }, + }, + { + "keyword": { + "key": "In Vitro Construct Library Method Mechanism", + "label": "Native locus replacement", + "special": False, + "description": "Description", + }, + }, + ] + } + experiment = {**TEST_MINIMAL_EXPERIMENT, **keywords} + response = client.post("/api/v1/experiments/", json=experiment) + assert response.status_code == 200 + + +def test_cannot_create_experiment_that_keywords_has_in_vitro_without_method_system(client, setup_router_db): + """ + Test src/mavedb/lib/validation/keywords.validate_keyword_keys function + Choose in vitro construct library method in Variant Library Creation Method, + but miss the in vitro construct library method system + """ + incomplete_keywords = { + "keywords": [ + { + "keyword": { + "key": "Variant Library Creation Method", + "label": "In vitro construct library method", + "special": False, + "description": "Description", + }, + }, + { + "keyword": { + "key": "In Vitro Construct Library Method Mechanism", + "label": "Native locus replacement", + "special": False, + "description": "Description", + }, + }, + ] + } + experiment = {**TEST_MINIMAL_EXPERIMENT, **incomplete_keywords} + response = client.post("/api/v1/experiments/", json=experiment) + assert response.status_code == 422 + response_data = response.json() + assert ( + response_data["detail"] + == "If 'Variant Library Creation Method' is 'In vitro construct library method', " + "both 'In Vitro Construct Library Method System' and 'In Vitro Construct Library Method Mechanism' " + "must be present." + ) + + +def test_cannot_create_experiment_that_keywords_has_in_vitro_without_method_mechanism(client, setup_router_db): + """ + Test src/mavedb/lib/validation/keywords.validate_keyword_keys function + Choose in vitro construct library method in Variant Library Creation Method, + but miss the in vitro construct library method mechanism + """ + incomplete_keywords = { + "keywords": [ + { + "keyword": { + "key": "Variant Library Creation Method", + "label": "In vitro construct library method", + "special": False, + "description": "Description", + }, + }, + { + "keyword": { + "key": "In Vitro Construct Library Method System", + "label": "Oligo-directed mutagenic PCR", + "special": False, + "description": "Description", + }, + }, + ] + } + experiment = {**TEST_MINIMAL_EXPERIMENT, **incomplete_keywords} + response = client.post("/api/v1/experiments/", json=experiment) + assert response.status_code == 422 + response_data = response.json() + assert ( + response_data["detail"] + == "If 'Variant Library Creation Method' is 'In vitro construct library method', " + "both 'In Vitro Construct Library Method System' and 'In Vitro Construct Library Method Mechanism' " + "must be present." + ) + + def test_create_experiment_that_keyword_gene_ontology_has_valid_code(client, setup_router_db): valid_keyword = { "keywords": [ { "keyword": { - "key": "Phenotypic Assay Mechanism", - "label": "Label", + "key": "Molecular Mechanism Assessed", + "label": "Sodium channel activity", "code": "GO:1234567", "special": False, "description": "Description", @@ -310,8 +541,8 @@ def test_create_experiment_that_keyword_gene_ontology_has_valid_code(client, set response = client.post("/api/v1/experiments/", json=experiment) assert response.status_code == 200 response_data = response.json() - assert response_data["keywords"][0]["keyword"]["key"] == "Phenotypic Assay Mechanism" - assert response_data["keywords"][0]["keyword"]["label"] == "Label" + assert response_data["keywords"][0]["keyword"]["key"] == "Molecular Mechanism Assessed" + assert response_data["keywords"][0]["keyword"]["label"] == "Sodium channel activity" assert response_data["keywords"][0]["keyword"]["code"] == "GO:1234567" @@ -320,7 +551,7 @@ def test_create_experiment_that_keyword_gene_ontology_is_other_without_code(clie "keywords": [ { "keyword": { - "key": "Phenotypic Assay Mechanism", + "key": "Molecular Mechanism Assessed", "label": "Other", "code": None, "description": "Description", @@ -333,17 +564,60 @@ def test_create_experiment_that_keyword_gene_ontology_is_other_without_code(clie response = client.post("/api/v1/experiments/", json=experiment) assert response.status_code == 200 response_data = response.json() - assert response_data["keywords"][0]["keyword"]["key"] == "Phenotypic Assay Mechanism" + assert response_data["keywords"][0]["keyword"]["key"] == "Molecular Mechanism Assessed" assert response_data["keywords"][0]["keyword"]["label"] == "Other" +def test_create_experiment_that_keywords_has_multiple_molecular_mechanism_assessed_labels(client, setup_router_db): + valid_keywords = { + "keywords": [ + { + "keyword": { + "key": "Molecular Mechanism Assessed", + "label": "Sodium channel activity", + "code": "GO:1234567", + "special": False, + "description": "Description", + }, + }, + { + "keyword": { + "key": "Molecular Mechanism Assessed", + "label": "Calcium-mediated signaling", + "code": "GO:1134567", + "special": False, + "description": "Description", + }, + } + ], + } + experiment = {**TEST_MINIMAL_EXPERIMENT, **valid_keywords} + response = client.post("/api/v1/experiments/", json=experiment) + assert response.status_code == 200 + response_data = response.json() + assert len(response_data["keywords"]) == 2 + labels = {kw["keyword"]["label"] for kw in response_data["keywords"]} + codes = {kw["keyword"]["code"] for kw in response_data["keywords"]} + keys = {kw["keyword"]["key"] for kw in response_data["keywords"]} + + assert keys == {"Molecular Mechanism Assessed"} + assert labels == { + "Sodium channel activity", + "Calcium-mediated signaling", + } + assert codes == { + "GO:1234567", + "GO:1134567", + } + + # TODO(#511) Re-enable the Gene Ontology code requirement. # def test_cannot_create_experiment_that_keyword_has_an_invalid_code(client, setup_router_db): # invalid_keyword = { # "keywords": [ # { # "keyword": { -# "key": "Phenotypic Assay Mechanism", +# "key": "Molecular Mechanism Assessed", # "label": "Label", # "code": "invalid", # "description": "Description", @@ -422,7 +696,7 @@ def test_cannot_create_experiment_that_keywords_have_duplicate_labels(client, se "keywords": [ { "keyword": { - "key": "Delivery method", + "key": "Delivery Method", "label": "In vitro construct library method", "special": False, "description": "Description", @@ -462,7 +736,7 @@ def test_create_experiment_that_keywords_have_duplicate_others(client, setup_rou "description": "Description", }, { - "keyword": {"key": "Delivery method", "label": "Other", "special": False, "description": "Description"}, + "keyword": {"key": "Delivery Method", "label": "Other", "special": False, "description": "Description"}, "description": "Description", }, ] @@ -481,6 +755,54 @@ def test_create_experiment_that_keywords_have_duplicate_others(client, setup_rou assert (key, expected_response[key]) == (key, response_data[key]) +def test_update_experiment_keywords(session, client, setup_router_db): + response = client.post("/api/v1/experiments/", json=TEST_EXPERIMENT_WITH_KEYWORD) + assert response.status_code == 200 + experiment = response.json() + experiment_post_payload = experiment.copy() + experiment_post_payload.update({"keywords": [ + { + "keyword": { + "key": "Phenotypic Assay Profiling Strategy", + "label": "Shotgun sequencing", + "special": False, + "description": "Description" + }, + "description": "Details of phenotypic assay profiling strategy", + }, + + ]}) + updated_response = client.put(f"/api/v1/experiments/{experiment['urn']}", json=experiment_post_payload) + assert updated_response.status_code == 200 + updated_experiment = updated_response.json() + updated_expected_response = deepcopy(TEST_EXPERIMENT_WITH_UPDATE_KEYWORD_RESPONSE) + updated_expected_response.update({"urn": updated_experiment["urn"], "experimentSetUrn": updated_experiment["experimentSetUrn"]}) + assert sorted(updated_expected_response.keys()) == sorted(updated_experiment.keys()) + for key in updated_experiment: + assert (key, updated_expected_response[key]) == (key, updated_experiment[key]) + for kw in updated_experiment["keywords"]: + assert "Delivery Method" not in kw["keyword"]["key"] + + +def test_update_experiment_keywords_case_insensitive(session, client, setup_router_db): + experiment = create_experiment(client) + experiment_post_payload = experiment.copy() + # Test database has Delivery Method. The updating keyword's key is delivery method. + experiment_post_payload.update({"keywords": [ + { + "keyword": {"key": "delivery method", "label": "Other", "special": False, "description": "Description"}, + "description": "Details of delivery method", + }, + ]}) + response = client.put(f"/api/v1/experiments/{experiment['urn']}", json=experiment_post_payload) + response_data = response.json() + expected_response = deepcopy(TEST_EXPERIMENT_WITH_KEYWORD_RESPONSE) + expected_response.update({"urn": response_data["urn"], "experimentSetUrn": response_data["experimentSetUrn"]}) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + def test_can_delete_experiment(client, setup_router_db): experiment = create_experiment(client) response = client.delete(f"api/v1/experiments/{experiment['urn']}")