Skip to content

Commit f44197e

Browse files
committed
Add OddsPath Property to ScoreRange Data Model
Adds an `OddsPath` property to the score range data model. This property is optional. If it is given, it should contain a `ratios` object with `abnormal` and `normal` (both floats), a `evidenceStrengths` property with `normal` and `abnormal` (both strings), and an optional `source` field. Uses the PublicationIdentifierBase view model for the OddsPath source. Adds a validator to the score set record that enforces this publication identifier has been included with the score set as either a primary or secondary publication identifier. Adds tests for this check and to ensure publications are added successfully.
1 parent 2977ea6 commit f44197e

File tree

5 files changed

+271
-5
lines changed

5 files changed

+271
-5
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from typing import Literal, Optional, Sequence
2+
3+
from mavedb.view_models import record_type_validator, set_record_type
4+
from mavedb.view_models.base.base import BaseModel
5+
from mavedb.view_models.publication_identifier import PublicationIdentifierBase
6+
7+
8+
class OddsPathRatio(BaseModel):
9+
normal: float
10+
abnormal: float
11+
12+
13+
class OddsPathEvidenceStrengths(BaseModel):
14+
normal: Literal["BS3_STRONG"]
15+
abnormal: Literal["PS3_STRONG"]
16+
17+
18+
class OddsPathBase(BaseModel):
19+
ratios: OddsPathRatio
20+
evidence_strengths: OddsPathEvidenceStrengths
21+
22+
23+
class OddsPathModify(OddsPathBase):
24+
source: Optional[list[PublicationIdentifierBase]] = None
25+
26+
27+
class OddsPathCreate(OddsPathModify):
28+
pass
29+
30+
31+
class SavedOddsPath(OddsPathBase):
32+
record_type: str = None # type: ignore
33+
34+
source: Optional[Sequence[PublicationIdentifierBase]] = None
35+
36+
_record_type_factory = record_type_validator()(set_record_type)
37+
38+
39+
class OddsPath(SavedOddsPath):
40+
pass

src/mavedb/view_models/score_set.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
DoiIdentifierCreate,
2222
SavedDoiIdentifier,
2323
)
24+
from mavedb.view_models.odds_path import OddsPath
2425
from mavedb.view_models.license import ShortLicense
2526
from mavedb.view_models.publication_identifier import (
2627
PublicationIdentifier,
@@ -79,6 +80,7 @@ def ranges_are_not_backwards(cls, field_value: tuple[Any]):
7980
class ScoreRanges(BaseModel):
8081
wt_score: Optional[float]
8182
ranges: list[ScoreRange] # type: ignore
83+
odds_path: Optional[OddsPath] = None
8284

8385

8486
class ScoreSetGetter(PublicationIdentifiersGetter):
@@ -292,6 +294,35 @@ def wild_type_score_in_normal_range(cls, field_value: Optional[ScoreRanges]):
292294
custom_loc=["body", "scoreRanges", "wtScore"],
293295
)
294296

297+
@root_validator()
298+
def validate_score_range_odds_path_source_in_publication_identifiers(cls, values):
299+
score_ranges: Optional[ScoreRanges] = values.get("score_ranges")
300+
if values.get("score_ranges") is None or score_ranges.odds_path is None:
301+
return values
302+
303+
if score_ranges.odds_path.source is None or len(score_ranges.odds_path.source) == 0:
304+
return values
305+
306+
for idx, pub in enumerate(score_ranges.odds_path.source):
307+
primary_publication_identifiers = (
308+
values.get("primary_publication_identifiers", [])
309+
if values.get("primary_publication_identifiers")
310+
else []
311+
)
312+
secondary_publication_identifiers = (
313+
values.get("secondary_publication_identifiers", [])
314+
if values.get("secondary_publication_identifiers")
315+
else []
316+
)
317+
if pub not in [*primary_publication_identifiers, *secondary_publication_identifiers]:
318+
raise ValidationError(
319+
f"Odds path source publication identifier at index {idx} is not defined in score set publications. "
320+
"To use a publication identifier in the odds path source, it must be defined in the primary or secondary publication identifiers.",
321+
custom_loc=["body", "scoreRanges", "oddsPath", "source", idx],
322+
)
323+
324+
return values
325+
295326

296327
class ScoreSetCreate(ScoreSetModify):
297328
"""View model for creating a new score set."""

tests/helpers/constants.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,12 +862,66 @@
862862
}
863863

864864

