diff --git a/src/mavedb/view_models/score_range.py b/src/mavedb/view_models/score_range.py index ee6c1e38..951b5804 100644 --- a/src/mavedb/view_models/score_range.py +++ b/src/mavedb/view_models/score_range.py @@ -148,10 +148,15 @@ class ScoreRangesCreate(ScoreRangesModify): ranges: Sequence[ScoreRangeCreate] +class ScoreRangesAdminCreate(ScoreRangesCreate): + primary: bool = False + + class SavedScoreRanges(ScoreRangesBase): record_type: str = None # type: ignore ranges: Sequence[SavedScoreRange] + primary: bool = False _record_type_factory = record_type_validator()(set_record_type) @@ -237,10 +242,15 @@ class BrnichScoreRangesCreate(ScoreRangesCreate, BrnichScoreRangesModify): ranges: Sequence[BrnichScoreRangeCreate] +class BrnichScoreRangesAdminCreate(ScoreRangesAdminCreate, BrnichScoreRangesCreate): + pass + + class SavedBrnichScoreRanges(SavedScoreRanges, BrnichScoreRangesBase): record_type: str = None # type: ignore ranges: Sequence[SavedBrnichScoreRange] + primary: bool = False _record_type_factory = record_type_validator()(set_record_type) @@ -290,11 +300,16 @@ class InvestigatorScoreRangesCreate(BrnichScoreRangesCreate, InvestigatorScoreRa research_use_only: bool = False +class InvestigatorScoreRangesAdminCreate(ScoreRangesAdminCreate, InvestigatorScoreRangesCreate): + pass + + class SavedInvestigatorScoreRanges(SavedBrnichScoreRanges, InvestigatorScoreRangesBase): record_type: str = None # type: ignore title: str = "Investigator-provided functional classes" research_use_only: bool = False + primary: bool = False _record_type_factory = record_type_validator()(set_record_type) @@ -324,11 +339,16 @@ class ScottScoreRangesCreate(BrnichScoreRangesCreate, ScottScoreRangesModify): research_use_only: bool = False +class ScottScoreRangesAdminCreate(ScoreRangesAdminCreate, ScottScoreRangesCreate): + pass + + class SavedScottScoreRanges(SavedBrnichScoreRanges, ScottScoreRangesBase): record_type: str = None # type: ignore title: str = "Scott calibration" research_use_only: bool = False + primary: bool = False _record_type_factory = record_type_validator()(set_record_type) @@ -338,6 +358,45 @@ class ScottScoreRanges(BrnichScoreRanges, SavedScottScoreRanges): research_use_only: bool = False +############################################################################################################## +# Fayer score range models +############################################################################################################## + + +class FayerScoreRangesBase(BrnichScoreRangesBase): + title: str = "Fayer calibration" + research_use_only: bool = False + + +class FayerScoreRangesModify(BrnichScoreRangesModify, FayerScoreRangesBase): + title: str = "Fayer calibration" + research_use_only: bool = False + + +class FayerScoreRangesCreate(BrnichScoreRangesCreate, FayerScoreRangesModify): + title: str = "Fayer calibration" + research_use_only: bool = False + + +class FayerScoreRangesAdminCreate(ScoreRangesAdminCreate, FayerScoreRangesCreate): + pass + + +class SavedFayerScoreRanges(SavedBrnichScoreRanges, FayerScoreRangesBase): + record_type: str = None # type: ignore + + title: str = "Fayer calibration" + research_use_only: bool = False + primary: bool = False + + _record_type_factory = record_type_validator()(set_record_type) + + +class FayerScoreRanges(BrnichScoreRanges, SavedFayerScoreRanges): + title: str = "Fayer calibration" + research_use_only: bool = False + + ############################################################################################################## # IGVF Coding Variant Focus Group (CVFG) range models ############################################################################################################## @@ -364,6 +423,12 @@ class IGVFCodingVariantFocusGroupControlScoreRangesCreate( research_use_only: bool = False +class IGVFCodingVariantFocusGroupControlScoreRangesAdminCreate( + ScoreRangesAdminCreate, IGVFCodingVariantFocusGroupControlScoreRangesCreate +): + pass + + class SavedIGVFCodingVariantFocusGroupControlScoreRanges( SavedBrnichScoreRanges, IGVFCodingVariantFocusGroupControlScoreRangesBase ): @@ -371,6 +436,7 @@ class SavedIGVFCodingVariantFocusGroupControlScoreRanges( title: str = "IGVF Coding Variant Focus Group -- Controls: All Variants" research_use_only: bool = False + primary: bool = False _record_type_factory = record_type_validator()(set_record_type) @@ -404,6 +470,12 @@ class IGVFCodingVariantFocusGroupMissenseScoreRangesCreate( research_use_only: bool = False +class IGVFCodingVariantFocusGroupMissenseScoreRangesAdminCreate( + ScoreRangesAdminCreate, IGVFCodingVariantFocusGroupMissenseScoreRangesCreate +): + pass + + class SavedIGVFCodingVariantFocusGroupMissenseScoreRanges( SavedBrnichScoreRanges, IGVFCodingVariantFocusGroupMissenseScoreRangesBase ): @@ -411,6 +483,7 @@ class SavedIGVFCodingVariantFocusGroupMissenseScoreRanges( title: str = "IGVF Coding Variant Focus Group -- Controls: Missense Variants Only" research_use_only: bool = False + primary: bool = False _record_type_factory = record_type_validator()(set_record_type) @@ -507,11 +580,16 @@ class ZeibergCalibrationScoreRangesCreate(ScoreRangesCreate, ZeibergCalibrationS ranges: Sequence[ZeibergCalibrationScoreRangeCreate] +class ZeibergCalibrationScoreRangesAdminCreate(ScoreRangesAdminCreate, ZeibergCalibrationScoreRangesCreate): + pass + + class SavedZeibergCalibrationScoreRanges(SavedScoreRanges, ZeibergCalibrationScoreRangesBase): record_type: str = None # type: ignore title: str = "Zeiberg calibration" research_use_only: bool = True + primary: bool = False ranges: Sequence[SavedZeibergCalibrationScoreRange] _record_type_factory = record_type_validator()(set_record_type) @@ -535,6 +613,7 @@ class ZeibergCalibrationScoreRanges(ScoreRanges, SavedZeibergCalibrationScoreRan class ScoreSetRangesBase(BaseModel): investigator_provided: Optional[InvestigatorScoreRangesBase] = None scott_calibration: Optional[ScottScoreRangesBase] = None + fayer_calibration: Optional[FayerScoreRangesBase] = None zeiberg_calibration: Optional[ZeibergCalibrationScoreRangesBase] = None cvfg_all_variants: Optional[IGVFCodingVariantFocusGroupControlScoreRangesBase] = None cvfg_missense_variants: Optional[IGVFCodingVariantFocusGroupMissenseScoreRangesBase] = None @@ -547,6 +626,7 @@ def score_range_labels_must_be_unique(self: "ScoreSetRangesBase") -> "ScoreSetRa self.investigator_provided, self.zeiberg_calibration, self.scott_calibration, + self.fayer_calibration, self.cvfg_all_variants, self.cvfg_missense_variants, ): @@ -571,6 +651,7 @@ def score_range_labels_must_be_unique(self: "ScoreSetRangesBase") -> "ScoreSetRa class ScoreSetRangesModify(ScoreSetRangesBase): investigator_provided: Optional[InvestigatorScoreRangesModify] = None scott_calibration: Optional[ScottScoreRangesModify] = None + fayer_calibration: Optional[FayerScoreRangesModify] = None zeiberg_calibration: Optional[ZeibergCalibrationScoreRangesModify] = None cvfg_all_variants: Optional[IGVFCodingVariantFocusGroupControlScoreRangesModify] = None cvfg_missense_variants: Optional[IGVFCodingVariantFocusGroupMissenseScoreRangesModify] = None @@ -579,6 +660,7 @@ class ScoreSetRangesModify(ScoreSetRangesBase): class ScoreSetRangesCreate(ScoreSetRangesModify): investigator_provided: Optional[InvestigatorScoreRangesCreate] = None scott_calibration: Optional[ScottScoreRangesCreate] = None + fayer_calibration: Optional[FayerScoreRangesCreate] = None zeiberg_calibration: Optional[ZeibergCalibrationScoreRangesCreate] = None cvfg_all_variants: Optional[IGVFCodingVariantFocusGroupControlScoreRangesCreate] = None cvfg_missense_variants: Optional[IGVFCodingVariantFocusGroupMissenseScoreRangesCreate] = None @@ -589,16 +671,45 @@ class SavedScoreSetRanges(ScoreSetRangesBase): investigator_provided: Optional[SavedInvestigatorScoreRanges] = None scott_calibration: Optional[SavedScottScoreRanges] = None + fayer_calibration: Optional[SavedFayerScoreRanges] = None zeiberg_calibration: Optional[SavedZeibergCalibrationScoreRanges] = None cvfg_all_variants: Optional[SavedIGVFCodingVariantFocusGroupControlScoreRanges] = None cvfg_missense_variants: Optional[SavedIGVFCodingVariantFocusGroupMissenseScoreRanges] = None _record_type_factory = record_type_validator()(set_record_type) + @model_validator(mode="after") + def one_and_only_one_primary_score_range_set(self: "SavedScoreSetRanges") -> "SavedScoreSetRanges": + primary_count = 0 + for container in ( + self.investigator_provided, + self.zeiberg_calibration, + self.scott_calibration, + self.cvfg_all_variants, + self.cvfg_missense_variants, + ): + if container is None: + continue + if getattr(container, "primary", False): + primary_count += 1 + + # Set the investigator provided score ranges as primary if no other primary is set. + if primary_count == 0 and self.investigator_provided is not None: + self.investigator_provided.primary = True + + elif primary_count > 1: + raise ValidationError( + f"A maximum of one score range set must be marked as primary, but {primary_count} were.", + custom_loc=["body", "scoreRanges"], + ) + + return self + class ScoreSetRanges(SavedScoreSetRanges): investigator_provided: Optional[InvestigatorScoreRanges] = None scott_calibration: Optional[ScottScoreRanges] = None + fayer_calibration: Optional[FayerScoreRanges] = None zeiberg_calibration: Optional[ZeibergCalibrationScoreRanges] = None cvfg_all_variants: Optional[IGVFCodingVariantFocusGroupControlScoreRanges] = None cvfg_missense_variants: Optional[IGVFCodingVariantFocusGroupMissenseScoreRanges] = None diff --git a/tests/helpers/constants.py b/tests/helpers/constants.py index 26edfec4..f49ded26 100644 --- a/tests/helpers/constants.py +++ b/tests/helpers/constants.py @@ -1614,6 +1614,7 @@ ], "research_use_only": False, "title": "Test Base Ranges with Source", + "primary": False, "source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], } @@ -1666,6 +1667,7 @@ ], "research_use_only": False, "title": "Test Brnich Functional Ranges", + "primary": False, "odds_path_source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], "source": None, } @@ -1681,6 +1683,7 @@ ], "researchUseOnly": False, "title": "Test Brnich Functional Ranges", + "primary": False, "oddsPathSource": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}], "source": None, } @@ -1745,6 +1748,7 @@ ], "research_use_only": False, "title": "Test Scott Functional Ranges", + "primary": False, "odds_path_source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], "source": None, } @@ -1760,6 +1764,7 @@ ], "researchUseOnly": False, "title": "Test Scott Functional Ranges", + "primary": False, "oddsPathSource": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}], "source": None, } @@ -1824,6 +1829,7 @@ ], "research_use_only": False, "title": "Test Investigator-provided Functional Ranges", + "primary": True, "odds_path_source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], "source": None, } @@ -1839,6 +1845,7 @@ ], "researchUseOnly": False, "title": "Test Investigator-provided Functional Ranges", + "primary": True, "oddsPathSource": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}], "source": None, } @@ -2039,6 +2046,7 @@ ], "research_use_only": True, "title": "Test Zeiberg Calibration", + "primary": False, "parameter_sets": TEST_ZEIBERG_CALIBRATION_PARAMETER_SETS, "prior_probability_pathogenicity": 0.20, "source": None, @@ -2059,6 +2067,7 @@ ], "researchUseOnly": True, "title": "Test Zeiberg Calibration", + "primary": False, "parameterSets": TEST_SAVED_ZEIBERG_CALIBRATION_PARAMETER_SETS, "priorProbabilityPathogenicity": 0.20, } diff --git a/tests/view_models/test_score_range.py b/tests/view_models/test_score_range.py index 704e26b1..73657d16 100644 --- a/tests/view_models/test_score_range.py +++ b/tests/view_models/test_score_range.py @@ -3,6 +3,7 @@ from pydantic import ValidationError from mavedb.view_models.score_range import ( + SavedScoreSetRanges, ScoreRangeModify, ScoreRangeCreate, ScoreRange, @@ -52,6 +53,8 @@ TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, TEST_SCORE_SET_NEGATIVE_INFINITY_RANGE, TEST_SCORE_SET_POSITIVE_INFINITY_RANGE, + TEST_SAVED_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, + TEST_SAVED_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, TEST_BASELINE_SCORE, ) @@ -727,16 +730,21 @@ def test_score_ranges_brnich_baseline_type_score_provided_if_normal_range_exists @pytest.mark.parametrize( - "score_set_ranges_data", + "score_set_ranges_data, primary_key", [ - TEST_SCORE_SET_RANGES_ONLY_SCOTT, - TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, - TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, + (TEST_SCORE_SET_RANGES_ONLY_SCOTT, "scott_calibration"), + (TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, "investigator_provided"), + (TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, "zeiberg_calibration"), + (TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, "investigator_provided"), ], ) @pytest.mark.parametrize("ScoreSetRangesModel", [ScoreSetRanges, ScoreSetRangesCreate, ScoreSetRangesModify]) -def test_score_set_ranges_valid_range(ScoreSetRangesModel, score_set_ranges_data): +def test_score_set_ranges_valid_range(ScoreSetRangesModel, score_set_ranges_data, primary_key): + # ensure only one primary if primary_key is set if necessary + if primary_key and ScoreSetRangesModel == ScoreSetRanges: + score_set_ranges_data = deepcopy(score_set_ranges_data) + score_set_ranges_data[primary_key]["primary"] = True + score_set_ranges = ScoreSetRangesModel(**score_set_ranges_data) assert isinstance(score_set_ranges, ScoreSetRangesModel), "ScoreSetRangesModel instantiation failed" # Ensure a ranges property exists. Data values are checked elsewhere in more detail. @@ -794,3 +802,40 @@ def test_score_set_ranges_may_include_duplicate_labels_in_different_range_defini range_schema["ranges"][0]["label"] = "duplicated_label" ScoreSetRangesModel(**score_set_ranges_data) + + +@pytest.mark.parametrize( + "ScoreSetRangesModel", + [ + SavedScoreSetRanges, + ScoreSetRanges, + ], +) +def test_saved_score_set_ranges_may_include_only_one_primary_definition(ScoreSetRangesModel): + # Add a duplicate label across all schemas + score_set_ranges_data = deepcopy(TEST_SAVED_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT) + score_set_ranges_data["investigatorProvided"]["primary"] = True + score_set_ranges_data["scottCalibration"]["primary"] = True + + with pytest.raises( + ValidationError, + match=r".*A maximum of one score range set must be marked as primary, but {} were\..*".format(2), + ): + ScoreSetRangesModel(**score_set_ranges_data) + + +@pytest.mark.parametrize( + "ScoreSetRangesModel", + [ + SavedScoreSetRanges, + ScoreSetRanges, + ], +) +def test_saved_score_set_ranges_may_include_no_primary_definition_if_investigator_provided_ranges_exist( + ScoreSetRangesModel, +): + # Add a duplicate label across all schemas + score_set_ranges_data = deepcopy(TEST_SAVED_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED) + score_set_ranges_data["investigatorProvided"]["primary"] = False + + ScoreSetRangesModel(**score_set_ranges_data)