diff --git a/alembic/versions/00dab0f5f498_add_external_links_property_to_.py b/alembic/versions/00dab0f5f498_add_external_links_property_to_.py new file mode 100644 index 00000000..01c25ad4 --- /dev/null +++ b/alembic/versions/00dab0f5f498_add_external_links_property_to_.py @@ -0,0 +1,33 @@ +"""add external links property to experiments + +Revision ID: 00dab0f5f498 +Revises: b22b450d409c +Create Date: 2025-12-16 12:06:15.265947 + +""" + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "00dab0f5f498" +down_revision = "b22b450d409c" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "experiments", + sa.Column("external_links", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("experiments", "external_links") + # ### end Alembic commands ### diff --git a/src/mavedb/models/experiment.py b/src/mavedb/models/experiment.py index 846ab00a..22014a59 100644 --- a/src/mavedb/models/experiment.py +++ b/src/mavedb/models/experiment.py @@ -73,6 +73,7 @@ class Experiment(Base): abstract_text = Column(String, nullable=False) method_text = Column(String, nullable=False) extra_metadata = Column(JSONB, nullable=False) + external_links = Column(JSONB, nullable=False, default={}) private = Column(Boolean, nullable=False, default=True) approved = Column(Boolean, nullable=False, default=False) 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/src/mavedb/view_models/collection.py b/src/mavedb/view_models/collection.py index 9761686d..e83de288 100644 --- a/src/mavedb/view_models/collection.py +++ b/src/mavedb/view_models/collection.py @@ -1,5 +1,5 @@ from datetime import date -from typing import Any, Sequence, Optional +from typing import Any, Optional, Sequence from pydantic import Field, model_validator @@ -132,3 +132,14 @@ class Collection(SavedCollection): # NOTE: Coupled to ContributionRole enum class AdminCollection(Collection): pass + + +# Properties to return for official collections +class OfficialCollection(BaseModel): + badge_name: str + name: str + urn: str + + class Config: + arbitrary_types_allowed = True + from_attributes = True diff --git a/src/mavedb/view_models/components/external_link.py b/src/mavedb/view_models/components/external_link.py new file mode 100644 index 00000000..43c5d28e --- /dev/null +++ b/src/mavedb/view_models/components/external_link.py @@ -0,0 +1,15 @@ +from typing import Optional + +from mavedb.view_models.base.base import BaseModel + + +class ExternalLink(BaseModel): + """ + Represents an external hyperlink for view models. + + Attributes: + url (Optional[str]): Fully qualified URL for the external resource. + May be None if no link is available or applicable. + """ + + url: Optional[str] = None diff --git a/src/mavedb/view_models/experiment.py b/src/mavedb/view_models/experiment.py index b05766ff..c75e3bde 100644 --- a/src/mavedb/view_models/experiment.py +++ b/src/mavedb/view_models/experiment.py @@ -1,18 +1,20 @@ from datetime import date from typing import Any, Collection, Optional, Sequence -from pydantic import field_validator, model_validator, ValidationInfo +from pydantic import ValidationInfo, field_validator, model_validator +from mavedb.lib.validation import urn_re from mavedb.lib.validation.exceptions import ValidationError from mavedb.lib.validation.transform import ( transform_experiment_set_to_urn, - transform_score_set_list_to_urn_list, transform_record_publication_identifiers, + transform_score_set_list_to_urn_list, ) -from mavedb.lib.validation import urn_re from mavedb.lib.validation.utilities import is_null from mavedb.view_models import record_type_validator, set_record_type from mavedb.view_models.base.base import BaseModel +from mavedb.view_models.collection import OfficialCollection +from mavedb.view_models.components.external_link import ExternalLink from mavedb.view_models.contributor import Contributor, ContributorCreate from mavedb.view_models.doi_identifier import ( DoiIdentifier, @@ -37,16 +39,6 @@ from mavedb.view_models.user import SavedUser, User -class OfficialCollection(BaseModel): - badge_name: str - name: str - urn: str - - class Config: - arbitrary_types_allowed = True - from_attributes = True - - class ExperimentBase(BaseModel): title: str short_description: str @@ -115,6 +107,7 @@ class SavedExperiment(ExperimentBase): contributors: list[Contributor] keywords: Sequence[SavedExperimentControlledKeyword] score_set_urns: list[str] + external_links: dict[str, ExternalLink] _record_type_factory = record_type_validator()(set_record_type) diff --git a/src/mavedb/view_models/score_set.py b/src/mavedb/view_models/score_set.py index 9f53cf64..9766f6df 100644 --- a/src/mavedb/view_models/score_set.py +++ b/src/mavedb/view_models/score_set.py @@ -19,6 +19,8 @@ from mavedb.models.enums.processing_state import ProcessingState from mavedb.view_models import record_type_validator, set_record_type from mavedb.view_models.base.base import BaseModel +from mavedb.view_models.collection import OfficialCollection +from mavedb.view_models.components.external_link import ExternalLink from mavedb.view_models.contributor import Contributor, ContributorCreate from mavedb.view_models.doi_identifier import ( DoiIdentifier, @@ -49,20 +51,6 @@ UnboundedRange = tuple[Union[float, None], Union[float, None]] -class ExternalLink(BaseModel): - url: Optional[str] = None - - -class OfficialCollection(BaseModel): - badge_name: str - name: str - urn: str - - class Config: - arbitrary_types_allowed = True - from_attributes = True - - class ScoreSetBase(BaseModel): """Base class for score set view models.""" @@ -109,7 +97,7 @@ def targets_need_labels_when_multiple_targets_exist(self) -> Self: "Target sequence labels cannot be empty when multiple targets are defined.", custom_loc=[ "body", - "targetGene", + "targetGenes", idx, "targetSequence", "label", @@ -134,7 +122,7 @@ def target_labels_are_unique(self) -> Self: "Target sequence labels cannot be duplicated.", custom_loc=[ "body", - "targetGene", + "targetGenes", dup_indices[-1], "targetSequence", "label", @@ -161,7 +149,7 @@ def target_accession_base_editor_targets_are_consistent(cls, field_value, values "All target accessions must be of the same base editor type.", custom_loc=[ "body", - "targetGene", + "targetGenes", 0, "targetAccession", "isBaseEditor", diff --git a/tests/helpers/constants.py b/tests/helpers/constants.py index 1a219f17..415a37bf 100644 --- a/tests/helpers/constants.py +++ b/tests/helpers/constants.py @@ -427,7 +427,7 @@ "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", "label": "Other", @@ -442,6 +442,13 @@ "special": False, "description": "Description", }, + { + "key": "Phenotypic Assay Profiling Strategy", + "label": "Shotgun sequencing", + "code": None, + "special": False, + "description": "Description", + }, ] TEST_KEYWORDS = [ @@ -470,7 +477,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 +499,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", }, ], @@ -540,6 +547,7 @@ "primaryPublicationIdentifiers": [], "secondaryPublicationIdentifiers": [], "rawReadIdentifiers": [], + "externalLinks": {}, # keys to be set after receiving response "urn": None, "experimentSetUrn": None, @@ -572,7 +580,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", }, ], @@ -580,6 +588,52 @@ "primaryPublicationIdentifiers": [], "secondaryPublicationIdentifiers": [], "rawReadIdentifiers": [], + "externalLinks": {}, + # 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_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, @@ -622,7 +676,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", }, ], @@ -630,6 +684,7 @@ "primaryPublicationIdentifiers": [], "secondaryPublicationIdentifiers": [], "rawReadIdentifiers": [], + "externalLinks": {}, # keys to be set after receiving response "urn": None, "experimentSetUrn": None, diff --git a/tests/routers/test_experiments.py b/tests/routers/test_experiments.py index cd4a54ad..1a04ed6a 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,6 +293,236 @@ 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": [ @@ -422,7 +653,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 +693,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 +712,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']}")