865+
TEST_ODDS_PATH = {
866+
"ratios": {
867+
"normal": 0.5,
868+
"abnormal": 5.0,
869+
},
870+
"evidence_strengths": {
871+
"normal": "BS3_STRONG",
872+
"abnormal": "PS3_STRONG",
873+
},
874+
"source": None,
875+
}
876+
877+
878+
TEST_SAVED_ODDS_PATH = {
879+
"recordType": "OddsPath",
880+
"ratios": {
881+
"normal": 0.5,
882+
"abnormal": 5.0,
883+
},
884+
"evidenceStrengths": {
885+
"normal": "BS3_STRONG",
886+
"abnormal": "PS3_STRONG",
887+
},
888+
}
889+
890+
891+
TEST_ODDS_PATH_WITH_SOURCE = {
892+
"ratios": {
893+
"normal": 0.5,
894+
"abnormal": 5.0,
895+
},
896+
"evidence_strengths": {
897+
"normal": "BS3_STRONG",
898+
"abnormal": "PS3_STRONG",
899+
},
900+
"source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}],
901+
}
902+
903+
904+
TEST_SAVED_ODDS_PATH_WITH_SOURCE = {
905+
"recordType": "OddsPath",
906+
"ratios": {
907+
"normal": 0.5,
908+
"abnormal": 5.0,
909+
},
910+
"evidenceStrengths": {
911+
"normal": "BS3_STRONG",
912+
"abnormal": "PS3_STRONG",
913+
},
914+
"source": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}],
915+
}
916+
917+
865918
TEST_SCORE_SET_RANGE = {
866919
"wt_score": 1.0,
867920
"ranges": [
868921
{"label": "test1", "classification": "normal", "range": (0, 2.0)},
869922
{"label": "test2", "classification": "abnormal", "range": (-2.0, 0)},
870923
],
924+
"odds_path": None,
871925
}
872926

873927

@@ -880,6 +934,46 @@
880934
}
881935

882936

