From 3859cac0f41c8334ccc1a106d4e593dd08e4e3c8 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Tue, 16 Dec 2025 12:07:13 -0800 Subject: [PATCH 1/5] feat: add external links property to Experiment model and view model --- ...0f5f498_add_external_links_property_to_.py | 33 +++++++++++++++++++ src/mavedb/models/experiment.py | 1 + src/mavedb/view_models/experiment.py | 11 +++++-- tests/helpers/constants.py | 3 ++ 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/00dab0f5f498_add_external_links_property_to_.py 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/view_models/experiment.py b/src/mavedb/view_models/experiment.py index b05766ff..c4cf20ba 100644 --- a/src/mavedb/view_models/experiment.py +++ b/src/mavedb/view_models/experiment.py @@ -1,15 +1,15 @@ 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 @@ -37,6 +37,10 @@ from mavedb.view_models.user import SavedUser, User +class ExternalLink(BaseModel): + url: Optional[str] = None + + class OfficialCollection(BaseModel): badge_name: str name: str @@ -115,6 +119,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/tests/helpers/constants.py b/tests/helpers/constants.py index 1a219f17..2bb66ce8 100644 --- a/tests/helpers/constants.py +++ b/tests/helpers/constants.py @@ -540,6 +540,7 @@ "primaryPublicationIdentifiers": [], "secondaryPublicationIdentifiers": [], "rawReadIdentifiers": [], + "externalLinks": {}, # keys to be set after receiving response "urn": None, "experimentSetUrn": None, @@ -580,6 +581,7 @@ "primaryPublicationIdentifiers": [], "secondaryPublicationIdentifiers": [], "rawReadIdentifiers": [], + "externalLinks": {}, # keys to be set after receiving response "urn": None, "experimentSetUrn": None, @@ -630,6 +632,7 @@ "primaryPublicationIdentifiers": [], "secondaryPublicationIdentifiers": [], "rawReadIdentifiers": [], + "externalLinks": {}, # keys to be set after receiving response "urn": None, "experimentSetUrn": None, From a1d0838785095b78b622f184724294307e7d3dc9 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Tue, 16 Dec 2025 12:21:29 -0800 Subject: [PATCH 2/5] feat: centralize duplicated `OfficialCollection` and `ExternalLink` components Adds a new `components/` directory to the view models directory which is intended to house shared components. Refactors the official collection component into the `collections` view model folder, which is more appropriate given the content. --- src/mavedb/view_models/collection.py | 13 ++++++++++++- .../view_models/components/external_link.py | 15 +++++++++++++++ src/mavedb/view_models/experiment.py | 16 ++-------------- src/mavedb/view_models/score_set.py | 16 ++-------------- 4 files changed, 31 insertions(+), 29 deletions(-) create mode 100644 src/mavedb/view_models/components/external_link.py 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 c4cf20ba..c75e3bde 100644 --- a/src/mavedb/view_models/experiment.py +++ b/src/mavedb/view_models/experiment.py @@ -13,6 +13,8 @@ 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,20 +39,6 @@ from mavedb.view_models.user import SavedUser, User -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 ExperimentBase(BaseModel): title: str short_description: str diff --git a/src/mavedb/view_models/score_set.py b/src/mavedb/view_models/score_set.py index 9f53cf64..e6f20af9 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.""" From 9dbcd775bf086479ca7aa70ddec2157b906b180f Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Fri, 12 Dec 2025 18:08:10 -0800 Subject: [PATCH 3/5] fix: use TEST_ENVIRONMENT envvar in email validator tester rather than manipulating domain list --- tests/conftest.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c79c033e..b11f728c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import logging # noqa: F401 +import sys from datetime import datetime from unittest import mock -import sys import email_validator import pytest @@ -11,35 +11,33 @@ from sqlalchemy.pool import NullPool from mavedb.db.base import Base +from mavedb.models import * # noqa: F403 +from mavedb.models.experiment import Experiment from mavedb.models.experiment_set import ExperimentSet -from mavedb.models.score_set_publication_identifier import ScoreSetPublicationIdentifierAssociation -from mavedb.models.user import User, UserRole, Role from mavedb.models.license import License -from mavedb.models.taxonomy import Taxonomy -from mavedb.models.publication_identifier import PublicationIdentifier -from mavedb.models.experiment import Experiment -from mavedb.models.variant import Variant from mavedb.models.mapped_variant import MappedVariant +from mavedb.models.publication_identifier import PublicationIdentifier from mavedb.models.score_set import ScoreSet - -from mavedb.models import * # noqa: F403 - +from mavedb.models.score_set_publication_identifier import ScoreSetPublicationIdentifierAssociation +from mavedb.models.taxonomy import Taxonomy +from mavedb.models.user import Role, User, UserRole +from mavedb.models.variant import Variant from tests.helpers.constants import ( ADMIN_USER, EXTRA_USER, - TEST_LICENSE, + TEST_BRNICH_SCORE_CALIBRATION, TEST_INACTIVE_LICENSE, + TEST_LICENSE, + TEST_PATHOGENICITY_SCORE_CALIBRATION, + TEST_PUBMED_IDENTIFIER, TEST_SAVED_TAXONOMY, TEST_USER, - VALID_VARIANT_URN, - VALID_SCORE_SET_URN, - VALID_EXPERIMENT_URN, - VALID_EXPERIMENT_SET_URN, - TEST_PUBMED_IDENTIFIER, TEST_VALID_POST_MAPPED_VRS_ALLELE_VRS2_X, TEST_VALID_PRE_MAPPED_VRS_ALLELE_VRS2_X, - TEST_BRNICH_SCORE_CALIBRATION, - TEST_PATHOGENICITY_SCORE_CALIBRATION, + VALID_EXPERIMENT_SET_URN, + VALID_EXPERIMENT_URN, + VALID_SCORE_SET_URN, + VALID_VARIANT_URN, ) sys.path.append(".") @@ -56,7 +54,7 @@ assert pytest_postgresql.factories # Allow the @test domain name through our email validator. -email_validator.SPECIAL_USE_DOMAIN_NAMES.remove("test") +email_validator.TEST_ENVIRONMENT = True @pytest.fixture() From bb1e709d77ac65bed4598984166a1fb6e98db51e Mon Sep 17 00:00:00 2001 From: EstelleDa Date: Thu, 18 Dec 2025 16:15:54 +1100 Subject: [PATCH 4/5] Make the updating keywords in the editing experiment accept insensible searching and add some controlled keywords tests. --- src/mavedb/routers/experiments.py | 17 +- tests/helpers/constants.py | 62 ++++++- tests/routers/test_experiments.py | 283 +++++++++++++++++++++++++++++- 3 files changed, 351 insertions(+), 11 deletions(-) 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..481b5703 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", }, ], @@ -572,7 +579,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 +594,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 +674,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..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']}") From 6ae0d32e6efcc0a38bfcc2e21bed03dcf3240c03 Mon Sep 17 00:00:00 2001 From: David Reinhart Date: Mon, 5 Jan 2026 13:32:31 -0800 Subject: [PATCH 5/5] Fix path for validation errors on score set target genes to match view model --- src/mavedb/view_models/score_set.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mavedb/view_models/score_set.py b/src/mavedb/view_models/score_set.py index 9f53cf64..0e7f0529 100644 --- a/src/mavedb/view_models/score_set.py +++ b/src/mavedb/view_models/score_set.py @@ -109,7 +109,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 +134,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 +161,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",