From 2d9c43fe84c03a7adcf26ba0ea8640c99516d8df Mon Sep 17 00:00:00 2001 From: Estelle Da Date: Mon, 15 Jun 2026 16:37:51 +1000 Subject: [PATCH 1/6] Add superseded_calibration in Calibration. Modified related functions and tests. --- ...7c60b_add_calibration_superseded_column.py | 32 +++++++++++++ src/mavedb/lib/score_calibrations.py | 47 +++++++++++++++---- src/mavedb/lib/validation/urn_re.py | 8 ++++ src/mavedb/models/score_calibration.py | 13 ++++- src/mavedb/routers/score_calibrations.py | 3 +- src/mavedb/routers/score_sets.py | 2 +- src/mavedb/scripts/load_calibration_csv.py | 4 +- .../scripts/load_excalibr_calibrations.py | 4 +- src/mavedb/view_models/score_calibration.py | 16 +++++++ tests/routers/test_score_calibrations.py | 23 ++++++--- 10 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 alembic/versions/adb481b7c60b_add_calibration_superseded_column.py diff --git a/alembic/versions/adb481b7c60b_add_calibration_superseded_column.py b/alembic/versions/adb481b7c60b_add_calibration_superseded_column.py new file mode 100644 index 000000000..d6a064ad2 --- /dev/null +++ b/alembic/versions/adb481b7c60b_add_calibration_superseded_column.py @@ -0,0 +1,32 @@ +"""add_calibration_superseded_column + +Revision ID: adb481b7c60b +Revises: 398067c53257 +Create Date: 2026-06-01 16:45:35.507837 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'adb481b7c60b' +down_revision = '398067c53257' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('score_calibrations', sa.Column('replaces_id', sa.Integer(), nullable=True)) + op.create_index(op.f('ix_score_calibrations_replaces_id'), 'score_calibrations', ['replaces_id'], unique=False) + op.create_foreign_key(None, 'score_calibrations', 'score_calibrations', ['replaces_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'score_calibrations', type_='foreignkey') + op.drop_index(op.f('ix_score_calibrations_replaces_id'), table_name='score_calibrations') + op.drop_column('score_calibrations', 'replaces_id') + # ### end Alembic commands ### diff --git a/src/mavedb/lib/score_calibrations.py b/src/mavedb/lib/score_calibrations.py index 11e1b2e88..a4615e4a6 100644 --- a/src/mavedb/lib/score_calibrations.py +++ b/src/mavedb/lib/score_calibrations.py @@ -4,11 +4,14 @@ from typing import Optional, Union import pandas as pd +from fastapi import Depends from sqlalchemy import Float, and_, select from sqlalchemy.orm import Session from mavedb.lib.acmg import find_or_create_acmg_classification from mavedb.lib.identifiers import find_or_create_publication_identifier +from mavedb.lib.permissions import Action, has_permission +from mavedb.lib.types.authentication import UserData from mavedb.lib.types.score_calibrations import ClassificationDict from mavedb.lib.validation.constants.general import ( calibration_class_column_name, @@ -25,6 +28,7 @@ from mavedb.models.user import User from mavedb.models.variant import Variant from mavedb.view_models import score_calibration +from mavedb.view_models.score_calibration import ScoreCalibrationCreate def create_functional_classification( @@ -97,7 +101,7 @@ def create_functional_classification( async def _create_score_calibration( db: Session, calibration_create: score_calibration.ScoreCalibrationCreate, - user: User, + user_data: UserData, variant_classes: Optional[ClassificationDict] = None, containing_score_set: Optional[ScoreSet] = None, ) -> ScoreCalibration: @@ -176,6 +180,8 @@ async def _create_score_calibration( db.add(pub) db.flush() + superseded_calibration = validate_superseded_score_calibration(db, calibration_create, user_data) + calibration = ScoreCalibration( **calibration_create.model_dump( by_alias=False, @@ -185,12 +191,14 @@ async def _create_score_calibration( "evidence_sources", "method_sources", "score_set_urn", + "superseded_calibration_urn" }, ), publication_identifier_associations=calibration_pub_assocs, functional_classifications=[], - created_by=user, - modified_by=user, + superseded_calibration=superseded_calibration, + created_by=user_data.user, + modified_by=user_data.user, ) # type: ignore[call-arg] if containing_score_set: @@ -210,7 +218,7 @@ async def _create_score_calibration( async def create_score_calibration_in_score_set( db: Session, calibration_create: score_calibration.ScoreCalibrationCreate, - user: User, + user_data: UserData, variant_classes: Optional[ClassificationDict] = None, ) -> ScoreCalibration: """ @@ -251,9 +259,9 @@ async def create_score_calibration_in_score_set( raise ValueError("score_set_urn must be provided to create a score calibration within a score set.") containing_score_set = db.query(ScoreSet).where(ScoreSet.urn == calibration_create.score_set_urn).one() - calibration = await _create_score_calibration(db, calibration_create, user, variant_classes, containing_score_set) + calibration = await _create_score_calibration(db, calibration_create, user_data, variant_classes, containing_score_set) - if user.username in [contributor.orcid_id for contributor in containing_score_set.contributors] + [ + if user_data.user.username in [contributor.orcid_id for contributor in containing_score_set.contributors] + [ containing_score_set.created_by.username, containing_score_set.modified_by.username, ]: @@ -268,7 +276,7 @@ async def create_score_calibration_in_score_set( async def create_score_calibration( db: Session, calibration_create: score_calibration.ScoreCalibrationCreate, - user: User, + user_data: UserData, variant_classes: Optional[ClassificationDict] = None, ) -> ScoreCalibration: """ @@ -321,7 +329,7 @@ async def create_score_calibration( raise ValueError("score_set_urn must not be provided to create a score calibration outside a score set.") created_calibration = await _create_score_calibration( - db, calibration_create, user, variant_classes, containing_score_set=None + db, calibration_create, user_data, variant_classes, containing_score_set=None ) db.add(created_calibration) @@ -652,6 +660,29 @@ def delete_score_calibration(db: Session, calibration: ScoreCalibration) -> None return None +def validate_superseded_score_calibration( + db: Session, + calibration_create: ScoreCalibrationCreate, + user_data: Optional[UserData], +) -> Optional[ScoreCalibration]: + if not calibration_create.superseded_calibration_urn: + return None + + superseded_calibration = ( + db.query(ScoreCalibration) + .where(ScoreCalibration.urn == calibration_create.superseded_calibration_urn) + .one_or_none() + ) + + if superseded_calibration is None: + raise ValueError("Superseded calibration does not exist.") + + if not has_permission(user_data, superseded_calibration, Action.READ).permitted: + raise ValueError("No access right to supersede this calibration.") + + return superseded_calibration + + def variants_for_functional_classification( db: Session, functional_classification: ScoreCalibrationFunctionalClassification, diff --git a/src/mavedb/lib/validation/urn_re.py b/src/mavedb/lib/validation/urn_re.py index 82feb19a2..595c07e3d 100644 --- a/src/mavedb/lib/validation/urn_re.py +++ b/src/mavedb/lib/validation/urn_re.py @@ -32,6 +32,14 @@ MAVEDB_COLLECTION_URN_PATTERN = r"urn:mavedb:collection-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" MAVEDB_COLLECTION_URN_RE = re.compile(MAVEDB_COLLECTION_URN_PATTERN) +# Temp calibration URN +MAVEDB_TMP_CALIBRATION_URN_PATTERN = r"tmp:mavedb.calibration-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" +MAVEDB_TMP_CALIBRATION_URN_RE = re.compile(MAVEDB_TMP_CALIBRATION_URN_PATTERN) + +# Calibration URN +MAVEDB_CALIBRATION_URN_PATTERN = r"urn:mavedb:calibration-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" +MAVEDB_CALIBRATION_URN_RE = re.compile(MAVEDB_CALIBRATION_URN_PATTERN) + # Any URN MAVEDB_ANY_URN_PATTERN = "|".join( [ diff --git a/src/mavedb/models/score_calibration.py b/src/mavedb/models/score_calibration.py index 38ce1f286..f4c283010 100644 --- a/src/mavedb/models/score_calibration.py +++ b/src/mavedb/models/score_calibration.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import date -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from sqlalchemy import Boolean, Column, Date, Float, ForeignKey, Integer, String from sqlalchemy.dialects.postgresql import JSONB @@ -60,6 +60,17 @@ class ScoreCalibration(Base): calibration_metadata = Column(JSONB(none_as_null=True), nullable=True) + superseded_calibration_id = Column("replaces_id", Integer, ForeignKey("score_calibrations.id"), index=True, nullable=True) + superseded_calibration: Mapped[Optional["ScoreCalibration"]] = relationship( + "ScoreCalibration", + uselist=False, + foreign_keys="ScoreCalibration.superseded_calibration_id", + remote_side=[id], + ) + superseding_calibration: Mapped[Optional["ScoreCalibration"]] = relationship( + "ScoreCalibration", uselist=False, back_populates="superseded_calibration" + ) + created_by_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=False) created_by: Mapped["User"] = relationship("User", foreign_keys="ScoreCalibration.created_by_id") modified_by_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=False) diff --git a/src/mavedb/routers/score_calibrations.py b/src/mavedb/routers/score_calibrations.py index e8b31478c..eff22df22 100644 --- a/src/mavedb/routers/score_calibrations.py +++ b/src/mavedb/routers/score_calibrations.py @@ -132,6 +132,7 @@ async def get_score_calibrations_for_score_set( calibrations = ( db.query(ScoreCalibration) .filter(ScoreCalibration.score_set_id == score_set.id) + .filter(~ScoreCalibration.superseding_calibration.has(ScoreCalibration.private == False)) .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) .all() ) @@ -339,7 +340,7 @@ async def create_score_calibration_route( ) created_calibration = await create_score_calibration_in_score_set( - db, calibration, user_data.user, variant_classes if classes_file else None + db, calibration, user_data, variant_classes if classes_file else None ) db.commit() diff --git a/src/mavedb/routers/score_sets.py b/src/mavedb/routers/score_sets.py index 74f2a5c0b..349080cac 100644 --- a/src/mavedb/routers/score_sets.py +++ b/src/mavedb/routers/score_sets.py @@ -1742,7 +1742,7 @@ async def create_score_set( ) created_calibration_item = await create_score_calibration( - db, calibration_create, user_data.user, variant_classes=None + db, calibration_create, user_data, variant_classes=None ) created_calibration_item.investigator_provided = True # necessarily true on score set creation score_calibrations.append(created_calibration_item) diff --git a/src/mavedb/scripts/load_calibration_csv.py b/src/mavedb/scripts/load_calibration_csv.py index 066509e39..2a3c7b482 100644 --- a/src/mavedb/scripts/load_calibration_csv.py +++ b/src/mavedb/scripts/load_calibration_csv.py @@ -100,6 +100,7 @@ from mavedb.lib.acmg import ACMGCriterion, StrengthOfEvidenceProvided from mavedb.lib.oddspaths import oddspaths_evidence_strength_equivalent from mavedb.lib.score_calibrations import create_score_calibration_in_score_set +from mavedb.lib.types.authentication import UserData from mavedb.models import score_calibration from mavedb.models.enums.functional_classification import FunctionalClassification as FunctionalClassifcationOptions from mavedb.models.score_set import ScoreSet @@ -414,8 +415,9 @@ def main(db: Session, csv_path: str, delimiter: str, overwrite: bool, purge_publ system_user = db.query(User).filter(User.id == 1).one() calibration_user = score_set.created_by if calibration_is_investigator_provided else system_user + calibration_user_data = UserData(calibration_user, calibration_user.roles) new_calibration_object = asyncio.run( - create_score_calibration_in_score_set(db, created_score_calibration, calibration_user) + create_score_calibration_in_score_set(db, created_score_calibration, calibration_user_data) ) new_calibration_object.primary = primary new_calibration_object.private = False diff --git a/src/mavedb/scripts/load_excalibr_calibrations.py b/src/mavedb/scripts/load_excalibr_calibrations.py index 72f7ed2cd..208754806 100644 --- a/src/mavedb/scripts/load_excalibr_calibrations.py +++ b/src/mavedb/scripts/load_excalibr_calibrations.py @@ -49,6 +49,7 @@ from sqlalchemy.orm import Session from mavedb.lib.score_calibrations import create_score_calibration_in_score_set +from mavedb.lib.types.authentication import UserData from mavedb.models.enums.functional_classification import FunctionalClassification as FunctionalClassificationOptions from mavedb.models.score_calibration import ScoreCalibration from mavedb.models.score_set import ScoreSet @@ -238,8 +239,9 @@ def main(db: Session, csv_path: str, dataset_map: str, overwrite: bool, remove: method_sources=[EXCALIBR_CALIBRATION_CITATION], ) + system_user_data = UserData(system_user, system_user.roles) new_calibration_object = asyncio.run( - create_score_calibration_in_score_set(db, score_calibration_create, system_user) + create_score_calibration_in_score_set(db, score_calibration_create, system_user_data) ) new_calibration_object.primary = False new_calibration_object.private = False diff --git a/src/mavedb/view_models/score_calibration.py b/src/mavedb/view_models/score_calibration.py index 857d4e49f..06e12885b 100644 --- a/src/mavedb/view_models/score_calibration.py +++ b/src/mavedb/view_models/score_calibration.py @@ -10,6 +10,7 @@ from pydantic import Field, field_validator, model_validator from mavedb.lib.oddspaths import oddspaths_evidence_strength_equivalent +from mavedb.lib.validation import urn_re from mavedb.lib.validation.exceptions import ValidationError from mavedb.lib.validation.transform import ( transform_score_calibration_publication_identifiers, @@ -477,11 +478,24 @@ def functional_classifications_require_publication_sources( class ScoreCalibrationCreate(ScoreCalibrationModify): """Model used to create a new score calibration.""" + superseded_calibration_urn: Optional[str] = None functional_classifications: Optional[Sequence[FunctionalClassificationCreate]] = None threshold_sources: Sequence[PublicationIdentifierCreate] evidence_sources: Sequence[PublicationIdentifierCreate] method_sources: Sequence[PublicationIdentifierCreate] + @field_validator("superseded_calibration_urn") + def validate_superseded_calibration_urn(cls, v: Optional[str]) -> Optional[str]: + if not v: + return None + + if urn_re.MAVEDB_CALIBRATION_URN_RE.fullmatch(v) is None: + if urn_re.MAVEDB_TMP_CALIBRATION_URN_RE.fullmatch(v) is None: + raise ValueError(f"'{v}' is not a valid calibration URN") + else: + raise ValueError("cannot supersede a private calibration - please edit it instead") + + return v class SavedScoreCalibration(ScoreCalibrationBase): """Persisted score calibration model (includes identifiers and source lists).""" @@ -501,6 +515,8 @@ class SavedScoreCalibration(ScoreCalibrationBase): threshold_sources: Sequence[SavedPublicationIdentifier] evidence_sources: Sequence[SavedPublicationIdentifier] method_sources: Sequence[SavedPublicationIdentifier] + superseded_calibration: Optional[ScoreCalibrationBase] = None + superseding_calibration: Optional[ScoreCalibrationBase] = None created_by: Optional[SavedUser] = None modified_by: Optional[SavedUser] = None diff --git a/tests/routers/test_score_calibrations.py b/tests/routers/test_score_calibrations.py index fe1aeba7c..9f9be8391 100644 --- a/tests/routers/test_score_calibrations.py +++ b/tests/routers/test_score_calibrations.py @@ -963,8 +963,15 @@ def test_creating_user_can_get_score_calibrations_for_score_set_when_public( assert response.status_code == 200 calibrations_response = response.json() assert len(calibrations_response) == 2 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is False + # The order is different from before. + created_calibration = next( + c for c in calibrations_response if c["urn"] == calibration["urn"] + ) + assert created_calibration["private"] is False + other_calibration = next( + c for c in calibrations_response if c["urn"] != calibration["urn"] + ) + assert other_calibration["private"] is True @pytest.mark.parametrize( @@ -1013,8 +1020,10 @@ def test_contributing_user_can_get_score_calibrations_for_score_set_when_public( assert response.status_code == 200 calibrations_response = response.json() assert len(calibrations_response) == 2 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is False + created_calibration = next( + c for c in calibrations_response if c["urn"] == calibration["urn"] + ) + assert created_calibration["private"] is False @pytest.mark.parametrize( @@ -1054,8 +1063,10 @@ def test_admin_user_can_get_score_calibrations_for_score_set_when_public( assert response.status_code == 200 calibrations_response = response.json() assert len(calibrations_response) == 2 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is False + created_calibration = next( + c for c in calibrations_response if c["urn"] == calibration["urn"] + ) + assert created_calibration["private"] is False ########################################################### From 8dfd0a2501d88a66f23a6f354d029960d2748247 Mon Sep 17 00:00:00 2001 From: Estelle Da Date: Mon, 15 Jun 2026 16:50:41 +1000 Subject: [PATCH 2/6] Remove unnecessary import and modify query code. --- .../versions/adb481b7c60b_add_calibration_superseded_column.py | 1 - src/mavedb/lib/score_calibrations.py | 1 - src/mavedb/routers/score_calibrations.py | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/alembic/versions/adb481b7c60b_add_calibration_superseded_column.py b/alembic/versions/adb481b7c60b_add_calibration_superseded_column.py index d6a064ad2..ce88b632d 100644 --- a/alembic/versions/adb481b7c60b_add_calibration_superseded_column.py +++ b/alembic/versions/adb481b7c60b_add_calibration_superseded_column.py @@ -7,7 +7,6 @@ """ from alembic import op import sqlalchemy as sa -from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = 'adb481b7c60b' diff --git a/src/mavedb/lib/score_calibrations.py b/src/mavedb/lib/score_calibrations.py index a4615e4a6..392175f37 100644 --- a/src/mavedb/lib/score_calibrations.py +++ b/src/mavedb/lib/score_calibrations.py @@ -4,7 +4,6 @@ from typing import Optional, Union import pandas as pd -from fastapi import Depends from sqlalchemy import Float, and_, select from sqlalchemy.orm import Session diff --git a/src/mavedb/routers/score_calibrations.py b/src/mavedb/routers/score_calibrations.py index eff22df22..f55bb8871 100644 --- a/src/mavedb/routers/score_calibrations.py +++ b/src/mavedb/routers/score_calibrations.py @@ -132,7 +132,7 @@ async def get_score_calibrations_for_score_set( calibrations = ( db.query(ScoreCalibration) .filter(ScoreCalibration.score_set_id == score_set.id) - .filter(~ScoreCalibration.superseding_calibration.has(ScoreCalibration.private == False)) + .filter(~ScoreCalibration.superseding_calibration.has(ScoreCalibration.private.is_(False))) .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) .all() ) From 63bc5b865ab896cdf463beb759d6b217719d22ea Mon Sep 17 00:00:00 2001 From: Estelle Da Date: Tue, 16 Jun 2026 16:35:30 +1000 Subject: [PATCH 3/6] Modified related tests. --- tests/helpers/util/score_calibration.py | 6 +- tests/lib/test_score_calibrations.py | 135 +++++++++++++++++------- 2 files changed, 97 insertions(+), 44 deletions(-) diff --git a/tests/helpers/util/score_calibration.py b/tests/helpers/util/score_calibration.py index a535096c2..900e3822d 100644 --- a/tests/helpers/util/score_calibration.py +++ b/tests/helpers/util/score_calibration.py @@ -3,8 +3,8 @@ import jsonschema from mavedb.lib.score_calibrations import create_score_calibration_in_score_set +from mavedb.lib.types.authentication import UserData from mavedb.models.score_calibration import ScoreCalibration -from mavedb.models.user import User from mavedb.view_models.score_calibration import ScoreCalibrationCreate, ScoreCalibrationWithScoreSetUrn from tests.helpers.constants import TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED @@ -14,12 +14,12 @@ async def create_test_range_based_score_calibration_in_score_set( - db: "Session", score_set_urn: str, user: User + db: "Session", score_set_urn: str, user_data: UserData ) -> ScoreCalibration: calibration_create = ScoreCalibrationCreate( **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, score_set_urn=score_set_urn ) - created_score_calibration = await create_score_calibration_in_score_set(db, calibration_create, user) + created_score_calibration = await create_score_calibration_in_score_set(db, calibration_create, user_data) assert created_score_calibration is not None db.commit() diff --git a/tests/lib/test_score_calibrations.py b/tests/lib/test_score_calibrations.py index 9a5ba43c5..43f0b367e 100644 --- a/tests/lib/test_score_calibrations.py +++ b/tests/lib/test_score_calibrations.py @@ -23,6 +23,7 @@ variant_classification_df_to_dict, variants_for_functional_classification, ) +from mavedb.lib.types.authentication import UserData from mavedb.lib.validation.constants.general import ( calibration_class_column_name, calibration_variant_column_name, @@ -341,6 +342,7 @@ async def test_create_score_calibration_in_score_set_creates_score_calibration_w setup_lib_db_with_score_set, session ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) MockCalibrationCreate = create_model( "MockCalibrationCreate", @@ -349,9 +351,10 @@ async def test_create_score_calibration_in_score_set_creates_score_calibration_w evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) - calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), test_user) + calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), test_user_data) assert calibration is not None assert calibration.score_set == setup_lib_db_with_score_set @@ -361,6 +364,7 @@ async def test_create_score_calibration_in_score_set_investigator_provided_set_w setup_lib_db_with_score_set, session, mock_user ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) MockCalibrationCreate = create_model( "MockCalibrationCreate", @@ -369,9 +373,10 @@ async def test_create_score_calibration_in_score_set_investigator_provided_set_w evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) - calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), test_user) + calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), test_user_data) assert calibration is not None assert calibration.score_set == setup_lib_db_with_score_set assert calibration.created_by == test_user @@ -384,6 +389,7 @@ async def test_create_score_calibration_in_score_set_investigator_provided_set_w setup_lib_db_with_score_set, session ): extra_user = session.execute(select(User).where(User.username == EXTRA_USER["username"])).scalars().first() + extra_user_data = UserData(user=extra_user, active_roles=[]) add_contributor( session, @@ -401,9 +407,10 @@ async def test_create_score_calibration_in_score_set_investigator_provided_set_w evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) - calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), extra_user) + calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), extra_user_data) assert calibration is not None assert calibration.score_set == setup_lib_db_with_score_set assert calibration.created_by == extra_user @@ -422,12 +429,14 @@ async def test_create_score_calibration_in_score_set_investigator_provided_not_s evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) # invoke from a different user context extra_user = session.execute(select(User).where(User.username == EXTRA_USER["username"])).scalars().first() + extra_user_data = UserData(user=extra_user, active_roles=[]) - calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), extra_user) + calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), extra_user_data) assert calibration is not None assert calibration.score_set == setup_lib_db_with_score_set assert calibration.created_by == extra_user @@ -453,6 +462,7 @@ async def test_create_score_calibration_raises_value_error_when_score_set_urn_is @pytest.mark.asyncio async def test_create_score_calibration_creates_score_calibration_when_score_set_urn_is_absent(setup_lib_db, session): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) MockCalibrationCreate = create_model( "MockCalibrationCreate", @@ -461,9 +471,10 @@ async def test_create_score_calibration_creates_score_calibration_when_score_set evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) - calibration = await create_score_calibration(session, MockCalibrationCreate(), test_user) + calibration = await create_score_calibration(session, MockCalibrationCreate(), test_user_data) assert calibration is not None assert calibration.score_set is None @@ -549,9 +560,11 @@ async def test_create_score_calibration_publication_identifier_associations_crea evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) mocked_calibration = MockCalibrationCreate() setattr( @@ -560,7 +573,7 @@ async def test_create_score_calibration_publication_identifier_associations_crea [create_model("MockPublicationCreate", db_name=(str, "PubMed"), identifier=(str, TEST_PUBMED_IDENTIFIER))()], ) - calibration = await create_function_to_call(session, mocked_calibration, test_user) + calibration = await create_function_to_call(session, mocked_calibration, test_user_data) assert calibration.publication_identifier_associations[0].publication.db_name == "PubMed" assert calibration.publication_identifier_associations[0].publication.identifier == TEST_PUBMED_IDENTIFIER assert calibration.publication_identifier_associations[0].relation == expected_relation @@ -585,11 +598,13 @@ async def test_create_score_calibration_user_is_set_as_creator_and_modifier( evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) - calibration = await create_function_to_call(session, MockCalibrationCreate(), test_user) + calibration = await create_function_to_call(session, MockCalibrationCreate(), test_user_data) assert calibration.created_by == test_user assert calibration.modified_by == test_user @@ -630,8 +645,9 @@ async def test_create_score_calibration_fully_valid_calibration( calibration_create = ScoreCalibrationCreate(**valid_score_calibration_data, score_set_urn=score_set_urn) test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) - calibration = await create_function_to_call(session, calibration_create, test_user) + calibration = await create_function_to_call(session, calibration_create, test_user_data) for field in valid_score_calibration_data: # Sources are tested elsewhere. @@ -671,9 +687,10 @@ async def test_create_score_calibration_does_not_commit_transaction( **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, score_set_urn=score_set_urn ) test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) with mock.patch.object(session, "commit") as mock_commit: - await create_function_to_call(session, calibration_create, test_user) + await create_function_to_call(session, calibration_create, test_user_data) mock_commit.assert_not_called() @@ -721,9 +738,10 @@ async def test_modify_score_calibration_modifies_score_calibration_when_score_se setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -734,6 +752,7 @@ async def test_modify_score_calibration_modifies_score_calibration_when_score_se evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) modified_calibration = await modify_score_calibration( @@ -759,9 +778,10 @@ async def test_modify_score_calibration_clears_existing_publication_identifier_a setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -771,6 +791,7 @@ async def test_modify_score_calibration_clears_existing_publication_identifier_a evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) mocked_calibration = MockCalibrationModify() @@ -806,9 +827,10 @@ async def test_modify_score_calibration_publication_identifier_associations_crea expected_relation, ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -818,6 +840,7 @@ async def test_modify_score_calibration_publication_identifier_associations_crea evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) mocked_calibration = MockCalibrationModify() @@ -849,9 +872,10 @@ async def test_modify_score_calibration_retains_existing_publication_relationshi setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) calibration_publication_relations = existing_calibration.publication_identifier_associations.copy() @@ -917,9 +941,10 @@ async def test_modify_score_calibration_adds_new_publication_association( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -938,6 +963,7 @@ async def test_modify_score_calibration_adds_new_publication_association( evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) modified_calibration = await modify_score_calibration( @@ -967,9 +993,10 @@ async def test_modify_score_calibration_user_is_set_as_modifier( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -979,6 +1006,7 @@ async def test_modify_score_calibration_user_is_set_as_modifier( evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) modify_user = session.execute(select(User).where(User.id != test_user.id)).scalars().first() @@ -1018,8 +1046,9 @@ async def test_modify_score_calibration_new_score_set(setup_lib_db_with_score_se session.refresh(new_containing_score_set) test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, new_containing_score_set.urn, test_user + session, new_containing_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -1029,6 +1058,7 @@ async def test_modify_score_calibration_new_score_set(setup_lib_db_with_score_se evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) modified_calibration = await modify_score_calibration( @@ -1053,9 +1083,10 @@ async def test_modify_score_calibration_clears_functional_classifications( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -1065,6 +1096,7 @@ async def test_modify_score_calibration_clears_functional_classifications( evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) modified_calibration = await modify_score_calibration( @@ -1089,9 +1121,10 @@ async def test_modify_score_calibration_fully_valid_calibration( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) modify_calibration = ScoreCalibrationModify( @@ -1127,9 +1160,10 @@ async def test_modify_score_calibration_does_not_commit_transaction( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) modify_calibration = ScoreCalibrationModify( @@ -1161,9 +1195,10 @@ async def test_cannot_publish_already_published_calibration( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.private = False session.add(existing_calibration) @@ -1189,9 +1224,10 @@ async def test_publish_score_calibration_marks_calibration_public( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) assert existing_calibration.private is True @@ -1214,9 +1250,10 @@ async def test_publish_score_calibration_user_is_set_as_modifier( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) publish_user = session.execute(select(User).where(User.id != test_user.id)).scalars().first() @@ -1244,9 +1281,10 @@ async def test_publish_score_calibration_user_is_set_as_modifier( ) async def test_cannot_promote_already_primary_calibration(setup_lib_db_with_score_set, session, mock_publication_fetch): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.primary = True session.add(existing_calibration) @@ -1272,9 +1310,10 @@ async def test_cannot_promote_calibration_when_calibration_is_research_use_only( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.research_use_only = True session.add(existing_calibration) @@ -1300,9 +1339,10 @@ async def test_cannot_promote_calibration_when_calibration_is_private( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.private = True session.add(existing_calibration) @@ -1328,12 +1368,13 @@ async def test_cannot_promote_calibration_when_another_primary_exists( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_primary_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_primary_calibration.private = False existing_primary_calibration.primary = True @@ -1365,9 +1406,10 @@ async def test_promote_score_calibration_to_primary_marks_calibration_primary( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.private = False existing_calibration.primary = False @@ -1394,12 +1436,13 @@ async def test_promote_score_calibration_to_primary_demotes_existing_primary_whe setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_primary_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_primary_calibration.private = False existing_primary_calibration.primary = True @@ -1437,9 +1480,10 @@ async def test_promote_score_calibration_to_primary_user_is_set_as_modifier( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.private = False existing_calibration.primary = False @@ -1471,12 +1515,13 @@ async def test_promote_score_calibration_to_primary_demoted_existing_primary_use setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_primary_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_primary_calibration.private = False existing_primary_calibration.primary = True @@ -1517,9 +1562,10 @@ async def test_promote_score_calibration_to_primary_does_not_commit_transaction( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.private = False existing_calibration.primary = False @@ -1550,9 +1596,10 @@ async def test_promote_score_calibration_to_primary_does_not_commit_transaction( ) async def test_cannot_demote_non_primary_calibration(setup_lib_db_with_score_set, session, mock_publication_fetch): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.primary = False session.add(existing_calibration) @@ -1578,9 +1625,10 @@ async def test_demote_score_calibration_from_primary_marks_calibration_non_prima setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.primary = True session.add(existing_calibration) @@ -1607,9 +1655,10 @@ async def test_demote_score_calibration_from_primary_user_is_set_as_modifier( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.primary = True session.add(existing_calibration) @@ -1638,9 +1687,10 @@ async def test_demote_score_calibration_from_primary_does_not_commit_transaction setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.primary = True session.add(existing_calibration) @@ -1670,9 +1720,10 @@ async def test_demote_score_calibration_from_primary_does_not_commit_transaction ) async def test_cannot_delete_primary_calibration(setup_lib_db_with_score_set, session, mock_publication_fetch): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.primary = True session.add(existing_calibration) @@ -1698,9 +1749,10 @@ async def test_delete_score_calibration_deletes_calibration( session, setup_lib_db_with_score_set, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) calibration_id = existing_calibration.id @@ -1726,9 +1778,10 @@ async def test_delete_score_calibration_does_not_commit_transaction( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) with mock.patch.object(session, "commit") as mock_commit: From a3875f5b753e3cf11f08d6208e622427b322571c Mon Sep 17 00:00:00 2001 From: Estelle Da Date: Tue, 23 Jun 2026 17:01:26 +1000 Subject: [PATCH 4/6] Debug calibration functions and add some new tests. --- src/mavedb/lib/score_calibrations.py | 8 +- src/mavedb/routers/score_sets.py | 13 +- tests/lib/test_score_calibrations.py | 189 +++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 2 deletions(-) diff --git a/src/mavedb/lib/score_calibrations.py b/src/mavedb/lib/score_calibrations.py index 392175f37..f7e7cebe4 100644 --- a/src/mavedb/lib/score_calibrations.py +++ b/src/mavedb/lib/score_calibrations.py @@ -520,7 +520,7 @@ def publish_score_calibration(db: Session, calibration: ScoreCalibration, user: This function adds the modified calibration to the session but does not commit; the caller is responsible for committing the transaction. """ - if not calibration.private: + if calibration.private is False: # It's possible None value raise ValueError("Calibration is already published.") calibration.private = False @@ -679,6 +679,12 @@ def validate_superseded_score_calibration( if not has_permission(user_data, superseded_calibration, Action.READ).permitted: raise ValueError("No access right to supersede this calibration.") + if superseded_calibration.private: + raise ValueError("Cannot supersede a private calibration. Please edit it instead.") + + if superseded_calibration.superseding_calibration: + raise ValueError("Cannot supersede a superseded calibration. Please edit it instead.") + return superseded_calibration diff --git a/src/mavedb/routers/score_sets.py b/src/mavedb/routers/score_sets.py index 349080cac..63bfcc064 100644 --- a/src/mavedb/routers/score_sets.py +++ b/src/mavedb/routers/score_sets.py @@ -602,7 +602,18 @@ async def fetch_score_set_by_urn( if item.superseding_score_set and not has_permission(user, item.superseding_score_set, Action.READ).permitted: item.superseding_score_set = None - item.score_calibrations = [sc for sc in item.score_calibrations if has_permission(user, sc, Action.READ).permitted] + visible_calibrations = [sc for sc in item.score_calibrations if has_permission(user, sc, Action.READ).permitted] + + superseded_ids = [sc.superseded_calibration_id for sc in visible_calibrations if sc.superseded_calibration_id is not None] + + available_calibrations = [sc for sc in visible_calibrations if sc.id not in superseded_ids] + + # Solve Pydantic model validation error + for sc in available_calibrations: + sc.superseded_calibration = None + sc.superseding_calibration = None + + item.score_calibrations = available_calibrations return item diff --git a/tests/lib/test_score_calibrations.py b/tests/lib/test_score_calibrations.py index 43f0b367e..d62a01508 100644 --- a/tests/lib/test_score_calibrations.py +++ b/tests/lib/test_score_calibrations.py @@ -1263,6 +1263,195 @@ async def test_publish_score_calibration_user_is_set_as_modifier( assert published_calibration.created_by == test_user +################################################################################ +# Tests for validate_superseded_score_calibration +################################################################################ + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +async def test_private_calibration_supersedes_a_published_calibration( + client, setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) + + existing_calibration = await create_test_range_based_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user_data + ) + assert existing_calibration.private is True + + published_calibration = publish_score_calibration(session, existing_calibration, test_user) + assert published_calibration.private is False + + superseding_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=published_calibration.score_set.urn, + superseded_calibration_urn=published_calibration.urn, + ) + + superseding_calibration = await create_score_calibration_in_score_set( + session, superseding_calibration_create, test_user_data + ) + + session.commit() + session.refresh(superseding_calibration) + + assert superseding_calibration.private is True + assert published_calibration.superseding_calibration.urn == superseding_calibration.urn + + score_set_db = session.execute(select(ScoreSet).where(ScoreSet.urn == superseding_calibration.score_set.urn)).scalars().first() + score_set_response = client.get(f"/api/v1/score-sets/{score_set_db.urn}") + score_set = score_set_response.json() + assert score_set_response.status_code == 200 + assert len(score_set["scoreCalibrations"]) == 1 + assert score_set["urn"] == superseding_calibration.score_set.urn + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +async def test_public_calibration_supersedes_a_published_calibration( + client, setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) + + existing_calibration = await create_test_range_based_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user_data + ) + assert existing_calibration.private is True + + published_first_calibration = publish_score_calibration(session, existing_calibration, test_user) + assert published_first_calibration.private is False + + superseding_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=published_first_calibration.score_set.urn, + superseded_calibration_urn=published_first_calibration.urn, + ) + + superseding_calibration = await create_score_calibration_in_score_set( + session, superseding_calibration_create, test_user_data + ) + published_second_calibration = publish_score_calibration(session, superseding_calibration, test_user) + assert published_second_calibration.private is False + session.commit() + session.refresh(published_second_calibration) + + assert published_first_calibration.superseding_calibration.urn == published_second_calibration.urn + + score_set_db = session.execute( + select(ScoreSet).where(ScoreSet.urn == published_second_calibration.score_set.urn)).scalars().first() + score_set_response = client.get(f"/api/v1/score-sets/{score_set_db.urn}") + score_set = score_set_response.json() + assert score_set_response.status_code == 200 + assert len(score_set["scoreCalibrations"]) == 1 + assert score_set["urn"] == published_second_calibration.score_set.urn + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +async def test_private_calibration_cannot_supersedes_a_private_calibration( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) + + existing_calibration = await create_test_range_based_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user_data + ) + assert existing_calibration.private is True + + superseding_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=existing_calibration.score_set.urn, + superseded_calibration_urn=existing_calibration.urn, + ) + + with pytest.raises(ValueError, match="Cannot supersede a private calibration. Please edit it instead."): + await create_score_calibration_in_score_set( + session, superseding_calibration_create, test_user_data + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +async def test_cannot_supersede_a_superseded_calibration( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) + + existing_calibration = await create_test_range_based_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user_data + ) + assert existing_calibration.private is True + + published_calibration = publish_score_calibration(session, existing_calibration, test_user) + assert published_calibration.private is False + + superseding_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=published_calibration.score_set.urn, + superseded_calibration_urn=published_calibration.urn, + ) + + superseding_calibration = await create_score_calibration_in_score_set( + session, superseding_calibration_create, test_user_data + ) + + session.commit() + session.refresh(superseding_calibration) + + superseding_same_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=published_calibration.score_set.urn, + superseded_calibration_urn=published_calibration.urn, + ) + + with pytest.raises(ValueError, match="Cannot supersede a superseded calibration. Please edit it instead."): + await create_score_calibration_in_score_set( + session, superseding_same_calibration_create, test_user_data + ) + + + ################################################################################ # Tests for promote_score_calibration_to_primary ################################################################################ From 1ca15c65edfe6d45848328e7a25b9f86cca76a0c Mon Sep 17 00:00:00 2001 From: Estelle Da Date: Mon, 13 Jul 2026 11:45:36 +1000 Subject: [PATCH 5/6] Debug superseding calibration functions and add a lot of new tests. --- src/mavedb/lib/score_calibrations.py | 13 +- src/mavedb/routers/score_calibrations.py | 26 +- src/mavedb/view_models/score_calibration.py | 19 +- tests/lib/test_score_calibrations.py | 87 + tests/routers/test_score_calibrations.py | 3119 ++++++++++++++++--- 5 files changed, 2902 insertions(+), 362 deletions(-) diff --git a/src/mavedb/lib/score_calibrations.py b/src/mavedb/lib/score_calibrations.py index f7e7cebe4..09148a8c3 100644 --- a/src/mavedb/lib/score_calibrations.py +++ b/src/mavedb/lib/score_calibrations.py @@ -179,7 +179,10 @@ async def _create_score_calibration( db.add(pub) db.flush() - superseded_calibration = validate_superseded_score_calibration(db, calibration_create, user_data) + if calibration_create.superseded_calibration_urn: + superseded_calibration = validate_superseded_score_calibration(db, calibration_create, user_data) + else: + superseded_calibration = None calibration = ScoreCalibration( **calibration_create.model_dump( @@ -527,6 +530,10 @@ def publish_score_calibration(db: Session, calibration: ScoreCalibration, user: calibration.modified_by = user db.add(calibration) + + if calibration.superseded_calibration and calibration.superseded_calibration.primary: + promote_score_calibration_to_primary(db, calibration, user, force=True) + return calibration @@ -559,6 +566,7 @@ def promote_score_calibration_to_primary( - If the calibration is already primary. - If the calibration is research-use-only. - If the calibration is private. + - If the calibration is superseded. - If another primary calibration exists for the score set and force is False. Side Effects: @@ -578,6 +586,9 @@ def promote_score_calibration_to_primary( if calibration.private: raise ValueError("Cannot promote a private calibration to primary.") + if calibration.superseding_calibration: + raise ValueError("Cannot promote a superseded calibration to primary.") + existing_primary_calibrations = ( db.query(ScoreCalibration) .filter( diff --git a/src/mavedb/routers/score_calibrations.py b/src/mavedb/routers/score_calibrations.py index f55bb8871..424c856d1 100644 --- a/src/mavedb/routers/score_calibrations.py +++ b/src/mavedb/routers/score_calibrations.py @@ -137,9 +137,20 @@ async def get_score_calibrations_for_score_set( .all() ) - permitted_calibrations = [ + visible_calibrations = [ calibration for calibration in calibrations if has_permission(user_data, calibration, Action.READ).permitted ] + + superseded_ids = [sc.superseded_calibration_id for sc in visible_calibrations if + sc.superseded_calibration_id is not None] + + permitted_calibrations = [sc for sc in visible_calibrations if sc.id not in superseded_ids] + + # Solve Pydantic model validation error + for sc in permitted_calibrations: + sc.superseded_calibration = None + sc.superseding_calibration = None + if not permitted_calibrations: logger.debug("No score calibrations found for the requested score set", extra=logging_context()) raise HTTPException(status_code=404, detail="No score calibrations found for the requested score set") @@ -339,9 +350,12 @@ async def create_score_calibration_route( detail=[{"loc": [e.custom_loc or "classesFile"], "msg": str(e), "type": "value_error"}], ) - created_calibration = await create_score_calibration_in_score_set( - db, calibration, user_data, variant_classes if classes_file else None - ) + try: + created_calibration = await create_score_calibration_in_score_set( + db, calibration, user_data, variant_classes if classes_file else None + ) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) db.commit() db.refresh(created_calibration) @@ -599,6 +613,10 @@ async def promote_score_calibration_to_primary_route( logger.debug("Private score calibrations cannot be promoted to primary", extra=logging_context()) raise HTTPException(status_code=400, detail="Private score calibrations cannot be promoted to primary") + if item.superseding_calibration: + logger.debug("Superseded score calibrations cannot be promoted to primary", extra=logging_context()) + raise HTTPException(status_code=400, detail="Superseded score calibrations cannot be promoted to primary") + # We've already checked whether the item matching the calibration URN is primary, so this # will necessarily be a different calibration, if it exists. existing_primary_calibration = next((c for c in item.score_set.score_calibrations if c.primary), None) diff --git a/src/mavedb/view_models/score_calibration.py b/src/mavedb/view_models/score_calibration.py index 06e12885b..e61942ebf 100644 --- a/src/mavedb/view_models/score_calibration.py +++ b/src/mavedb/view_models/score_calibration.py @@ -437,6 +437,9 @@ class ScoreCalibrationModify(ScoreCalibrationBase): evidence_sources: Sequence[PublicationIdentifierCreate] method_sources: Sequence[PublicationIdentifierCreate] + class Config: + extra = "forbid" + # TODO#668: Move this validator to ScoreCalibrationBase once legacy calibrations have been # backfilled with publication associations. Currently on the write model only so that existing # calibrations without publications can still be serialized for API read responses. @@ -497,6 +500,18 @@ def validate_superseded_calibration_urn(cls, v: Optional[str]) -> Optional[str]: return v + +class ShorterScoreCalibration(BaseModel): + urn: str + record_type: str = None # type: ignore + + _record_type_factory = record_type_validator()(set_record_type) + + class Config: + from_attributes = True + arbitrary_types_allowed = True + + class SavedScoreCalibration(ScoreCalibrationBase): """Persisted score calibration model (includes identifiers and source lists).""" @@ -515,8 +530,8 @@ class SavedScoreCalibration(ScoreCalibrationBase): threshold_sources: Sequence[SavedPublicationIdentifier] evidence_sources: Sequence[SavedPublicationIdentifier] method_sources: Sequence[SavedPublicationIdentifier] - superseded_calibration: Optional[ScoreCalibrationBase] = None - superseding_calibration: Optional[ScoreCalibrationBase] = None + superseded_calibration: Optional[ShorterScoreCalibration] = None + superseding_calibration: Optional[ShorterScoreCalibration] = None created_by: Optional[SavedUser] = None modified_by: Optional[SavedUser] = None diff --git a/tests/lib/test_score_calibrations.py b/tests/lib/test_score_calibrations.py index d62a01508..ccabb3ba4 100644 --- a/tests/lib/test_score_calibrations.py +++ b/tests/lib/test_score_calibrations.py @@ -1367,6 +1367,93 @@ async def test_public_calibration_supersedes_a_published_calibration( assert score_set["urn"] == published_second_calibration.score_set.urn +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +async def test_supersede_a_calibration_that_its_score_set_has_multiple_calibrations( + client, setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) + + existing_calibration_1 = await create_test_range_based_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user_data + ) + assert existing_calibration_1.private is True + existing_calibration_2 = await create_test_range_based_score_calibration_in_score_set( + session, existing_calibration_1.score_set.urn, test_user_data + ) + assert existing_calibration_2.private is True + + published_first_calibration = publish_score_calibration(session, existing_calibration_1, test_user) + assert published_first_calibration.private is False + + superseding_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=published_first_calibration.score_set.urn, + superseded_calibration_urn=published_first_calibration.urn, + ) + + superseding_calibration = await create_score_calibration_in_score_set( + session, superseding_calibration_create, test_user_data + ) + session.commit() + session.refresh(superseding_calibration) + + assert published_first_calibration.superseding_calibration.urn == superseding_calibration.urn + + score_set_db = session.execute( + select(ScoreSet).where(ScoreSet.urn == superseding_calibration.score_set.urn)).scalars().first() + score_set_response = client.get(f"/api/v1/score-sets/{score_set_db.urn}") + score_set = score_set_response.json() + assert score_set_response.status_code == 200 + assert len(score_set["scoreCalibrations"]) == 2 + cal_urns = {cal["urn"] for cal in score_set["scoreCalibrations"]} + + assert superseding_calibration.urn in cal_urns + assert existing_calibration_2.urn in cal_urns + assert published_first_calibration.urn not in cal_urns + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +async def test_cannot_supersede_none_exist_calibration( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) + + invalid_urn = "urn:mavedb:calibration-00000000-0000-0000-0000-000000000000" + + superseding_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=setup_lib_db_with_score_set.urn, + superseded_calibration_urn=invalid_urn, + ) + + with pytest.raises(ValueError, match="Superseded calibration does not exist."): + await create_score_calibration_in_score_set( + session, superseding_calibration_create, test_user_data + ) + + @pytest.mark.asyncio @pytest.mark.parametrize( "mock_publication_fetch", diff --git a/tests/routers/test_score_calibrations.py b/tests/routers/test_score_calibrations.py index 9f9be8391..f4dafe971 100644 --- a/tests/routers/test_score_calibrations.py +++ b/tests/routers/test_score_calibrations.py @@ -459,23 +459,19 @@ def test_admin_user_can_get_score_calibration_when_public( assert calibration_response["private"] is False -########################################################### -# GET /score-calibrations/score-set/{score_set_urn} -########################################################### - - +@pytest.mark.asyncio @pytest.mark.parametrize( "mock_publication_fetch", [ [ - {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, - {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, ] ], indirect=["mock_publication_fetch"], ) -def test_cannot_get_score_calibrations_for_score_set_when_none_exist( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +async def test_anonymous_user_cannot_get_superseding_score_calibration_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -485,45 +481,28 @@ def test_cannot_get_score_calibrations_for_score_set_when_none_exist( experiment["urn"], data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") - - assert response.status_code == 404 - error = response.json() - assert "No score calibrations found for the requested score set" in error["detail"] - + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } -@pytest.mark.parametrize( - "mock_publication_fetch", - [ - [ - {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, - {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, - ] - ], - indirect=["mock_publication_fetch"], -) -def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_private( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides -): - experiment = create_experiment(client) - score_set = create_seq_score_set_with_mapped_variants( + superseding_calibration = create_test_score_calibration_in_score_set_via_client( client, - session, - data_provider, - experiment["urn"], - data_files / "scores.csv", - ) - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + score_set["urn"], + deepcamelize(superseding_calibration_data), ) with DependencyOverrider(anonymous_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{superseding_calibration['urn']}") assert response.status_code == 404 error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert f"score calibration with URN '{superseding_calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -536,7 +515,7 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_private ], indirect=["mock_publication_fetch"], ) -def test_other_user_cannot_get_score_calibrations_for_score_set_when_private( +def test_other_user_cannot_get_superseding_score_calibration_when_private( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -547,16 +526,29 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_private( experiment["urn"], data_files / "scores.csv", ) - create_test_score_calibration_in_score_set_via_client( + calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + with DependencyOverrider(extra_user_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{superseding_calibration['urn']}") assert response.status_code == 404 error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert f"score calibration with URN '{superseding_calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -569,8 +561,8 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_private( ], indirect=["mock_publication_fetch"], ) -def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_published_but_calibrations_private( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +def test_creating_user_can_get_superseding_score_calibration_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -580,19 +572,29 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_publish experiment["urn"], data_files / "scores.csv", ) - create_test_score_calibration_in_score_set_via_client( + calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) - with patch.object(ArqRedis, "enqueue_job", return_value=None): - score_set = publish_score_set(client, score_set["urn"]) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - with DependencyOverrider(anonymous_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } - assert response.status_code == 404 - error = response.json() - assert "No score calibrations found for the requested score set" in error["detail"] + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + response = client.get(f"/api/v1/score-calibrations/{superseding_calibration['urn']}") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == superseding_calibration["urn"] + assert calibration_response["private"] is True @pytest.mark.parametrize( @@ -605,7 +607,7 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_publish ], indirect=["mock_publication_fetch"], ) -def test_other_user_cannot_get_score_calibrations_for_score_set_when_published_but_calibrations_private( +def test_contributing_user_can_get_superseding_score_calibration_when_private_and_investigator_provided( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -616,53 +618,38 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_published_b experiment["urn"], data_files / "scores.csv", ) - create_test_score_calibration_in_score_set_via_client( + calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - with patch.object(ArqRedis, "enqueue_job", return_value=None): - score_set = publish_score_set(client, score_set["urn"]) - - with DependencyOverrider(extra_user_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") - - assert response.status_code == 404 - error = response.json() - assert "No score calibrations found for the requested score set" in error["detail"] - + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } -@pytest.mark.parametrize( - "mock_publication_fetch", - [ - [ - {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, - {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, - ] - ], - indirect=["mock_publication_fetch"], -) -def test_creating_user_can_get_score_calibrations_for_score_set_when_private( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files -): - experiment = create_experiment(client) - score_set = create_seq_score_set_with_mapped_variants( + superseding_calibration = create_test_score_calibration_in_score_set_via_client( client, - session, - data_provider, - experiment["urn"], - data_files / "scores.csv", + score_set["urn"], + deepcamelize(superseding_calibration_data), ) - calibration = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + + add_contributor( + session, + score_set["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], ) - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/{superseding_calibration['urn']}") assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 1 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is True + calibration_response = response.json() + assert calibration_response["urn"] == superseding_calibration["urn"] + assert calibration_response["private"] is True @pytest.mark.parametrize( @@ -675,7 +662,7 @@ def test_creating_user_can_get_score_calibrations_for_score_set_when_private( ], indirect=["mock_publication_fetch"], ) -def test_contributing_user_can_get_investigator_provided_score_calibrations_for_score_set_when_private( +def test_contributing_user_cannot_get_superseding_score_calibration_when_private_and_not_investigator_provided( client, setup_router_db, mock_publication_fetch, @@ -694,14 +681,22 @@ def test_contributing_user_can_get_investigator_provided_score_calibrations_for_ data_files / "scores.csv", ) - with DependencyOverrider(admin_app_overrides): - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - - investigator_calibration = create_test_score_calibration_in_score_set_via_client( + calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + with DependencyOverrider(admin_app_overrides): + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) add_contributor( session, @@ -713,13 +708,11 @@ def test_contributing_user_can_get_investigator_provided_score_calibrations_for_ ) with DependencyOverrider(extra_user_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{superseding_calibration['urn']}") - assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 1 - assert calibrations_response[0]["urn"] == investigator_calibration["urn"] - assert calibrations_response[0]["private"] is True + assert response.status_code == 404 + error = response.json() + assert f"score calibration with URN '{superseding_calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -732,7 +725,7 @@ def test_contributing_user_can_get_investigator_provided_score_calibrations_for_ ], indirect=["mock_publication_fetch"], ) -def test_admin_user_can_get_score_calibrations_for_score_set_when_private( +def test_admin_user_can_get_superseding_score_calibration_when_private( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides ): experiment = create_experiment(client) @@ -746,28 +739,38 @@ def test_admin_user_can_get_score_calibrations_for_score_set_when_private( calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) with DependencyOverrider(admin_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{superseding_calibration['urn']}") assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 1 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is True + calibration_response = response.json() + assert calibration_response["urn"] == superseding_calibration["urn"] + assert calibration_response["private"] is True +@pytest.mark.asyncio @pytest.mark.parametrize( "mock_publication_fetch", [ [ - {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, - {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, ] ], indirect=["mock_publication_fetch"], ) -def test_anonymous_user_can_get_score_calibrations_for_score_set_when_public( +async def test_anonymous_user_can_get_superseding_score_calibration_when_public( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides ): experiment = create_experiment(client) @@ -781,25 +784,27 @@ def test_anonymous_user_can_get_score_calibrations_for_score_set_when_public( calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that will remain private. The anonymous user should not see this one - calibration = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - - publish_test_score_calibration_via_client(client, calibration["urn"]) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } - with patch.object(ArqRedis, "enqueue_job", return_value=None): - score_set = publish_score_set(client, score_set["urn"]) + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) with DependencyOverrider(anonymous_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}") assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 1 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is False + calibration_response = response.json() + assert calibration_response["urn"] == published_superseding_calibration["urn"] + assert calibration_response["private"] is False @pytest.mark.parametrize( @@ -812,7 +817,7 @@ def test_anonymous_user_can_get_score_calibrations_for_score_set_when_public( ], indirect=["mock_publication_fetch"], ) -def test_other_user_can_get_score_calibrations_for_score_set_when_public( +def test_other_user_can_get_superseding_score_calibration_when_public( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -826,25 +831,27 @@ def test_other_user_can_get_score_calibrations_for_score_set_when_public( calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that will remain private. The other user should not see this one - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - - publish_test_score_calibration_via_client(client, calibration["urn"]) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } - with patch.object(ArqRedis, "enqueue_job", return_value=None): - score_set = publish_score_set(client, score_set["urn"]) + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) with DependencyOverrider(extra_user_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}") assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 1 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is False + calibration_response = response.json() + assert calibration_response["urn"] == published_superseding_calibration["urn"] + assert calibration_response["private"] is False @pytest.mark.parametrize( @@ -857,8 +864,8 @@ def test_other_user_can_get_score_calibrations_for_score_set_when_public( ], indirect=["mock_publication_fetch"], ) -def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_calibrations_public_score_set_private( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +def test_creating_user_can_get_superseding_score_calibration_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -871,20 +878,26 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_calibra calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that will remain private. The anonymous user should not see this one - calibration = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } - publish_test_score_calibration_via_client(client, calibration["urn"]) + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) - with DependencyOverrider(anonymous_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}") - assert response.status_code == 404 - error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == published_superseding_calibration["urn"] + assert calibration_response["private"] is False @pytest.mark.parametrize( @@ -897,7 +910,7 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_calibra ], indirect=["mock_publication_fetch"], ) -def test_other_user_cannot_get_score_calibrations_for_score_set_when_calibrations_public_score_set_private( +def test_contributing_user_can_get_superseding_score_calibration_when_public( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -911,20 +924,36 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_calibration calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that will remain private. The other user should not see this one - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) - publish_test_score_calibration_via_client(client, calibration["urn"]) + add_contributor( + session, + score_set["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) with DependencyOverrider(extra_user_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}") - assert response.status_code == 404 - error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == published_superseding_calibration["urn"] + assert calibration_response["private"] is False @pytest.mark.parametrize( @@ -937,8 +966,8 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_calibration ], indirect=["mock_publication_fetch"], ) -def test_creating_user_can_get_score_calibrations_for_score_set_when_public( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +def test_admin_user_can_get_superseding_score_calibration_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -951,27 +980,94 @@ def test_creating_user_can_get_score_calibrations_for_score_set_when_public( calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) - publish_test_score_calibration_via_client(client, calibration["urn"]) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that is private. The creating user should see this one too - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + with DependencyOverrider(admin_app_overrides): + response = client.get(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}") assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 2 - # The order is different from before. - created_calibration = next( - c for c in calibrations_response if c["urn"] == calibration["urn"] + calibration_response = response.json() + assert calibration_response["urn"] == published_superseding_calibration["urn"] + assert calibration_response["private"] is False + + +########################################################### +# GET /score-calibrations/score-set/{score_set_urn} +########################################################### + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_get_score_calibrations_for_score_set_when_none_exist( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", ) - assert created_calibration["private"] is False - other_calibration = next( - c for c in calibrations_response if c["urn"] != calibration["urn"] + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert "No score calibrations found for the requested score set" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", ) - assert other_calibration["private"] is True + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -984,7 +1080,7 @@ def test_creating_user_can_get_score_calibrations_for_score_set_when_public( ], indirect=["mock_publication_fetch"], ) -def test_contributing_user_can_get_score_calibrations_for_score_set_when_public( +def test_other_user_cannot_get_score_calibrations_for_score_set_when_private( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -995,16 +1091,162 @@ def test_contributing_user_can_get_score_calibrations_for_score_set_when_public( experiment["urn"], data_files / "scores.csv", ) - calibration = create_test_score_calibration_in_score_set_via_client( + create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) - publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that is private. The contributing user should see this one too + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_published_but_calibrations_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert "No score calibrations found for the requested score set" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_cannot_get_score_calibrations_for_score_set_when_published_but_calibrations_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert "No score calibrations found for the requested score set" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_creating_user_can_get_score_calibrations_for_score_set_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == calibration["urn"] + assert calibrations_response[0]["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_contributing_user_can_get_investigator_provided_score_calibrations_for_score_set_when_private( + client, + setup_router_db, + mock_publication_fetch, + session, + data_provider, + data_files, + extra_user_app_overrides, + admin_app_overrides, +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + with DependencyOverrider(admin_app_overrides): + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + investigator_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + add_contributor( session, score_set["urn"], @@ -1014,16 +1256,1601 @@ def test_contributing_user_can_get_score_calibrations_for_score_set_when_public( EXTRA_USER["last_name"], ) - with DependencyOverrider(extra_user_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == investigator_calibration["urn"] + assert calibrations_response[0]["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_admin_user_can_get_score_calibrations_for_score_set_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + with DependencyOverrider(admin_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == calibration["urn"] + assert calibrations_response[0]["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_can_get_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The anonymous user should not see this one + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == calibration["urn"] + assert calibrations_response[0]["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_can_get_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The other user should not see this one + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == calibration["urn"] + assert calibrations_response[0]["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_calibrations_public_score_set_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The anonymous user should not see this one + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_cannot_get_score_calibrations_for_score_set_when_calibrations_public_score_set_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The other user should not see this one + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_creating_user_can_get_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + + # add another calibration that is private. The creating user should see this one too + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 2 + # The order is different from before. + created_calibration = next( + c for c in calibrations_response if c["urn"] == calibration["urn"] + ) + assert created_calibration["private"] is False + other_calibration = next( + c for c in calibrations_response if c["urn"] != calibration["urn"] + ) + assert other_calibration["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_contributing_user_can_get_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + + # add another calibration that is private. The contributing user should see this one too + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + add_contributor( + session, + score_set["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 2 + created_calibration = next( + c for c in calibrations_response if c["urn"] == calibration["urn"] + ) + assert created_calibration["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_admin_user_can_get_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + + # add another calibration that is private. The admin user should see this one too + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + with DependencyOverrider(admin_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 2 + created_calibration = next( + c for c in calibrations_response if c["urn"] == calibration["urn"] + ) + assert created_calibration["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_cannot_get_superseding_score_calibrations_for_score_set_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_cannot_get_superseding_score_calibrations_for_score_set_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_can_get_superseded_score_calibrations_for_score_set_when_published_but_superseding_calibrations_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == published_calibration["urn"] + assert calibrations_response[0]["urn"] != superseding_calibration["urn"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_can_get_superseded_score_calibrations_for_score_set_when_published_but_superseding_calibrations_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == published_calibration["urn"] + assert calibrations_response[0]["urn"] != superseding_calibration["urn"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_creating_user_can_get_superseding_score_calibrations_for_score_set_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == superseding_calibration["urn"] + assert calibrations_response[0]["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_contributing_user_can_get_investigator_provided_superseding_score_calibrations_for_score_set_when_private( + client, + setup_router_db, + mock_publication_fetch, + session, + data_provider, + data_files, + extra_user_app_overrides, + admin_app_overrides, +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + with DependencyOverrider(admin_app_overrides): + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + investigator_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, investigator_calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + add_contributor( + session, + score_set["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == superseding_calibration["urn"] + assert calibrations_response[0]["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_admin_user_can_get_superseding_score_calibrations_for_score_set_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + with DependencyOverrider(admin_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == superseding_calibration["urn"] + assert calibrations_response[0]["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_can_get_superseding_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + private_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The anonymous user should not see this one + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == published_superseding_calibration["urn"] + assert calibrations_response[0]["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_can_get_superseding_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + # add another calibration that will remain private. The other user should not see this one + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == published_superseding_calibration["urn"] + assert calibrations_response[0]["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_cannot_get_superseding_score_calibrations_for_score_set_when_calibrations_public_score_set_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + private_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The anonymous user should not see this one + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_cannot_get_superseding_score_calibrations_for_score_set_when_calibrations_public_score_set_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The other user should not see this one + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_creating_user_can_get_superseding_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + # add another calibration that is private. The creating user should see this one too + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 2 + assert calibrations_response[0]["urn"] == superseding_calibration["urn"] + assert calibrations_response[0]["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_contributing_user_can_get_superseding_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + # add another calibration that is private. The contributing user should see this one too + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + add_contributor( + session, + score_set["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 2 + assert calibrations_response[0]["urn"] == superseding_calibration["urn"] + assert calibrations_response[0]["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_admin_user_can_get_superseding_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + # add another calibration that is private. The admin user should see this one too + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + with DependencyOverrider(admin_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 2 + assert calibrations_response[0]["urn"] == superseding_calibration["urn"] + assert calibrations_response[0]["private"] is False + + +########################################################### +# GET /score-calibrations/score-set/{score_set_urn}/primary +########################################################### + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_get_primary_score_calibration_for_score_set_when_no_calibrations_exist( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + + assert response.status_code == 404 + error = response.json() + assert "No primary score calibrations found for the requested score set" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_get_primary_score_calibration_for_score_set_when_none_exist( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + + assert response.status_code == 404 + error = response.json() + assert "No primary score calibrations found for the requested score set" in error["detail"] + + +# primary calibrations may not be private, so no need to test different user roles + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_get_primary_score_calibration_for_score_set_when_exists( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["private"] is False + + +# TODO#544: Business logic on view models should prevent this case from arising in production, but it could occur if the database +# were sloppily edited directly. +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_get_primary_score_calibration_for_score_set_when_multiple_exist( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + calibration2 = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + publish_test_score_calibration_via_client(client, calibration2["urn"]) + + second_primary = session.execute( + select(CalibrationDbModel).where(CalibrationDbModel.urn == calibration2["urn"]) + ).scalar_one() + second_primary.primary = True + session.add(second_primary) + session.commit() + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + + assert response.status_code == 500 + error = response.json() + assert "Multiple primary score calibrations found for the requested score set" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_get_superseding_primary_score_calibration_for_score_set_when_exists( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": calibration["urn"], + } + + superseding_calibration = create_publish_and_promote_score_calibration( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == superseding_calibration["urn"] + assert calibration_response["private"] is False + + +########################################################### +# POST /score-calibrations +########################################################### + + +def test_cannot_create_score_calibration_when_missing_score_set_urn(client, setup_router_db): + response = client.post( + "/api/v1/score-calibrations", + json={**deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)}, + ) + + assert response.status_code == 422 + error = response.json() + assert "score_set_urn must be provided to create a score calibration" in str(error["detail"]) + + +def test_cannot_create_score_calibration_when_score_set_does_not_exist(client, setup_router_db): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": "urn:ngs:score-set:nonexistent", + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, + ) + + assert response.status_code == 404 + error = response.json() + assert "score set with URN 'urn:ngs:score-set:nonexistent' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_score_calibration_when_csv_file_fails_decoding( + client, setup_router_db, session, data_provider, data_files, mock_publication_fetch +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + calibration_csv_path = data_files / "calibration_classes_by_urn.csv" + with ( + open(calibration_csv_path, "rb") as class_file, + patch( + "mavedb.routers.score_calibrations.csv_data_to_df", + side_effect=UnicodeDecodeError("utf-8", b"", 0, 1, "invalid start byte"), + ), + ): + response = client.post( + "/api/v1/score-calibrations", + files={"classes_file": (calibration_csv_path.name, class_file, "text/csv")}, + data={ + "calibration_json": json.dumps( + {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} + ), + }, + ) + + assert response.status_code == 400 + error = response.json() + assert "Error decoding file:" in str(error["detail"]) + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_score_calibration_when_validation_error_is_raised_from_score_calibration_file_standardization( + client, setup_router_db, session, data_provider, data_files, mock_publication_fetch +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + calibration_csv_path = data_files / "calibration_classes_by_urn.csv" + with ( + open(calibration_csv_path, "rb") as class_file, + patch( + "mavedb.routers.score_calibrations.validate_and_standardize_calibration_classes_dataframe", + side_effect=ValidationError("Test validation error"), + ), + ): + response = client.post( + "/api/v1/score-calibrations", + files={"classes_file": (calibration_csv_path.name, class_file, "text/csv")}, + data={ + "calibration_json": json.dumps( + {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} + ), + }, + ) + + assert response.status_code == 422 + error = response.json() + assert "Test validation error" in str(error["detail"][0]["msg"]) + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_score_calibration_when_score_set_not_owned_by_user( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, + ) + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_can_create_score_calibration_in_public_score_set_as_non_contributor( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, + ) + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["investigatorProvided"] is False + assert calibration_response["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_class_based_score_calibration_without_classes_file( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED), + }, + ) + + assert response.status_code == 422 + error = response.json() + assert "A classes_file must be provided when creating a class-based calibration" in str(error["detail"]) + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +@pytest.mark.parametrize( + "calibration_csv_path", + ["calibration_classes_by_urn.csv", "calibration_classes_by_hgvs_nt.csv", "calibration_classes_by_hgvs_prot.csv"], +) +def test_cannot_create_range_based_score_calibration_with_classes_file( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, calibration_csv_path +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + classification_csv_path = data_files / calibration_csv_path + with open(classification_csv_path, "rb") as class_file: + response = client.post( + "/api/v1/score-calibrations", + files={"classes_file": (classification_csv_path.name, class_file, "text/csv")}, + data={ + "calibration_json": json.dumps( + {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} + ), + }, + ) + + assert response.status_code == 422 + error = response.json() + assert "A classes_file should not be provided when creating a range-based calibration" in str(error["detail"]) + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_score_calibration_as_anonymous_user( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + with DependencyOverrider(anonymous_app_overrides): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, + ) - assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 2 - created_calibration = next( - c for c in calibrations_response if c["urn"] == calibration["urn"] - ) - assert created_calibration["private"] is False + assert response.status_code == 401 + error = response.json() + assert "Could not validate credentials" in error["detail"] @pytest.mark.parametrize( @@ -1036,8 +2863,8 @@ def test_contributing_user_can_get_score_calibrations_for_score_set_when_public( ], indirect=["mock_publication_fetch"], ) -def test_admin_user_can_get_score_calibrations_for_score_set_when_public( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides +def test_can_create_score_calibration_as_score_set_owner( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -1047,31 +2874,19 @@ def test_admin_user_can_get_score_calibrations_for_score_set_when_public( experiment["urn"], data_files / "scores.csv", ) - calibration = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that is private. The admin user should see this one too - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, ) - with DependencyOverrider(admin_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") - assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 2 - created_calibration = next( - c for c in calibrations_response if c["urn"] == calibration["urn"] - ) - assert created_calibration["private"] is False - - -########################################################### -# GET /score-calibrations/score-set/{score_set_urn}/primary -########################################################### + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True @pytest.mark.parametrize( @@ -1084,7 +2899,7 @@ def test_admin_user_can_get_score_calibrations_for_score_set_when_public( ], indirect=["mock_publication_fetch"], ) -def test_cannot_get_primary_score_calibration_for_score_set_when_no_calibrations_exist( +def test_can_create_score_calibration_as_score_set_owner_form( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) @@ -1096,11 +2911,19 @@ def test_cannot_get_primary_score_calibration_for_score_set_when_no_calibrations data_files / "scores.csv", ) - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + response = client.post( + "/api/v1/score-calibrations", + data={ + "calibration_json": json.dumps( + {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} + ), + }, + ) - assert response.status_code == 404 - error = response.json() - assert "No primary score calibrations found for the requested score set" in error["detail"] + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True @pytest.mark.parametrize( @@ -1113,8 +2936,8 @@ def test_cannot_get_primary_score_calibration_for_score_set_when_no_calibrations ], indirect=["mock_publication_fetch"], ) -def test_cannot_get_primary_score_calibration_for_score_set_when_none_exist( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +def test_can_create_score_calibration_as_score_set_contributor( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -1124,18 +2947,29 @@ def test_cannot_get_primary_score_calibration_for_score_set_when_none_exist( experiment["urn"], data_files / "scores.csv", ) - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") - assert response.status_code == 404 - error = response.json() - assert "No primary score calibrations found for the requested score set" in error["detail"] + add_contributor( + session, + score_set["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, + ) -# primary calibrations may not be private, so no need to test different user roles + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True @pytest.mark.parametrize( @@ -1148,8 +2982,8 @@ def test_cannot_get_primary_score_calibration_for_score_set_when_none_exist( ], indirect=["mock_publication_fetch"], ) -def test_get_primary_score_calibration_for_score_set_when_exists( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +def test_can_create_score_calibration_as_admin_user( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -1159,20 +2993,22 @@ def test_get_primary_score_calibration_for_score_set_when_exists( experiment["urn"], data_files / "scores.csv", ) - calibration = create_publish_and_promote_score_calibration( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + with DependencyOverrider(admin_app_overrides): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, + ) assert response.status_code == 200 calibration_response = response.json() - assert calibration_response["urn"] == calibration["urn"] - assert calibration_response["private"] is False + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True -# TODO#544: Business logic on view models should prevent this case from arising in production, but it could occur if the database -# were sloppily edited directly. @pytest.mark.parametrize( "mock_publication_fetch", [ @@ -1183,8 +3019,12 @@ def test_get_primary_score_calibration_for_score_set_when_exists( ], indirect=["mock_publication_fetch"], ) -def test_get_primary_score_calibration_for_score_set_when_multiple_exist( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +@pytest.mark.parametrize( + "calibration_csv_path", + ["calibration_classes_by_urn.csv", "calibration_classes_by_hgvs_nt.csv", "calibration_classes_by_hgvs_prot.csv"], +) +def test_can_create_class_based_score_calibration_form( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, calibration_csv_path ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -1194,58 +3034,29 @@ def test_get_primary_score_calibration_for_score_set_when_multiple_exist( experiment["urn"], data_files / "scores.csv", ) + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) - create_publish_and_promote_score_calibration( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - calibration2 = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - publish_test_score_calibration_via_client(client, calibration2["urn"]) - - second_primary = session.execute( - select(CalibrationDbModel).where(CalibrationDbModel.urn == calibration2["urn"]) - ).scalar_one() - second_primary.primary = True - session.add(second_primary) - session.commit() - - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") - - assert response.status_code == 500 - error = response.json() - assert "Multiple primary score calibrations found for the requested score set" in error["detail"] - - -########################################################### -# POST /score-calibrations -########################################################### - - -def test_cannot_create_score_calibration_when_missing_score_set_urn(client, setup_router_db): - response = client.post( - "/api/v1/score-calibrations", - json={**deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)}, - ) - - assert response.status_code == 422 - error = response.json() - assert "score_set_urn must be provided to create a score calibration" in str(error["detail"]) - + classification_csv_path = data_files / calibration_csv_path + with open(classification_csv_path, "rb") as class_file: + response = client.post( + "/api/v1/score-calibrations", + files={"classes_file": (classification_csv_path.name, class_file, "text/csv")}, + data={ + "calibration_json": json.dumps( + {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} + ), + }, + ) -def test_cannot_create_score_calibration_when_score_set_does_not_exist(client, setup_router_db): - response = client.post( - "/api/v1/score-calibrations", - json={ - "scoreSetUrn": "urn:ngs:score-set:nonexistent", - **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), - }, + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True + assert all( + classification["variantCount"] == 1 for classification in calibration_response["functionalClassifications"] ) - assert response.status_code == 404 - error = response.json() - assert "score set with URN 'urn:ngs:score-set:nonexistent' not found" in error["detail"] - @pytest.mark.parametrize( "mock_publication_fetch", @@ -1257,7 +3068,7 @@ def test_cannot_create_score_calibration_when_score_set_does_not_exist(client, s ], indirect=["mock_publication_fetch"], ) -def test_cannot_create_score_calibration_when_csv_file_fails_decoding( +def test_cannot_create_superseding_score_calibration_when_csv_file_fails_decoding( client, setup_router_db, session, data_provider, data_files, mock_publication_fetch ): experiment = create_experiment(client) @@ -1269,9 +3080,14 @@ def test_cannot_create_score_calibration_when_csv_file_fails_decoding( data_files / "scores.csv", ) - calibration_csv_path = data_files / "calibration_classes_by_urn.csv" + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_csv_path = data_files / "calibration_classes_by_urn.csv" with ( - open(calibration_csv_path, "rb") as class_file, + open(superseding_calibration_csv_path, "rb") as class_file, patch( "mavedb.routers.score_calibrations.csv_data_to_df", side_effect=UnicodeDecodeError("utf-8", b"", 0, 1, "invalid start byte"), @@ -1279,10 +3095,14 @@ def test_cannot_create_score_calibration_when_csv_file_fails_decoding( ): response = client.post( "/api/v1/score-calibrations", - files={"classes_file": (calibration_csv_path.name, class_file, "text/csv")}, + files={"classes_file": (superseding_calibration_csv_path.name, class_file, "text/csv")}, data={ "calibration_json": json.dumps( - {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], + **TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED, + } ), }, ) @@ -1302,7 +3122,7 @@ def test_cannot_create_score_calibration_when_csv_file_fails_decoding( ], indirect=["mock_publication_fetch"], ) -def test_cannot_create_score_calibration_when_validation_error_is_raised_from_score_calibration_file_standardization( +def test_cannot_create_superseding_score_calibration_when_validation_error_is_raised_from_score_calibration_file_standardization( client, setup_router_db, session, data_provider, data_files, mock_publication_fetch ): experiment = create_experiment(client) @@ -1314,9 +3134,14 @@ def test_cannot_create_score_calibration_when_validation_error_is_raised_from_sc data_files / "scores.csv", ) - calibration_csv_path = data_files / "calibration_classes_by_urn.csv" + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_csv_path = data_files / "calibration_classes_by_urn.csv" with ( - open(calibration_csv_path, "rb") as class_file, + open(superseding_calibration_csv_path, "rb") as class_file, patch( "mavedb.routers.score_calibrations.validate_and_standardize_calibration_classes_dataframe", side_effect=ValidationError("Test validation error"), @@ -1324,10 +3149,13 @@ def test_cannot_create_score_calibration_when_validation_error_is_raised_from_sc ): response = client.post( "/api/v1/score-calibrations", - files={"classes_file": (calibration_csv_path.name, class_file, "text/csv")}, + files={"classes_file": (superseding_calibration_csv_path.name, class_file, "text/csv")}, data={ "calibration_json": json.dumps( - {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} ), }, ) @@ -1347,7 +3175,7 @@ def test_cannot_create_score_calibration_when_validation_error_is_raised_from_sc ], indirect=["mock_publication_fetch"], ) -def test_cannot_create_score_calibration_when_score_set_not_owned_by_user( +def test_cannot_create_superseding_score_calibration_when_score_set_not_owned_by_user( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -1359,11 +3187,17 @@ def test_cannot_create_score_calibration_when_score_set_not_owned_by_user( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + with DependencyOverrider(extra_user_app_overrides): response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), }, ) @@ -1383,7 +3217,7 @@ def test_cannot_create_score_calibration_when_score_set_not_owned_by_user( ], indirect=["mock_publication_fetch"], ) -def test_can_create_score_calibration_in_public_score_set_as_non_contributor( +def test_can_create_superseding_score_calibration_in_public_score_set_as_non_contributor( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -1398,11 +3232,17 @@ def test_can_create_score_calibration_in_public_score_set_as_non_contributor( with patch.object(ArqRedis, "enqueue_job", return_value=None): score_set = publish_score_set(client, score_set["urn"]) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + with DependencyOverrider(extra_user_app_overrides): response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), }, ) @@ -1412,6 +3252,7 @@ def test_can_create_score_calibration_in_public_score_set_as_non_contributor( assert calibration_response["scoreSetUrn"] == score_set["urn"] assert calibration_response["investigatorProvided"] is False assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] @pytest.mark.parametrize( @@ -1424,7 +3265,7 @@ def test_can_create_score_calibration_in_public_score_set_as_non_contributor( ], indirect=["mock_publication_fetch"], ) -def test_cannot_create_class_based_score_calibration_without_classes_file( +def test_cannot_create_superseding_class_based_score_calibration_without_classes_file( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) @@ -1436,10 +3277,16 @@ def test_cannot_create_class_based_score_calibration_without_classes_file( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED), }, ) @@ -1463,7 +3310,7 @@ def test_cannot_create_class_based_score_calibration_without_classes_file( "calibration_csv_path", ["calibration_classes_by_urn.csv", "calibration_classes_by_hgvs_nt.csv", "calibration_classes_by_hgvs_prot.csv"], ) -def test_cannot_create_range_based_score_calibration_with_classes_file( +def test_cannot_create_superseding_range_based_score_calibration_with_classes_file( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, calibration_csv_path ): experiment = create_experiment(client) @@ -1475,6 +3322,11 @@ def test_cannot_create_range_based_score_calibration_with_classes_file( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + classification_csv_path = data_files / calibration_csv_path with open(classification_csv_path, "rb") as class_file: response = client.post( @@ -1482,7 +3334,10 @@ def test_cannot_create_range_based_score_calibration_with_classes_file( files={"classes_file": (classification_csv_path.name, class_file, "text/csv")}, data={ "calibration_json": json.dumps( - {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} ), }, ) @@ -1502,7 +3357,7 @@ def test_cannot_create_range_based_score_calibration_with_classes_file( ], indirect=["mock_publication_fetch"], ) -def test_cannot_create_score_calibration_as_anonymous_user( +def test_cannot_create_superseding_score_calibration_as_anonymous_user( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides ): experiment = create_experiment(client) @@ -1514,11 +3369,17 @@ def test_cannot_create_score_calibration_as_anonymous_user( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + with DependencyOverrider(anonymous_app_overrides): response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), }, ) @@ -1538,7 +3399,7 @@ def test_cannot_create_score_calibration_as_anonymous_user( ], indirect=["mock_publication_fetch"], ) -def test_can_create_score_calibration_as_score_set_owner( +def test_can_create_superseding_score_calibration_as_score_set_owner( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) @@ -1550,10 +3411,16 @@ def test_can_create_score_calibration_as_score_set_owner( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), }, ) @@ -1562,6 +3429,7 @@ def test_can_create_score_calibration_as_score_set_owner( calibration_response = response.json() assert calibration_response["scoreSetUrn"] == score_set["urn"] assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] @pytest.mark.parametrize( @@ -1574,7 +3442,7 @@ def test_can_create_score_calibration_as_score_set_owner( ], indirect=["mock_publication_fetch"], ) -def test_can_create_score_calibration_as_score_set_owner_form( +def test_can_create_superseding_score_calibration_as_score_set_owner_form( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) @@ -1586,11 +3454,19 @@ def test_can_create_score_calibration_as_score_set_owner_form( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + response = client.post( "/api/v1/score-calibrations", data={ "calibration_json": json.dumps( - {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} ), }, ) @@ -1599,6 +3475,7 @@ def test_can_create_score_calibration_as_score_set_owner_form( calibration_response = response.json() assert calibration_response["scoreSetUrn"] == score_set["urn"] assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] @pytest.mark.parametrize( @@ -1611,7 +3488,7 @@ def test_can_create_score_calibration_as_score_set_owner_form( ], indirect=["mock_publication_fetch"], ) -def test_can_create_score_calibration_as_score_set_contributor( +def test_can_create_superseding_score_calibration_as_score_set_contributor( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -1632,11 +3509,17 @@ def test_can_create_score_calibration_as_score_set_contributor( EXTRA_USER["last_name"], ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + with DependencyOverrider(extra_user_app_overrides): response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), }, ) @@ -1645,6 +3528,7 @@ def test_can_create_score_calibration_as_score_set_contributor( calibration_response = response.json() assert calibration_response["scoreSetUrn"] == score_set["urn"] assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] @pytest.mark.parametrize( @@ -1657,7 +3541,7 @@ def test_can_create_score_calibration_as_score_set_contributor( ], indirect=["mock_publication_fetch"], ) -def test_can_create_score_calibration_as_admin_user( +def test_can_create_superseding_score_calibration_as_admin_user( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides ): experiment = create_experiment(client) @@ -1669,11 +3553,17 @@ def test_can_create_score_calibration_as_admin_user( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + with DependencyOverrider(admin_app_overrides): response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), }, ) @@ -1682,6 +3572,7 @@ def test_can_create_score_calibration_as_admin_user( calibration_response = response.json() assert calibration_response["scoreSetUrn"] == score_set["urn"] assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] @pytest.mark.parametrize( @@ -1698,7 +3589,7 @@ def test_can_create_score_calibration_as_admin_user( "calibration_csv_path", ["calibration_classes_by_urn.csv", "calibration_classes_by_hgvs_nt.csv", "calibration_classes_by_hgvs_prot.csv"], ) -def test_can_create_class_based_score_calibration_form( +def test_can_create_superseding_class_based_score_calibration_form( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, calibration_csv_path ): experiment = create_experiment(client) @@ -1712,6 +3603,11 @@ def test_can_create_class_based_score_calibration_form( with patch.object(ArqRedis, "enqueue_job", return_value=None): score_set = publish_score_set(client, score_set["urn"]) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + classification_csv_path = data_files / calibration_csv_path with open(classification_csv_path, "rb") as class_file: response = client.post( @@ -1719,7 +3615,10 @@ def test_can_create_class_based_score_calibration_form( files={"classes_file": (classification_csv_path.name, class_file, "text/csv")}, data={ "calibration_json": json.dumps( - {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} ), }, ) @@ -1728,11 +3627,101 @@ def test_can_create_class_based_score_calibration_form( calibration_response = response.json() assert calibration_response["scoreSetUrn"] == score_set["urn"] assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] assert all( classification["variantCount"] == 1 for classification in calibration_response["functionalClassifications"] ) +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_superseding_score_calibration_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.post( + "/api/v1/score-calibrations", + data={ + "calibration_json": json.dumps( + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": calibration["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} + ), + }, + ) + + assert response.status_code == 422 + error = response.json() + assert f"Cannot supersede a private calibration. Please edit it instead." in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_can_create_superseding_score_calibration_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + response = client.post( + "/api/v1/score-calibrations", + data={ + "calibration_json": json.dumps( + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} + ), + }, + ) + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] + + ########################################################### # PUT /score-calibrations/{calibration_urn} ########################################################### @@ -2703,6 +4692,55 @@ def test_can_modify_score_calibration_to_class_based( ) +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_update_superseded_calibration_when_score_calibration_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + data={ + "calibration_json": json.dumps( + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": calibration["urn"], + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) + } + ), + } + ) + + assert response.status_code == 422 + error = response.json() + detail = error["detail"] + assert any( + err.get("msg") == "Extra inputs are not permitted" + and err.get("loc") == ["supersededCalibrationUrn"] + for err in detail + ) + + ########################################################### # DELETE /score-calibrations/{calibration_urn} ########################################################### @@ -3308,9 +5346,124 @@ def test_cannot_promote_research_use_only_to_primary( response = client.post(f"/api/v1/score-calibrations/{calibration['urn']}/promote-to-primary") - assert response.status_code == 400 - error = response.json() - assert "Research use only score calibrations cannot be promoted to primary" in error["detail"] + assert response.status_code == 400 + error = response.json() + assert "Research use only score calibrations cannot be promoted to primary" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_promote_private_calibration_to_primary( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize({**TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED}), + ) + response = client.post(f"/api/v1/score-calibrations/{calibration['urn']}/promote-to-primary") + + assert response.status_code == 400 + error = response.json() + assert "Private score calibrations cannot be promoted to primary" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_promote_to_primary_if_primary_exists( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + secondary_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) + ) + publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) + + response = client.post(f"/api/v1/score-calibrations/{secondary_calibration['urn']}/promote-to-primary") + + assert response.status_code == 400 + error = response.json() + assert "A primary score calibration already exists for this score set" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_can_promote_to_primary_if_primary_exists_when_demote_existing_is_true( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + primary_calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + secondary_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) + ) + publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) + + response = client.post( + f"/api/v1/score-calibrations/{secondary_calibration['urn']}/promote-to-primary?demoteExistingPrimary=true", + ) + + assert response.status_code == 200 + promotion_response = response.json() + assert promotion_response["urn"] == secondary_calibration["urn"] + assert promotion_response["scoreSetUrn"] == score_set["urn"] + assert promotion_response["primary"] is True + + # verify the previous primary is no longer primary + get_response = client.get(f"/api/v1/score-calibrations/{primary_calibration['urn']}") + assert get_response.status_code == 200 + previous_primary = get_response.json() + assert previous_primary["primary"] is False @pytest.mark.parametrize( @@ -3323,9 +5476,11 @@ def test_cannot_promote_research_use_only_to_primary( ], indirect=["mock_publication_fetch"], ) -def test_cannot_promote_private_calibration_to_primary( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +def test_score_set_owner_can_promote_to_primary_with_demote_existing_flag_on_community_calibration( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides ): + """Score set owners can change rank of community calibrations, so they can promote + their own calibration while demoting an admin-created community primary.""" experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( client, @@ -3334,17 +5489,28 @@ def test_cannot_promote_private_calibration_to_primary( experiment["urn"], data_files / "scores.csv", ) - calibration = create_test_score_calibration_in_score_set_via_client( - client, - score_set["urn"], - deepcamelize({**TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, "private": True}), + with DependencyOverrider(admin_app_overrides): + primary_calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + secondary_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) ) + publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) - response = client.post(f"/api/v1/score-calibrations/{calibration['urn']}/promote-to-primary") + response = client.post( + f"/api/v1/score-calibrations/{secondary_calibration['urn']}/promote-to-primary?demoteExistingPrimary=true", + ) - assert response.status_code == 400 - error = response.json() - assert "Private score calibrations cannot be promoted to primary" in error["detail"] + assert response.status_code == 200 + promoted = response.json() + assert promoted["primary"] is True + + # verify the previous primary was demoted + get_response = client.get(f"/api/v1/score-calibrations/{primary_calibration['urn']}") + assert get_response.status_code == 200 + previous_primary = get_response.json() + assert previous_primary["primary"] is False @pytest.mark.parametrize( @@ -3357,7 +5523,7 @@ def test_cannot_promote_private_calibration_to_primary( ], indirect=["mock_publication_fetch"], ) -def test_cannot_promote_to_primary_if_primary_exists( +def test_promote_superseding_score_calibration_if_superseded_is_not_primary( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) @@ -3368,20 +5534,35 @@ def test_cannot_promote_to_primary_if_primary_exists( experiment["urn"], data_files / "scores.csv", ) - create_publish_and_promote_score_calibration( + calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) - secondary_calibration = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), ) - publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) + published_superseding_calibration = publish_test_score_calibration_via_client(client, + superseding_calibration["urn"]) - response = client.post(f"/api/v1/score-calibrations/{secondary_calibration['urn']}/promote-to-primary") + response = client.post(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}/promote-to-primary") - assert response.status_code == 400 - error = response.json() - assert "A primary score calibration already exists for this score set" in error["detail"] + assert response.status_code == 200 + promotion_response = response.json() + assert promotion_response["urn"] == published_superseding_calibration["urn"] + assert promotion_response["scoreSetUrn"] == score_set["urn"] + assert promotion_response["primary"] is True + get_response = client.get(f"/api/v1/score-calibrations/{published_calibration['urn']}") + assert get_response.status_code == 200 + previous_primary = get_response.json() + assert previous_primary["primary"] is False @pytest.mark.parametrize( "mock_publication_fetch", @@ -3393,7 +5574,7 @@ def test_cannot_promote_to_primary_if_primary_exists( ], indirect=["mock_publication_fetch"], ) -def test_can_promote_to_primary_if_primary_exists_when_demote_existing_is_true( +def test_promote_superseding_score_calibration_if_superseded_is_primary( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) @@ -3407,18 +5588,24 @@ def test_can_promote_to_primary_if_primary_exists_when_demote_existing_is_true( primary_calibration = create_publish_and_promote_score_calibration( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) - secondary_calibration = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) - ) - publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) - response = client.post( - f"/api/v1/score-calibrations/{secondary_calibration['urn']}/promote-to-primary?demoteExistingPrimary=true", + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": primary_calibration["urn"], + } + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, + superseding_calibration["urn"]) + + response = client.post(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}/promote-to-primary") assert response.status_code == 200 promotion_response = response.json() - assert promotion_response["urn"] == secondary_calibration["urn"] + assert promotion_response["urn"] == published_superseding_calibration["urn"] assert promotion_response["scoreSetUrn"] == score_set["urn"] assert promotion_response["primary"] is True @@ -3439,11 +5626,9 @@ def test_can_promote_to_primary_if_primary_exists_when_demote_existing_is_true( ], indirect=["mock_publication_fetch"], ) -def test_score_set_owner_can_promote_to_primary_with_demote_existing_flag_on_community_calibration( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides +def test_can_promote_superseding_calibration_to_primary_if_primary_exists_when_demote_existing_is_true( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): - """Score set owners can change rank of community calibrations, so they can promote - their own calibration while demoting an admin-created community primary.""" experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( client, @@ -3452,24 +5637,37 @@ def test_score_set_owner_can_promote_to_primary_with_demote_existing_flag_on_com experiment["urn"], data_files / "scores.csv", ) - with DependencyOverrider(admin_app_overrides): - primary_calibration = create_publish_and_promote_score_calibration( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) + primary_calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) secondary_calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) ) - publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) + published_secondary_calibration = publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_secondary_calibration["urn"], + } + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, + superseding_calibration["urn"]) response = client.post( - f"/api/v1/score-calibrations/{secondary_calibration['urn']}/promote-to-primary?demoteExistingPrimary=true", + f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}/promote-to-primary?demoteExistingPrimary=true", ) assert response.status_code == 200 - promoted = response.json() - assert promoted["primary"] is True + promotion_response = response.json() + assert promotion_response["urn"] == published_superseding_calibration["urn"] + assert promotion_response["scoreSetUrn"] == score_set["urn"] + assert promotion_response["primary"] is True - # verify the previous primary was demoted + # verify the previous primary is no longer primary get_response = client.get(f"/api/v1/score-calibrations/{primary_calibration['urn']}") assert get_response.status_code == 200 previous_primary = get_response.json() @@ -3719,6 +5917,59 @@ def test_can_demote_non_primary_score_calibration( assert demotion_response["primary"] is False +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_can_demote_superseding_score_calibration_as_score_set_owner( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": calibration["urn"], + } + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, + superseding_calibration["urn"]) + + response = client.post( + f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}/demote-from-primary", + ) + + assert response.status_code == 200 + demotion_response = response.json() + assert demotion_response["urn"] == published_superseding_calibration["urn"] + assert demotion_response["scoreSetUrn"] == score_set["urn"] + assert demotion_response["primary"] is False + assert demotion_response["supersededCalibration"]["urn"] == calibration["urn"] + + get_response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + assert get_response.status_code == 200 + previous_primary = get_response.json() + assert previous_primary["primary"] is False + + ########################################################### # POST /score-calibrations/{calibration_urn}/publish ########################################################### @@ -3931,6 +6182,57 @@ def test_can_publish_already_published_calibration( assert published_calibration_2["private"] is False +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_can_publish_superseding_score_calibration( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + response = client.post(f"/api/v1/score-calibrations/{superseding_calibration['urn']}/publish") + + assert response.status_code == 200 + publish_response = response.json() + assert publish_response["urn"] == superseding_calibration["urn"] + assert publish_response["scoreSetUrn"] == score_set["urn"] + assert publish_response["private"] is False + assert publish_response["supersededCalibration"]["urn"] == calibration["urn"] + + get_response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + assert get_response.status_code == 200 + score_set_response = get_response.json() + assert len(score_set_response) == 1 + + ########################################################### # GET /score-calibrations/{urn}/functional-classifications/{id}/variants ########################################################### @@ -5025,3 +7327,110 @@ def test_user_sees_calibrations_across_multiple_score_sets( returned_urns = {c["urn"] for c in calibrations} assert cal_1["urn"] in returned_urns assert cal_2["urn"] in returned_urns + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_authenticated_user_sees_own_calibrations_including_private_superseded_calibration( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + response = client.get("/api/v1/score-calibrations/me") + + assert response.status_code == 200 + calibrations = response.json() + assert len(calibrations) == 2 + superseding_calibration = next( + c for c in calibrations if c["urn"] == superseding_calibration["urn"] + ) + assert superseding_calibration["private"] is True + superseded_calibration = next( + c for c in calibrations if c["urn"] == published_calibration["urn"] + ) + assert superseded_calibration["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_authenticated_user_sees_own_calibrations_including_public_superseded_calibration( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + response = client.get("/api/v1/score-calibrations/me") + + assert response.status_code == 200 + calibrations = response.json() + assert len(calibrations) == 2 + superseding_calibration = next( + c for c in calibrations if c["urn"] == superseding_calibration["urn"] + ) + assert superseding_calibration["private"] is False + superseded_calibration = next( + c for c in calibrations if c["urn"] == published_calibration["urn"] + ) + assert superseded_calibration["private"] is False \ No newline at end of file From 17f920e79958c765d8d818051a88cbf68db7096b Mon Sep 17 00:00:00 2001 From: Estelle Da Date: Mon, 13 Jul 2026 16:10:13 +1000 Subject: [PATCH 6/6] Debug calibration functions and some tests. --- src/mavedb/view_models/score_calibration.py | 2 -- tests/lib/test_score_calibrations.py | 1 + tests/routers/test_score_calibrations.py | 18 +++++++----------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/mavedb/view_models/score_calibration.py b/src/mavedb/view_models/score_calibration.py index e61942ebf..a72172dbd 100644 --- a/src/mavedb/view_models/score_calibration.py +++ b/src/mavedb/view_models/score_calibration.py @@ -437,8 +437,6 @@ class ScoreCalibrationModify(ScoreCalibrationBase): evidence_sources: Sequence[PublicationIdentifierCreate] method_sources: Sequence[PublicationIdentifierCreate] - class Config: - extra = "forbid" # TODO#668: Move this validator to ScoreCalibrationBase once legacy calibrations have been # backfilled with publication associations. Currently on the write model only so that existing diff --git a/tests/lib/test_score_calibrations.py b/tests/lib/test_score_calibrations.py index ccabb3ba4..4a927e64c 100644 --- a/tests/lib/test_score_calibrations.py +++ b/tests/lib/test_score_calibrations.py @@ -507,6 +507,7 @@ async def test_create_score_calibration_propagates_errors_from_publication_find_ evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) with ( pytest.raises( diff --git a/tests/routers/test_score_calibrations.py b/tests/routers/test_score_calibrations.py index f4dafe971..cb80299f3 100644 --- a/tests/routers/test_score_calibrations.py +++ b/tests/routers/test_score_calibrations.py @@ -1987,7 +1987,7 @@ def test_anonymous_user_can_get_superseding_score_calibrations_for_score_set_whe experiment["urn"], data_files / "scores.csv", ) - private_calibration = create_test_score_calibration_in_score_set_via_client( + create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) @@ -2101,7 +2101,7 @@ def test_anonymous_user_cannot_get_superseding_score_calibrations_for_score_set_ experiment["urn"], data_files / "scores.csv", ) - private_calibration = create_test_score_calibration_in_score_set_via_client( + create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) @@ -3673,7 +3673,7 @@ def test_cannot_create_superseding_score_calibration_when_private( assert response.status_code == 422 error = response.json() - assert f"Cannot supersede a private calibration. Please edit it instead." in error["detail"] + assert "Cannot supersede a private calibration. Please edit it instead." in error["detail"] @pytest.mark.parametrize( @@ -4731,14 +4731,10 @@ def test_cannot_update_superseded_calibration_when_score_calibration_private( } ) - assert response.status_code == 422 - error = response.json() - detail = error["detail"] - assert any( - err.get("msg") == "Extra inputs are not permitted" - and err.get("loc") == ["supersededCalibrationUrn"] - for err in detail - ) + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["supersededCalibration"] is None ###########################################################