937+
TEST_SCORE_SET_RANGE_WITH_ODDS_PATH = {
938+
"wt_score": 1.0,
939+
"ranges": [
940+
{"label": "test1", "classification": "normal", "range": (0, 2.0)},
941+
{"label": "test2", "classification": "abnormal", "range": (-2.0, 0)},
942+
],
943+
"odds_path": TEST_ODDS_PATH,
944+
}
945+
946+
947+
TEST_SAVED_SCORE_SET_RANGE_WITH_ODDS_PATH = {
948+
"wtScore": 1.0,
949+
"ranges": [
950+
{"label": "test1", "classification": "normal", "range": [0.0, 2.0]},
951+
{"label": "test2", "classification": "abnormal", "range": [-2.0, 0.0]},
952+
],
953+
"oddsPath": TEST_SAVED_ODDS_PATH,
954+
}
955+
956+
957+
TEST_SCORE_SET_RANGE_WITH_ODDS_PATH_AND_SOURCE = {
958+
"wt_score": 1.0,
959+
"ranges": [
960+
{"label": "test1", "classification": "normal", "range": (0, 2.0)},
961+
{"label": "test2", "classification": "abnormal", "range": (-2.0, 0)},
962+
],
963+
"odds_path": TEST_ODDS_PATH_WITH_SOURCE,
964+
}
965+
966+
967+
TEST_SAVED_SCORE_SET_RANGE_WITH_ODDS_PATH_AND_SOURCE = {
968+
"wtScore": 1.0,
969+
"ranges": [
970+
{"label": "test1", "classification": "normal", "range": [0.0, 2.0]},
971+
{"label": "test2", "classification": "abnormal", "range": [-2.0, 0.0]},
972+
],
973+
"oddsPath": TEST_SAVED_ODDS_PATH_WITH_SOURCE,
974+
}
975+
976+
883977
TEST_SCORE_CALIBRATION = {
884978
"parameter_sets": [
885979
{

tests/routers/test_score_set.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@
3232
TEST_MINIMAL_SEQ_SCORESET_RESPONSE,
3333
TEST_PUBMED_IDENTIFIER,
3434
TEST_ORCID_ID,
35+
TEST_SAVED_SCORE_SET_RANGE_WITH_ODDS_PATH_AND_SOURCE,
3536
TEST_SCORE_SET_RANGE,
3637
TEST_SAVED_SCORE_SET_RANGE,
3738
TEST_MINIMAL_ACC_SCORESET_RESPONSE,
39+
TEST_SCORE_SET_RANGE_WITH_ODDS_PATH_AND_SOURCE,
3840
TEST_USER,
3941
TEST_INACTIVE_LICENSE,
4042
SAVED_DOI_IDENTIFIER,
@@ -45,6 +47,8 @@
4547
TEST_SAVED_SCORE_CALIBRATION,
4648
TEST_SAVED_CLINVAR_CONTROL,
4749
TEST_SAVED_GENERIC_CLINICAL_CONTROL,
50+
TEST_SCORE_SET_RANGE_WITH_ODDS_PATH,
51+
TEST_SAVED_SCORE_SET_RANGE_WITH_ODDS_PATH,
4852
)
4953
from tests.helpers.dependency_overrider import DependencyOverrider
5054
from tests.helpers.util.common import update_expected_response_for_created_resources
@@ -141,11 +145,18 @@ def test_create_score_set_with_contributor(client, setup_router_db):
141145
assert response.status_code == 200
142146

143147

144-
def test_create_score_set_with_score_range(client, setup_router_db):
148+
@pytest.mark.parametrize(
149+
"score_ranges,saved_score_ranges",
150+
[
151+
(TEST_SCORE_SET_RANGE, TEST_SAVED_SCORE_SET_RANGE),
152+
(TEST_SCORE_SET_RANGE_WITH_ODDS_PATH, TEST_SAVED_SCORE_SET_RANGE_WITH_ODDS_PATH),
153+
],
154+
)
155+
def test_create_score_set_with_score_range(client, setup_router_db, score_ranges, saved_score_ranges):
145156
experiment = create_experiment(client)
146157
score_set = deepcopy(TEST_MINIMAL_SEQ_SCORESET)
147158
score_set["experimentUrn"] = experiment["urn"]
148-
score_set.update({"score_ranges": TEST_SCORE_SET_RANGE})
159+
score_set.update({"score_ranges": score_ranges})
149160

150161
response = client.post("/api/v1/score-sets/", json=score_set)
151162
assert response.status_code == 200
@@ -157,7 +168,7 @@ def test_create_score_set_with_score_range(client, setup_router_db):
157168
expected_response = update_expected_response_for_created_resources(
158169
deepcopy(TEST_MINIMAL_SEQ_SCORESET_RESPONSE), experiment, response_data
159170
)
160-
expected_response["scoreRanges"] = TEST_SAVED_SCORE_SET_RANGE
171+
expected_response["scoreRanges"] = saved_score_ranges
161172

162173
assert sorted(expected_response.keys()) == sorted(response_data.keys())
163174
for key in expected_response:
@@ -167,6 +178,60 @@ def test_create_score_set_with_score_range(client, setup_router_db):
167178
assert response.status_code == 200
168179

169180

181+
@pytest.mark.parametrize("publication_list", ["primary_publication_identifiers", "secondary_publication_identifiers"])
182+
@pytest.mark.parametrize(
183+
"mock_publication_fetch",
184+
[({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})],
185+
indirect=["mock_publication_fetch"],
186+
)
187+
def test_create_score_set_with_score_range_and_odds_path_source(
188+
client, setup_router_db, publication_list, mock_publication_fetch
189+
):
190+
experiment = create_experiment(client)
191+
score_set = deepcopy(TEST_MINIMAL_SEQ_SCORESET)
192+
score_set["experimentUrn"] = experiment["urn"]
193+
score_set[publication_list] = TEST_SCORE_SET_RANGE_WITH_ODDS_PATH_AND_SOURCE["odds_path"]["source"]
194+
score_set.update({"score_ranges": TEST_SCORE_SET_RANGE_WITH_ODDS_PATH_AND_SOURCE})
195+
196+
response = client.post("/api/v1/score-sets/", json=score_set)
197+
assert response.status_code == 200
198+
response_data = response.json()
199+
200+
jsonschema.validate(instance=response_data, schema=ScoreSet.schema())
201+
assert isinstance(MAVEDB_TMP_URN_RE.fullmatch(response_data["urn"]), re.Match)
202+
203+
expected_response = update_expected_response_for_created_resources(
204+
deepcopy(TEST_MINIMAL_SEQ_SCORESET_RESPONSE), experiment, response_data
205+
)
206+
expected_response[camelize(publication_list)] = [SAVED_PUBMED_PUBLICATION]
207+
expected_response["scoreRanges"] = TEST_SAVED_SCORE_SET_RANGE_WITH_ODDS_PATH_AND_SOURCE
208+
209+
assert sorted(expected_response.keys()) == sorted(response_data.keys())
210+
for key in expected_response:
211+
assert (key, expected_response[key]) == (key, response_data[key])
212+
213+
response = client.get(f"/api/v1/score-sets/{response_data['urn']}")
214+
assert response.status_code == 200
215+
216+
217+
def test_cannot_create_score_set_with_score_range_and_odds_path_source_when_publication_not_in_publications(
218+
client, setup_router_db
219+
):
220+
experiment = create_experiment(client)
221+
score_set = deepcopy(TEST_MINIMAL_SEQ_SCORESET)
222+
score_set["experimentUrn"] = experiment["urn"]
223+
score_set.update({"score_ranges": TEST_SCORE_SET_RANGE_WITH_ODDS_PATH_AND_SOURCE})
224+
225+
response = client.post("/api/v1/score-sets/", json=score_set)
226+
assert response.status_code == 422
227+
228+
response_data = response.json()
229+
assert (
230+
"Odds path source publication identifier at index 0 is not defined in score set publications."
231+
in response_data["detail"][0]["msg"]
232+
)
233+
234+
170235
def test_remove_score_range_from_score_set(client, setup_router_db):
171236
experiment = create_experiment(client)
172237
score_set = deepcopy(TEST_MINIMAL_SEQ_SCORESET)
@@ -1166,7 +1231,9 @@ def test_multiple_score_set_meta_analysis_single_experiment(
11661231
)
11671232

11681233
published_score_set_1_refresh = (client.get(f"/api/v1/score-sets/{published_score_set_1['urn']}")).json()
1169-
assert meta_score_set["metaAnalyzesScoreSetUrns"] == sorted([published_score_set_1["urn"], published_score_set_2["urn"]])
1234+
assert meta_score_set["metaAnalyzesScoreSetUrns"] == sorted(
1235+
[published_score_set_1["urn"], published_score_set_2["urn"]]
1236+
)
11701237
assert published_score_set_1_refresh["metaAnalyzedByScoreSetUrns"] == [meta_score_set["urn"]]
11711238

11721239
with patch.object(arq.ArqRedis, "enqueue_job", return_value=None) as worker_queue:

tests/view_models/test_score_set.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
from mavedb.view_models.publication_identifier import PublicationIdentifierCreate
44
from mavedb.view_models.score_set import ScoreSetCreate, ScoreSetModify
55
from mavedb.view_models.target_gene import TargetGeneCreate
6-
from tests.helpers.constants import TEST_MINIMAL_ACC_SCORESET, TEST_MINIMAL_SEQ_SCORESET
6+
from tests.helpers.constants import (
7+
TEST_MINIMAL_ACC_SCORESET,
8+
TEST_MINIMAL_SEQ_SCORESET,
9+
TEST_SCORE_SET_RANGE_WITH_ODDS_PATH,
10+
TEST_SCORE_SET_RANGE_WITH_ODDS_PATH_AND_SOURCE,
11+
)
712

813

914
def test_cannot_create_score_set_without_a_target():
@@ -494,6 +499,35 @@ def test_can_create_score_set_with_any_range_classification(classification):
494499
ScoreSetModify(**score_set_test)
495500

496501

502+
def test_can_create_score_set_with_odds_path_in_score_ranges():
503+
score_set_test = TEST_MINIMAL_SEQ_SCORESET.copy()
504+
score_set_test["score_ranges"] = TEST_SCORE_SET_RANGE_WITH_ODDS_PATH.copy()
505+
506+
ScoreSetModify(**score_set_test)
507+
508+
509+
def test_can_create_score_set_with_odds_path_and_source_in_score_ranges():
510+
score_set_test = TEST_MINIMAL_SEQ_SCORESET.copy()
511+
score_set_test["primary_publication_identifiers"] = TEST_SCORE_SET_RANGE_WITH_ODDS_PATH_AND_SOURCE["odds_path"][
512+
"source"
513+
]
514+
score_set_test["score_ranges"] = TEST_SCORE_SET_RANGE_WITH_ODDS_PATH_AND_SOURCE.copy()
515+
516+
ScoreSetModify(**score_set_test)
517+
518+
519+
def test_cannot_create_score_set_with_odds_path_and_source_in_score_ranges_if_source_not_in_score_set_publications():
520+
score_set_test = TEST_MINIMAL_SEQ_SCORESET.copy()
521+
score_set_test["score_ranges"] = TEST_SCORE_SET_RANGE_WITH_ODDS_PATH_AND_SOURCE.copy()
522+
523+
with pytest.raises(ValueError) as exc_info:
524+
ScoreSetModify(**score_set_test)
525+
526+
assert "Odds path source publication identifier at index 0 is not defined in score set publications." in str(
527+
exc_info.value
528+
)
529+
530+
497531
def test_cannot_create_score_set_with_inconsistent_base_editor_flags():
498532
score_set_test = TEST_MINIMAL_ACC_SCORESET.copy()
499533

0 commit comments

Comments
 (0)