diff --git a/alembic/manual_migrations/migrate_score_ranges_to_calibrations.py b/alembic/manual_migrations/migrate_score_ranges_to_calibrations.py new file mode 100644 index 00000000..615f19e0 --- /dev/null +++ b/alembic/manual_migrations/migrate_score_ranges_to_calibrations.py @@ -0,0 +1,155 @@ + +from typing import Union + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +# SQLAlchemy needs access to all models to properly map relationships. +from mavedb.models import * + +from mavedb.db.session import SessionLocal +from mavedb.models.score_set import ScoreSet +from mavedb.models.score_calibration import ScoreCalibration as ScoreCalibrationDBModel +from mavedb.models.publication_identifier import PublicationIdentifier +from mavedb.view_models.score_range import ( + ScoreRangeCreate, + ScoreSetRangesAdminCreate, + ZeibergCalibrationScoreRangesAdminCreate, + ScottScoreRangesAdminCreate, + InvestigatorScoreRangesAdminCreate, + IGVFCodingVariantFocusGroupControlScoreRangesAdminCreate, + IGVFCodingVariantFocusGroupMissenseScoreRangesAdminCreate, +) + +score_range_kinds: dict[ + str, + Union[ + type[ZeibergCalibrationScoreRangesAdminCreate], + type[ScottScoreRangesAdminCreate], + type[InvestigatorScoreRangesAdminCreate], + type[IGVFCodingVariantFocusGroupControlScoreRangesAdminCreate], + type[IGVFCodingVariantFocusGroupMissenseScoreRangesAdminCreate], + ], +] = { + "zeiberg_calibration": ZeibergCalibrationScoreRangesAdminCreate, + "scott_calibration": ScottScoreRangesAdminCreate, + "investigator_provided": InvestigatorScoreRangesAdminCreate, + "cvfg_all_variants": IGVFCodingVariantFocusGroupControlScoreRangesAdminCreate, + "cvfg_missense_variants": IGVFCodingVariantFocusGroupMissenseScoreRangesAdminCreate, +} + +EVIDENCE_STRENGTH_FROM_POINTS = { + 8: "Very Strong", + 4: "Strong", + 3: "Moderate+", + 2: "Moderate", + 1: "Supporting", +} + + +def do_migration(session: Session) -> None: + score_sets_with_ranges = ( + session.execute(sa.select(ScoreSet).where(ScoreSet.score_ranges.isnot(None))).scalars().all() + ) + + for score_set in score_sets_with_ranges: + if not score_set.score_ranges: + continue + + score_set_ranges = ScoreSetRangesAdminCreate.model_validate(score_set.score_ranges) + + for field in score_set_ranges.model_fields_set: + if field == "record_type": + continue + + ranges = getattr(score_set_ranges, field) + if not ranges: + continue + + range_model = score_range_kinds.get(field) + inferred_ranges = range_model.model_validate(ranges) + + model_thresholds = [] + for range in inferred_ranges.ranges: + model_thresholds.append(ScoreRangeCreate.model_validate(range.__dict__).model_dump()) + + # We should migrate the zeiberg evidence classifications to be explicitly part of the calibration ranges. + if field == "zeiberg_calibration": + for inferred_range, model_range in zip( + inferred_ranges.ranges, + model_thresholds, + ): + model_range["label"] = f"PS3 {EVIDENCE_STRENGTH_FROM_POINTS.get(inferred_range.evidence_strength, 'Unknown')}" if inferred_range.evidence_strength > 0 else f"BS3 {EVIDENCE_STRENGTH_FROM_POINTS.get(abs(inferred_range.evidence_strength), 'Unknown')}" + model_range["acmg_classification"] = {"points": inferred_range.evidence_strength} + + # Reliant on existing behavior that these sources have been created already. + # If not present, no sources will be associated. + if "odds_path_source" in inferred_ranges.model_fields_set and inferred_ranges.odds_path_source: + oddspaths_sources = ( + session.execute( + sa.select(PublicationIdentifier).where( + PublicationIdentifier.identifier.in_( + [src.identifier for src in (inferred_ranges.odds_path_source or [])] + ) + ) + ) + .scalars() + .all() + ) + else: + oddspaths_sources = [] + + if "source" in inferred_ranges.model_fields_set and inferred_ranges.source: + range_sources = ( + session.execute( + sa.select(PublicationIdentifier).where( + PublicationIdentifier.identifier.in_( + [src.identifier for src in (inferred_ranges.source or [])] + ) + ) + ) + .scalars() + .all() + ) + else: + range_sources = [] + + sources = set() + for publication in oddspaths_sources: + setattr(publication, "relation", "method") + sources.add(publication) + for publication in range_sources: + setattr(publication, "relation", "threshold") + sources.add(publication) + + score_calibration = ScoreCalibrationDBModel( + score_set_id=score_set.id, + title=inferred_ranges.title, + research_use_only=inferred_ranges.research_use_only, + primary=inferred_ranges.primary, + private=False, # All migrated calibrations are public. + investigator_provided=True if field == "investigator_provided" else False, + baseline_score=inferred_ranges.baseline_score + if "baseline_score" in inferred_ranges.model_fields_set + else None, + baseline_score_description=inferred_ranges.baseline_score_description + if "baseline_score_description" in inferred_ranges.model_fields_set + else None, + functional_ranges=None if not model_thresholds else model_thresholds, + calibration_metadata=None, + publication_identifiers=sources, + # If investigator_provided, set to creator of score set, else set to default system user (1). + created_by_id=score_set.created_by_id if field == "investigator_provided" else 1, + modified_by_id=score_set.created_by_id if field == "investigator_provided" else 1, + ) + session.add(score_calibration) + + +if __name__ == "__main__": + db = SessionLocal() + db.current_user = None # type: ignore + + do_migration(db) + + db.commit() + db.close() \ No newline at end of file diff --git a/alembic/versions/002f6f9ec7ac_add_score_calibration_table.py b/alembic/versions/002f6f9ec7ac_add_score_calibration_table.py new file mode 100644 index 00000000..86ae6539 --- /dev/null +++ b/alembic/versions/002f6f9ec7ac_add_score_calibration_table.py @@ -0,0 +1,92 @@ +"""add score calibration table + +Revision ID: 002f6f9ec7ac +Revises: 019eb75ad9ae +Create Date: 2025-10-08 08:59:10.563528 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = "002f6f9ec7ac" +down_revision = "019eb75ad9ae" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "score_calibrations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("urn", sa.String(length=64), nullable=True), + sa.Column("score_set_id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("research_use_only", sa.Boolean(), nullable=False), + sa.Column("primary", sa.Boolean(), nullable=False), + sa.Column("investigator_provided", sa.Boolean(), nullable=False), + sa.Column("private", sa.Boolean(), nullable=False), + sa.Column("notes", sa.String(), nullable=True), + sa.Column("baseline_score", sa.Float(), nullable=True), + sa.Column("baseline_score_description", sa.String(), nullable=True), + sa.Column("functional_ranges", postgresql.JSONB(astext_type=sa.Text(), none_as_null=True), nullable=True), + sa.Column("calibration_metadata", postgresql.JSONB(astext_type=sa.Text(), none_as_null=True), nullable=True), + sa.Column("created_by_id", sa.Integer(), nullable=False), + sa.Column("modified_by_id", sa.Integer(), nullable=False), + sa.Column("creation_date", sa.Date(), nullable=False), + sa.Column("modification_date", sa.Date(), nullable=False), + sa.ForeignKeyConstraint( + ["score_set_id"], + ["scoresets.id"], + ), + sa.ForeignKeyConstraint( + ["created_by_id"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["modified_by_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "score_calibration_publication_identifiers", + sa.Column("score_calibration_id", sa.Integer(), nullable=False), + sa.Column("publication_identifier_id", sa.Integer(), nullable=False), + sa.Column( + "relation", + sa.Enum( + "thresholds", + "classifications", + "methods", + name="scorecalibrationrelation", + native_enum=False, + length=32, + ), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["publication_identifier_id"], + ["publication_identifiers.id"], + ), + sa.ForeignKeyConstraint( + ["score_calibration_id"], + ["score_calibrations.id"], + ), + sa.PrimaryKeyConstraint("score_calibration_id", "publication_identifier_id", "relation"), + ) + op.create_index(op.f("ix_score_calibrations_urn"), "score_calibrations", ["urn"], unique=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_score_calibrations_urn"), table_name="score_calibrations") + op.drop_table("score_calibration_publication_identifiers") + op.drop_table("score_calibrations") + # ### end Alembic commands ### diff --git a/alembic/versions/f5a72192fafd_remove_score_range_property_from_score_.py b/alembic/versions/f5a72192fafd_remove_score_range_property_from_score_.py new file mode 100644 index 00000000..30a96614 --- /dev/null +++ b/alembic/versions/f5a72192fafd_remove_score_range_property_from_score_.py @@ -0,0 +1,32 @@ +"""remove score range property from score sets + +Revision ID: f5a72192fafd +Revises: 002f6f9ec7ac +Create Date: 2025-10-08 15:35:49.275162 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "f5a72192fafd" +down_revision = "002f6f9ec7ac" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("scoresets", "score_ranges") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "scoresets", + sa.Column("score_ranges", postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True), + ) + # ### end Alembic commands ### diff --git a/src/mavedb/lib/acmg.py b/src/mavedb/lib/acmg.py new file mode 100644 index 00000000..971923c2 --- /dev/null +++ b/src/mavedb/lib/acmg.py @@ -0,0 +1,123 @@ +from enum import Enum +from typing import Optional + + +class ACMGCriterion(str, Enum): + """Enum for ACMG criteria codes.""" + + PVS1 = "PVS1" + PS1 = "PS1" + PS2 = "PS2" + PS3 = "PS3" + PS4 = "PS4" + PM1 = "PM1" + PM2 = "PM2" + PM3 = "PM3" + PM4 = "PM4" + PM5 = "PM5" + PM6 = "PM6" + PP1 = "PP1" + PP2 = "PP2" + PP3 = "PP3" + PP4 = "PP4" + PP5 = "PP5" + BA1 = "BA1" + BS1 = "BS1" + BS2 = "BS2" + BS3 = "BS3" + BS4 = "BS4" + BP1 = "BP1" + BP2 = "BP2" + BP3 = "BP3" + BP4 = "BP4" + BP5 = "BP5" + BP6 = "BP6" + BP7 = "BP7" + + @property + def is_pathogenic(self) -> bool: + """Return True if the criterion is pathogenic, False if benign.""" + return self.name.startswith("P") # PVS, PS, PM, PP are pathogenic criteria + + @property + def is_benign(self) -> bool: + """Return True if the criterion is benign, False if pathogenic.""" + return self.name.startswith("B") # BA, BS, BP are benign criteria + + +class StrengthOfEvidenceProvided(str, Enum): + """Enum for strength of evidence provided.""" + + VERY_STRONG = "very_strong" + STRONG = "strong" + MODERATE_PLUS = "moderate_plus" + MODERATE = "moderate" + SUPPORTING = "supporting" + + +def points_evidence_strength_equivalent( + points: int, +) -> tuple[Optional[ACMGCriterion], Optional[StrengthOfEvidenceProvided]]: + """Infer the evidence strength and criterion from a given point value. + + Parameters + ---------- + points : int + The point value to classify. Positive values indicate pathogenic evidence, + negative values indicate benign evidence, and zero indicates no evidence. + + Returns + ------- + tuple[Optional[ACMGCriterion], Optional[StrengthOfEvidenceProvided]] + The enumerated evidence strength and criterion corresponding to the point value. + + Raises + ------ + TypeError + If points is not an integer (depending on external validation; this function assumes an int input and does not explicitly check type). + ValueError + If the points value is outside the range of -8 to 8. + + Examples + -------- + >>> inferred_evidence_strength_from_points(8) + (ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG) + >>> inferred_evidence_strength_from_points(2) + (ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE) + >>> inferred_evidence_strength_from_points(0) + (None, None) + >>> inferred_evidence_strength_from_points(-1) + (ACMGCriterion.BS3, StrengthOfEvidenceProvided.SUPPORTING) + >>> inferred_evidence_strength_from_points(-5) + (ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG) + + Notes + ----- + These thresholds reflect predefined cut points aligning with qualitative evidence strength categories. + Adjust carefully if underlying classification criteria change, ensuring ordering and exclusivity are preserved. + """ + if points > 8 or points < -8: + raise ValueError("Points value must be between -8 and 8 inclusive") + + if points >= 8: + return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG) + elif points >= 4: + return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.STRONG) + elif points >= 3: + return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE_PLUS) + elif points >= 2: + return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE) + elif points > 0: + return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.SUPPORTING) + elif points == 0: + return (None, None) + elif points > -2: + return (ACMGCriterion.BS3, StrengthOfEvidenceProvided.SUPPORTING) + elif points > -3: + return (ACMGCriterion.BS3, StrengthOfEvidenceProvided.MODERATE) + elif points > -4: + return (ACMGCriterion.BS3, StrengthOfEvidenceProvided.MODERATE_PLUS) + elif points > -8: + return (ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG) + else: # points <= -8 + return (ACMGCriterion.BS3, StrengthOfEvidenceProvided.VERY_STRONG) diff --git a/src/mavedb/lib/annotation/classification.py b/src/mavedb/lib/annotation/classification.py index de8246f5..9bf7526b 100644 --- a/src/mavedb/lib/annotation/classification.py +++ b/src/mavedb/lib/annotation/classification.py @@ -6,9 +6,7 @@ from ga4gh.va_spec.base.enums import StrengthOfEvidenceProvided from mavedb.models.mapped_variant import MappedVariant -from mavedb.lib.annotation.constants import ZEIBERG_CALIBRATION_CALIBRATION_STRENGTH_OF_EVIDENCE_MAP -from mavedb.lib.validation.utilities import inf_or_float -from mavedb.view_models.score_range import ScoreSetRanges +from mavedb.view_models.score_calibration import FunctionalRange logger = logging.getLogger(__name__) @@ -24,18 +22,30 @@ class ExperimentalVariantFunctionalImpactClassification(StrEnum): def functional_classification_of_variant( mapped_variant: MappedVariant, ) -> ExperimentalVariantFunctionalImpactClassification: - if mapped_variant.variant.score_set.score_ranges is None: + """Classify a variant's functional impact as normal, abnormal, or indeterminate. + + Uses the primary score calibration and its functional ranges. + Raises ValueError if required calibration or score is missing. + """ + if not mapped_variant.variant.score_set.score_calibrations: raise ValueError( - f"Variant {mapped_variant.variant.urn} does not have a score set with score ranges." + f"Variant {mapped_variant.variant.urn} does not have a score set with score calibrations." " Unable to classify functional impact." ) - # This view model object is much simpler to work with. - score_ranges = ScoreSetRanges(**mapped_variant.variant.score_set.score_ranges).investigator_provided + # TODO#494: Support for multiple calibrations (all non-research use only). + score_calibrations = mapped_variant.variant.score_set.score_calibrations or [] + primary_calibration = next((c for c in score_calibrations if c.primary), None) + + if not primary_calibration: + raise ValueError( + f"Variant {mapped_variant.variant.urn} does not have a primary score calibration." + " Unable to classify functional impact." + ) - if not score_ranges or not score_ranges.ranges: + if not primary_calibration.functional_ranges: raise ValueError( - f"Variant {mapped_variant.variant.urn} does not have investigator-provided score ranges." + f"Variant {mapped_variant.variant.urn} does not have ranges defined in its primary score calibration." " Unable to classify functional impact." ) @@ -47,12 +57,14 @@ def functional_classification_of_variant( " Unable to classify functional impact." ) - for range in score_ranges.ranges: - lower_bound, upper_bound = inf_or_float(range.range[0], lower=True), inf_or_float(range.range[1], lower=False) - if functional_score > lower_bound and functional_score <= upper_bound: - if range.classification == "normal": + for functional_range in primary_calibration.functional_ranges: + # It's easier to reason with the view model objects for functional ranges than the JSONB fields in the raw database object. + functional_range_view = FunctionalRange.model_validate(functional_range) + + if functional_range_view.is_contained_by_range(functional_score): + if functional_range_view.classification == "normal": return ExperimentalVariantFunctionalImpactClassification.NORMAL - elif range.classification == "abnormal": + elif functional_range_view.classification == "abnormal": return ExperimentalVariantFunctionalImpactClassification.ABNORMAL else: return ExperimentalVariantFunctionalImpactClassification.INDETERMINATE @@ -60,20 +72,33 @@ def functional_classification_of_variant( return ExperimentalVariantFunctionalImpactClassification.INDETERMINATE -def zeiberg_calibration_clinical_classification_of_variant( +def pathogenicity_classification_of_variant( mapped_variant: MappedVariant, ) -> tuple[VariantPathogenicityEvidenceLine.Criterion, Optional[StrengthOfEvidenceProvided]]: - if mapped_variant.variant.score_set.score_ranges is None: + """Classify a variant's pathogenicity and evidence strength using clinical calibration. + + Uses the first clinical score calibration and its functional ranges. + Raises ValueError if required calibration, score, or evidence strength is missing. + """ + if not mapped_variant.variant.score_set.score_calibrations: raise ValueError( - f"Variant {mapped_variant.variant.urn} does not have a score set with score thresholds." + f"Variant {mapped_variant.variant.urn} does not have a score set with score calibrations." " Unable to classify clinical impact." ) - score_ranges = ScoreSetRanges(**mapped_variant.variant.score_set.score_ranges).zeiberg_calibration + # TODO#494: Support multiple clinical calibrations. + score_calibrations = mapped_variant.variant.score_set.score_calibrations or [] + primary_calibration = next((c for c in score_calibrations if c.primary), None) + + if not primary_calibration: + raise ValueError( + f"Variant {mapped_variant.variant.urn} does not have a primary score calibration." + " Unable to classify clinical impact." + ) - if not score_ranges or not score_ranges.ranges: + if not primary_calibration.functional_ranges: raise ValueError( - f"Variant {mapped_variant.variant.urn} does not have pillar project score ranges." + f"Variant {mapped_variant.variant.urn} does not have ranges defined in its primary score calibration." " Unable to classify clinical impact." ) @@ -85,9 +110,44 @@ def zeiberg_calibration_clinical_classification_of_variant( " Unable to classify clinical impact." ) - for range in score_ranges.ranges: - lower_bound, upper_bound = inf_or_float(range.range[0], lower=True), inf_or_float(range.range[1], lower=False) - if functional_score > lower_bound and functional_score <= upper_bound: - return ZEIBERG_CALIBRATION_CALIBRATION_STRENGTH_OF_EVIDENCE_MAP[range.evidence_strength] - - return ZEIBERG_CALIBRATION_CALIBRATION_STRENGTH_OF_EVIDENCE_MAP[0] + for pathogenicity_range in primary_calibration.functional_ranges: + # It's easier to reason with the view model objects for functional ranges than the JSONB fields in the raw database object. + pathogenicity_range_view = FunctionalRange.model_validate(pathogenicity_range) + + if pathogenicity_range_view.is_contained_by_range(functional_score): + if pathogenicity_range_view.acmg_classification is None: + return (VariantPathogenicityEvidenceLine.Criterion.PS3, None) + + # More of a type guard, as the ACMGClassification model we construct above enforces that + # criterion and evidence strength are mutually defined. + if ( + pathogenicity_range_view.acmg_classification.evidence_strength is None + or pathogenicity_range_view.acmg_classification.criterion is None + ): # pragma: no cover - enforced by model validators in FunctionalRange view model + return (VariantPathogenicityEvidenceLine.Criterion.PS3, None) + + # TODO#540: Handle moderate+ + if ( + pathogenicity_range_view.acmg_classification.evidence_strength.name + not in StrengthOfEvidenceProvided._member_names_ + ): + raise ValueError( + f"Variant {mapped_variant.variant.urn} is contained in a clinical calibration range with an invalid evidence strength." + " Unable to classify clinical impact." + ) + + if ( + pathogenicity_range_view.acmg_classification.criterion.name + not in VariantPathogenicityEvidenceLine.Criterion._member_names_ + ): # pragma: no cover - enforced by model validators in FunctionalRange view model + raise ValueError( + f"Variant {mapped_variant.variant.urn} is contained in a clinical calibration range with an invalid criterion." + " Unable to classify clinical impact." + ) + + return ( + VariantPathogenicityEvidenceLine.Criterion[pathogenicity_range_view.acmg_classification.criterion.name], + StrengthOfEvidenceProvided[pathogenicity_range_view.acmg_classification.evidence_strength.name], + ) + + return (VariantPathogenicityEvidenceLine.Criterion.PS3, None) diff --git a/src/mavedb/lib/annotation/constants.py b/src/mavedb/lib/annotation/constants.py index bdb4997b..90b7dfec 100644 --- a/src/mavedb/lib/annotation/constants.py +++ b/src/mavedb/lib/annotation/constants.py @@ -1,34 +1,2 @@ -from ga4gh.va_spec.acmg_2015 import VariantPathogenicityEvidenceLine -from ga4gh.va_spec.base.enums import StrengthOfEvidenceProvided - GENERIC_DISEASE_MEDGEN_CODE = "C0012634" MEDGEN_SYSTEM = "https://www.ncbi.nlm.nih.gov/medgen/" - -ZEIBERG_CALIBRATION_CALIBRATION_STRENGTH_OF_EVIDENCE_MAP = { - # No evidence - 0: (VariantPathogenicityEvidenceLine.Criterion.PS3, None), - # Supporting evidence - -1: (VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.SUPPORTING), - 1: (VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.SUPPORTING), - # Moderate evidence - -2: (VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.MODERATE), - 2: (VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.MODERATE), - -3: (VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.MODERATE), - 3: (VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.MODERATE), - # Strong evidence - -4: (VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.STRONG), - 4: (VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.STRONG), - -5: (VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.STRONG), - 5: (VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.STRONG), - -6: (VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.STRONG), - 6: (VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.STRONG), - -7: (VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.STRONG), - 7: (VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.STRONG), - # Very Strong evidence - -8: (VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.VERY_STRONG), - 8: (VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG), -} - -# TODO#493 -FUNCTIONAL_RANGES = ["investigator_provided"] -CLINICAL_RANGES = ["zeiberg_calibration"] diff --git a/src/mavedb/lib/annotation/evidence_line.py b/src/mavedb/lib/annotation/evidence_line.py index a3fee2ab..dd33f153 100644 --- a/src/mavedb/lib/annotation/evidence_line.py +++ b/src/mavedb/lib/annotation/evidence_line.py @@ -12,7 +12,7 @@ VariantPathogenicityProposition, ) -from mavedb.lib.annotation.classification import zeiberg_calibration_clinical_classification_of_variant +from mavedb.lib.annotation.classification import pathogenicity_classification_of_variant from mavedb.lib.annotation.contribution import ( mavedb_api_contribution, mavedb_vrs_contribution, @@ -33,7 +33,7 @@ def acmg_evidence_line( proposition: VariantPathogenicityProposition, evidence: list[Union[StudyResult, EvidenceLineType, StatementType, iriReference]], ) -> Optional[VariantPathogenicityEvidenceLine]: - evidence_outcome, evidence_strength = zeiberg_calibration_clinical_classification_of_variant(mapped_variant) + evidence_outcome, evidence_strength = pathogenicity_classification_of_variant(mapped_variant) if not evidence_strength: evidence_outcome_code = f"{evidence_outcome.value}_not_met" diff --git a/src/mavedb/lib/annotation/util.py b/src/mavedb/lib/annotation/util.py index d82b6898..0baab474 100644 --- a/src/mavedb/lib/annotation/util.py +++ b/src/mavedb/lib/annotation/util.py @@ -1,3 +1,4 @@ +from typing import Literal from ga4gh.core.models import Extension from ga4gh.vrs.models import ( MolecularVariation, @@ -8,9 +9,9 @@ Expression, LiteralSequenceExpression, ) -from mavedb.lib.annotation.constants import CLINICAL_RANGES, FUNCTIONAL_RANGES from mavedb.models.mapped_variant import MappedVariant from mavedb.lib.annotation.exceptions import MappingDataDoesntExistException +from mavedb.view_models.score_calibration import SavedScoreCalibration def allele_from_mapped_variant_dictionary_result(allelic_mapping_results: dict) -> Allele: @@ -162,32 +163,41 @@ def _can_annotate_variant_base_assumptions(mapped_variant: MappedVariant) -> boo return True -def _variant_score_ranges_have_required_keys_and_ranges_for_annotation( - mapped_variant: MappedVariant, key_options: list[str] +def _variant_score_calibrations_have_required_calibrations_and_ranges_for_annotation( + mapped_variant: MappedVariant, annotation_type: Literal["pathogenicity", "functional"] ) -> bool: """ - Check if a mapped variant's score set contains any of the required score range keys for annotation and is present. + Check if a mapped variant's score set contains any of the required calibrations for annotation. Args: mapped_variant (MappedVariant): The mapped variant object containing the variant with score set data. - key_options (list[str]): List of possible score range keys to check for in the score set. + annotation_type (Literal["pathogenicity", "functional"]): The type of annotation to check for. + Must be either "pathogenicity" or "functional". Returns: - bool: False if none of the required keys are found or if all found keys have None values or if all found keys - do not have range data. - Returns True (implicitly) if at least one required key exists with a non-None value. + bool: False if none of the required kinds are found or if all found calibrations have None or empty functional + range values/do not have range data. + Returns True (implicitly) if at least one required kind exists and has a non-empty functional range. """ - if mapped_variant.variant.score_set.score_ranges is None: + if mapped_variant.variant.score_set.score_calibrations is None: return False - if not any( - range_key in mapped_variant.variant.score_set.score_ranges - and mapped_variant.variant.score_set.score_ranges[range_key] is not None - and mapped_variant.variant.score_set.score_ranges[range_key]["ranges"] - for range_key in key_options - ): + # TODO#494: Support for multiple calibrations (all non-research use only). + primary_calibration = next((c for c in mapped_variant.variant.score_set.score_calibrations if c.primary), None) + if not primary_calibration: return False + saved_calibration = SavedScoreCalibration.model_validate(primary_calibration) + if annotation_type == "pathogenicity": + return ( + saved_calibration.functional_ranges is not None + and len(saved_calibration.functional_ranges) > 0 + and any(fr.acmg_classification is not None for fr in saved_calibration.functional_ranges) + ) + + if annotation_type == "functional": + return saved_calibration.functional_ranges is not None and len(saved_calibration.functional_ranges) > 0 + return True @@ -195,10 +205,9 @@ def can_annotate_variant_for_pathogenicity_evidence(mapped_variant: MappedVarian """ Determine if a mapped variant can be annotated for pathogenicity evidence. - This function checks whether a given mapped variant meets all the necessary - requirements to receive pathogenicity evidence annotations. It validates - both basic annotation assumptions and the presence of required clinical - score range keys. + This function checks if a variant meets all the necessary conditions to receive + pathogenicity evidence annotations by validating base assumptions and ensuring the variant's + score calibrations contain the required kinds for pathogenicity evidence annotation. Args: mapped_variant (MappedVariant): The mapped variant object to evaluate @@ -211,14 +220,16 @@ def can_annotate_variant_for_pathogenicity_evidence(mapped_variant: MappedVarian Notes: The function performs two main validation checks: 1. Basic annotation assumptions via _can_annotate_variant_base_assumptions - 2. Required clinical range keys via _variant_score_ranges_have_required_keys_and_ranges_for_annotation + 2. Verifies score calibrations have an appropriate calibration for pathogenicity evidence annotation. Both checks must pass for the variant to be considered eligible for pathogenicity evidence annotation. """ if not _can_annotate_variant_base_assumptions(mapped_variant): return False - if not _variant_score_ranges_have_required_keys_and_ranges_for_annotation(mapped_variant, CLINICAL_RANGES): + if not _variant_score_calibrations_have_required_calibrations_and_ranges_for_annotation( + mapped_variant, "pathogenicity" + ): return False return True @@ -230,7 +241,7 @@ def can_annotate_variant_for_functional_statement(mapped_variant: MappedVariant) This function checks if a variant meets all the necessary conditions to receive functional annotations by validating base assumptions and ensuring the variant's - score ranges contain the required keys for functional annotation. + score calibrations contain the required kinds for functional annotation. Args: mapped_variant (MappedVariant): The variant object to check for annotation @@ -243,11 +254,13 @@ def can_annotate_variant_for_functional_statement(mapped_variant: MappedVariant) Notes: The function performs two main checks: 1. Validates base assumptions using _can_annotate_variant_base_assumptions - 2. Verifies score ranges have required keys using FUNCTIONAL_RANGES + 2. Verifies score calibrations have an appropriate calibration for functional annotation. """ if not _can_annotate_variant_base_assumptions(mapped_variant): return False - if not _variant_score_ranges_have_required_keys_and_ranges_for_annotation(mapped_variant, FUNCTIONAL_RANGES): + if not _variant_score_calibrations_have_required_calibrations_and_ranges_for_annotation( + mapped_variant, "functional" + ): return False return True diff --git a/src/mavedb/lib/oddspaths.py b/src/mavedb/lib/oddspaths.py new file mode 100644 index 00000000..43f16030 --- /dev/null +++ b/src/mavedb/lib/oddspaths.py @@ -0,0 +1,98 @@ +from typing import Optional + +from mavedb.lib.acmg import StrengthOfEvidenceProvided, ACMGCriterion + + +def oddspaths_evidence_strength_equivalent( + ratio: float, +) -> tuple[Optional[ACMGCriterion], Optional[StrengthOfEvidenceProvided]]: + """ + Based on the guidelines laid out in Table 3 of: + Brnich, S.E., Abou Tayoun, A.N., Couch, F.J. et al. Recommendations for application + of the functional evidence PS3/BS3 criterion using the ACMG/AMP sequence variant + interpretation framework. Genome Med 12, 3 (2020). + https://doi.org/10.1186/s13073-019-0690-2 + + Classify an odds (likelihood) ratio into a ACMGCriterion and StrengthOfEvidenceProvided. + + This function infers the ACMG/AMP-style evidence strength category from a + precomputed odds (likelihood) ratio by applying a series of descending + threshold comparisons. The mapping is asymmetric: higher ratios favor + pathogenic (PS3*) evidence levels; lower ratios favor benign (BS3*) evidence + levels; an intermediate band is considered indeterminate. + + Threshold logic (first condition matched is returned): + ratio > 350 -> (PS3, VERY_STRONG) + ratio > 18.6 -> (PS3, STRONG) + ratio > 4.3 -> (PS3, MODERATE) + ratio > 2.1 -> (PS3, SUPPORTING) + ratio >= 0.48 -> Indeterminate (None, None) + ratio >= 0.23 -> (BS3, SUPPORTING) + ratio >= 0.053 -> (BS3, MODERATE) + ratio < 0.053 -> (BS3, STRONG) + + Interval semantics: + - Upper (pathogenic) tiers use strictly greater-than (>) comparisons. + - Lower (benign) tiers and the indeterminate band use inclusive lower + bounds (>=) to form closed intervals extending downward until a prior + condition matches. + - Because of the ordering, each numeric ratio falls into exactly one tier. + + Parameters + ---------- + ratio : float + The odds or likelihood ratio to classify. Must be a positive value in + typical use. Values <= 0 are not biologically meaningful in this context + and will be treated as < 0.053, yielding a benign-leaning classification. + + Returns + ------- + tuple[Optional[ACMGCriterion], Optional[StrengthOfEvidenceProvided]] + The enumerated evidence strength and criterion corresponding to the ratio. + + Raises + ------ + TypeError + If ratio is not a real (float/int) number (depending on external validation; + this function assumes a float input and does not explicitly check type). + ValueError + If the ratio is negative (less than 0). + + Examples + -------- + >>> inferred_evidence_strength_from_ratio(500.0) + (ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG) + >>> inferred_evidence_strength_from_ratio(10.0) + (ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE) + >>> inferred_evidence_strength_from_ratio(0.30) + (ACMGCriterion.BS3, StrengthOfEvidenceProvided.SUPPORTING) + >>> inferred_evidence_strength_from_ratio(0.06) + (ACMGCriterion.BS3, StrengthOfEvidenceProvided.MODERATE) + >>> inferred_evidence_strength_from_ratio(0.5) + (None, None) + + Notes + ----- + These thresholds reflect predefined likelihood ratio cut points aligning with + qualitative evidence strength categories. Adjust carefully if underlying + classification criteria change, ensuring ordering and exclusivity are preserved. + """ + if ratio < 0: + raise ValueError("OddsPaths ratio must be a non-negative value") + + if ratio > 350: + return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG) + elif ratio > 18.6: + return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.STRONG) + elif ratio > 4.3: + return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE) + elif ratio > 2.1: + return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.SUPPORTING) + elif ratio >= 0.48: + return (None, None) + elif ratio >= 0.23: + return (ACMGCriterion.BS3, StrengthOfEvidenceProvided.SUPPORTING) + elif ratio >= 0.053: + return (ACMGCriterion.BS3, StrengthOfEvidenceProvided.MODERATE) + else: # ratio < 0.053 + return (ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG) diff --git a/src/mavedb/lib/permissions.py b/src/mavedb/lib/permissions.py index 6305272c..99b2ada0 100644 --- a/src/mavedb/lib/permissions.py +++ b/src/mavedb/lib/permissions.py @@ -10,6 +10,7 @@ from mavedb.models.enums.user_role import UserRole from mavedb.models.experiment import Experiment from mavedb.models.experiment_set import ExperimentSet +from mavedb.models.score_calibration import ScoreCalibration from mavedb.models.score_set import ScoreSet from mavedb.models.user import User @@ -27,6 +28,7 @@ class Action(Enum): ADD_ROLE = "add_role" PUBLISH = "publish" ADD_BADGE = "add_badge" + CHANGE_RANK = "change_rank" class PermissionResponse: @@ -104,6 +106,21 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> save_to_logging_context({"resource_is_published": published}) + if isinstance(item, ScoreCalibration): + assert item.private is not None + private = item.private + published = item.private is False + user_is_owner = item.created_by_id == user_data.user.id if user_data is not None else False + + # If the calibration is investigator provided, treat permissions like score set permissions where contributors + # may also make changes to the calibration. Otherwise, only allow the calibration owner to edit the calibration. + if item.investigator_provided: + user_may_edit = user_is_owner or ( + user_data is not None and user_data.user.username in [c.orcid_id for c in item.score_set.contributors] + ) + else: + user_may_edit = user_is_owner + if isinstance(item, User): user_is_self = item.id == user_data.user.id if user_data is not None else False user_may_edit = user_is_self @@ -378,6 +395,67 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") else: raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)") + elif isinstance(item, ScoreCalibration): + if action == Action.READ: + if user_may_edit or not private: + return PermissionResponse(True) + # Roles which may perform this operation. + elif roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + elif private: + # Do not acknowledge the existence of a private entity. + return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") + elif user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") + else: + return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") + elif action == Action.UPDATE: + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # TODO#549: Allow editing of certain fields even if published. For now, + # Owner may only edit if a calibration is not published. + elif user_may_edit: + return PermissionResponse(not published, 403, f"insufficient permissions for URN '{item.urn}'") + elif private: + # Do not acknowledge the existence of a private entity. + return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") + elif user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") + else: + return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") + elif action == Action.DELETE: + # Roles which may perform this operation. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owner may only delete a calibration if it has not already been published. + elif user_may_edit: + return PermissionResponse(not published, 403, f"insufficient permissions for URN '{item.urn}'") + elif private: + # Do not acknowledge the existence of a private entity. + return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") + else: + return PermissionResponse(False) + # Only the owner may publish a private calibration. + elif action == Action.PUBLISH: + if user_may_edit: + return PermissionResponse(True) + elif roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + elif private: + # Do not acknowledge the existence of a private entity. + return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") + else: + return PermissionResponse(False) + elif action == Action.CHANGE_RANK: + if user_may_edit: + return PermissionResponse(True) + elif roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + else: + return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") + + else: + raise NotImplementedError(f"has_permission(User, ScoreCalibration, {action}, Role)") elif isinstance(item, User): if action == Action.LOOKUP: diff --git a/src/mavedb/lib/score_calibrations.py b/src/mavedb/lib/score_calibrations.py new file mode 100644 index 00000000..cc67673a --- /dev/null +++ b/src/mavedb/lib/score_calibrations.py @@ -0,0 +1,519 @@ +"""Utilities for building and mutating score calibration ORM objects.""" + +from sqlalchemy.orm import Session + +from mavedb.lib.identifiers import find_or_create_publication_identifier +from mavedb.models.enums.score_calibration_relation import ScoreCalibrationRelation +from mavedb.models.score_calibration import ScoreCalibration +from mavedb.models.score_set import ScoreSet +from mavedb.models.score_calibration_publication_identifier import ScoreCalibrationPublicationIdentifierAssociation +from mavedb.models.user import User +from mavedb.view_models import score_calibration + + +async def _create_score_calibration( + db: Session, calibration_create: score_calibration.ScoreCalibrationCreate, user: User +) -> ScoreCalibration: + """ + Create a ScoreCalibration ORM instance (not yet persisted) together with its + publication identifier associations. + + For each publication source listed in the incoming ScoreCalibrationCreate model + (threshold_sources, classification_sources, method_sources), this function + ensures a corresponding PublicationIdentifier row exists (via + find_or_create_publication_identifier) and creates a + ScoreCalibrationPublicationIdentifierAssociation that links the identifier to + the new calibration under the appropriate relation type + (ScoreCalibrationRelation.threshold / .classification / .method). + + Fields in calibration_create that represent source lists or audit metadata + (threshold_sources, classification_sources, method_sources, created_at, + created_by, modified_at, modified_by) are excluded when instantiating the + ScoreCalibration; audit fields created_by and modified_by are explicitly set + from the provided user_data. The resulting ScoreCalibration object includes + the assembled publication_identifier_associations collection but is not added + to the session nor committed—callers are responsible for persisting it. + + Parameters + ---------- + db : Session + SQLAlchemy database session used to look up or create publication + identifiers. + calibration_create : score_calibration.ScoreCalibrationCreate + Pydantic (or similar) schema containing the calibration attributes and + optional lists of publication source identifiers grouped by relation type. + user : User + Authenticated user context; the user to be recorded for audit + + Returns + ------- + ScoreCalibration + A new, transient ScoreCalibration ORM instance populated with associations + to publication identifiers and audit metadata set. + + Side Effects + ------------ + May read from or write to the database when resolving publication identifiers + (via find_or_create_publication_identifier). Does not flush, add, or commit the + returned calibration instance. + + Notes + ----- + - Duplicate identifiers across different source lists result in distinct + association objects (no deduplication is performed here). + - The function is async because it awaits the underlying publication + identifier retrieval/creation calls. + """ + relation_sources = ( + (ScoreCalibrationRelation.threshold, calibration_create.threshold_sources or []), + (ScoreCalibrationRelation.classification, calibration_create.classification_sources or []), + (ScoreCalibrationRelation.method, calibration_create.method_sources or []), + ) + + calibration_pub_assocs = [] + for relation, sources in relation_sources: + for identifier in sources: + pub = await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) + calibration_pub_assocs.append( + ScoreCalibrationPublicationIdentifierAssociation( + publication=pub, + relation=relation, + ) + ) + + # Ensure newly created publications are persisted for future loops to avoid duplicates. + db.add(pub) + db.flush() + + calibration = ScoreCalibration( + **calibration_create.model_dump( + by_alias=False, + exclude={ + "threshold_sources", + "classification_sources", + "method_sources", + "score_set_urn", + }, + ), + publication_identifier_associations=calibration_pub_assocs, + created_by=user, + modified_by=user, + ) # type: ignore[call-arg] + + return calibration + + +async def create_score_calibration_in_score_set( + db: Session, calibration_create: score_calibration.ScoreCalibrationCreate, user: User +) -> ScoreCalibration: + """ + Create a new score calibration and associate it with an existing score set. + + This coroutine ensures that the provided ScoreCalibrationCreate payload includes a + score_set_urn, loads the corresponding ScoreSet from the database, delegates creation + of the ScoreCalibration to an internal helper, and then links the created calibration + to the fetched score set. + + Parameters: + db (Session): An active SQLAlchemy session used for database access. + calibration_create (score_calibration.ScoreCalibrationCreate): Pydantic (or schema) + object containing the fields required to create a score calibration. Must include + a non-empty score_set_urn. + user (User): Authenticated user information used for auditing + + Returns: + ScoreCalibration: The newly created and persisted score calibration object with its + score_set relationship populated. + + Raises: + ValueError: If calibration_create.score_set_urn is missing or falsy. + sqlalchemy.orm.exc.NoResultFound: If no ScoreSet exists with the provided URN. + sqlalchemy.orm.exc.MultipleResultsFound: If multiple ScoreSets share the provided URN + (should not occur if URNs are unique). + + Notes: + - This function is async because it awaits the internal _create_score_calibration + helper, which may perform asynchronous operations (e.g., I/O or async ORM tasks). + - The passed Session is expected to be valid for the lifetime of this call; committing + or flushing is assumed to be handled externally (depending on the surrounding + transaction management strategy). + """ + if not calibration_create.score_set_urn: + 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) + calibration.score_set = containing_score_set + + if 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, + ]: + calibration.investigator_provided = True + else: + calibration.investigator_provided = False + + db.add(calibration) + return calibration + + +async def create_score_calibration( + db: Session, calibration_create: score_calibration.ScoreCalibrationCreate, user: User +) -> ScoreCalibration: + """ + Asynchronously create and persist a new ScoreCalibration record. + + This is a thin wrapper that delegates to the internal _create_score_calibration + implementation, allowing for separation of public API and internal logic. + + Parameters + ---------- + db : sqlalchemy.orm.Session + Active database session used for persisting the new calibration. + calibration_create : score_calibration.ScoreCalibrationCreate + Pydantic (or similar) schema instance containing the data required to + instantiate a ScoreCalibration (e.g., method, parameters, target assay / + score set identifiers). + user : User + Authenticated user context; the user to be recorded for audit + + Returns + ------- + ScoreCalibration + The newly created (but un-added and un-committed) ScoreCalibration + ORM/model instance. + + Raises + ------ + IntegrityError + If database constraints (e.g., uniqueness, foreign keys) are violated. + AuthorizationError + If the provided user does not have permission to create the calibration. + ValidationError + If the supplied input schema fails validation (depending on schema logic). + ValueError + If calibration_create.score_set_urn is provided (must be None/absent here). + + Notes + ----- + - Because this function is asynchronous, callers must await it. Any transaction + management (commit / rollback) is expected to be handled by the session lifecycle + manager in the calling context. + - Because the calibration database model enforces that a calibration must belong + to a ScoreSet, callers should perform this association themselves after creation + (e.g., by assigning the calibration's score_set attribute to an existing ScoreSet + instance) prior to flushing the session. + """ + if calibration_create.score_set_urn: + 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) + + db.add(created_calibration) + return created_calibration + + +async def modify_score_calibration( + db: Session, + calibration: ScoreCalibration, + calibration_update: score_calibration.ScoreCalibrationModify, + user: User, +) -> ScoreCalibration: + """ + Asynchronously modify an existing ScoreCalibration record and its related publication + identifier associations. + + This function: + 1. Validates that a score_set_urn is provided in the update model (raises ValueError if absent). + 2. Loads (via SELECT ... WHERE urn = :score_set_urn) the ScoreSet that will contain the calibration. + 3. Reconciles publication identifier associations for three relation categories: + - threshold_sources -> ScoreCalibrationRelation.threshold + - classification_sources -> ScoreCalibrationRelation.classification + - method_sources -> ScoreCalibrationRelation.method + For each provided source identifier: + * Calls find_or_create_publication_identifier to obtain (or persist) the identifier row. + * Preserves an existing association if already present. + * Creates a new association if missing. + Any previously existing associations not referenced in the update are deleted from the session. + 4. Updates mutable scalar fields on the calibration instance from calibration_update, excluding: + threshold_sources, classification_sources, method_sources, created_at, created_by, + modified_at, modified_by. + 5. Reassigns the calibration to the resolved ScoreSet, replaces its association collection, + and stamps modified_by with the requesting user. + 6. Adds the modified calibration back into the SQLAlchemy session and returns it (no commit). + + Parameters + ---------- + db : Session + An active SQLAlchemy session (synchronous engine session used within an async context). + calibration : ScoreCalibration + The existing calibration ORM instance to be modified (must be persistent or pending). + del carrying updated field values plus source identifier lists: + - score_set_urn (required) + - threshold_sources, classification_sources, method_sources (iterables of identifier objects) + - Additional mutable calibration attributes. + user : User + Context for the authenticated user; the user to be recorded for audit. + + Returns + ------- + ScoreCalibration + The in-memory (and session-added) updated calibration instance. Changes are not committed. + + Raises + ------ + ValueError + If score_set_urn is missing in the update model. + sqlalchemy.orm.exc.NoResultFound + If no ScoreSet exists with the provided URN. + sqlalchemy.orm.exc.MultipleResultsFound + If more than one ScoreSet matches the provided URN. + Any exception raised by find_or_create_publication_identifier + If identifier resolution/creation fails. + + Side Effects + ------------ + - Issues SELECT statements for the ScoreSet and publication identifiers. + - May INSERT new publication identifiers and association rows. + - May DELETE association rows no longer referenced. + - Mutates the provided calibration object in-place. + + Concurrency / Consistency Notes + ------------------------------- + The reconciliation of associations assumes no concurrent modification of the same calibration's + association set within the active transaction. To prevent races leading to duplicate associations, + enforce appropriate transaction isolation or unique constraints at the database level. + + Commit Responsibility + --------------------- + This function does NOT call commit or flush explicitly; the caller is responsible for committing + the session to persist changes. + + """ + if not calibration_update.score_set_urn: + raise ValueError("score_set_urn must be provided to modify a score calibration.") + + containing_score_set = db.query(ScoreSet).where(ScoreSet.urn == calibration_update.score_set_urn).one() + + relation_sources = ( + (ScoreCalibrationRelation.threshold, calibration_update.threshold_sources or []), + (ScoreCalibrationRelation.classification, calibration_update.classification_sources or []), + (ScoreCalibrationRelation.method, calibration_update.method_sources or []), + ) + + # Build a map of existing associations by (relation, publication_identifier_id) for easy lookup. + existing_assocs_map = { + (assoc.relation, assoc.publication_identifier_id): assoc + for assoc in calibration.publication_identifier_associations + } + + updated_assocs = [] + for relation, sources in relation_sources: + for identifier in sources: + pub = await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) + assoc_key = (relation, pub.id) + if assoc_key in existing_assocs_map: + # Keep existing association + updated_assocs.append(existing_assocs_map.pop(assoc_key)) + else: + # Create new association + updated_assocs.append( + ScoreCalibrationPublicationIdentifierAssociation( + publication=pub, + relation=relation, + ) + ) + + # Ensure newly created publications are persisted for future loops to avoid duplicates. + db.add(pub) + db.flush() + + # Remove associations that are no longer present + for assoc in existing_assocs_map.values(): + db.delete(assoc) + + for attr, value in calibration_update.model_dump().items(): + if attr not in { + "threshold_sources", + "classification_sources", + "method_sources", + "created_at", + "created_by", + "modified_at", + "modified_by", + "score_set_urn", + }: + setattr(calibration, attr, value) + + calibration.score_set = containing_score_set + calibration.publication_identifier_associations = updated_assocs + calibration.modified_by = user + + db.add(calibration) + return calibration + + +def publish_score_calibration(db: Session, calibration: ScoreCalibration, user: User) -> ScoreCalibration: + """Publish a private ScoreCalibration, marking it as publicly accessible. + + Parameters + ---------- + db : Session + Active SQLAlchemy session used to stage the update. + calibration : ScoreCalibration + The calibration instance to publish. Must currently be private. + user : User + The user performing the publish action; recorded in `modified_by`. + + Returns + ------- + ScoreCalibration + The updated calibration instance with `private` set to False. + + Raises + ------ + ValueError + If the calibration is already published (i.e., `private` is False). + + Notes + ----- + 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: + raise ValueError("Calibration is already published.") + + calibration.private = False + calibration.modified_by = user + + db.add(calibration) + return calibration + + +def promote_score_calibration_to_primary( + db: Session, calibration: ScoreCalibration, user: User, force: bool = False +) -> ScoreCalibration: + """ + Promote a non-primary score calibration to be the primary calibration for its score set. + + This function enforces several business rules before promotion: + 1. The calibration must not already be primary. + 2. It must not be marked as research-use-only. + 3. It must not be private. + 4. If another primary calibration already exists for the same score set, promotion is blocked + unless force=True is provided. When force=True, any existing primary calibration(s) are + demoted (their primary flag set to False) and updated with the acting user. + + Parameters: + db (Session): An active SQLAlchemy session used for querying and persisting changes. + calibration (ScoreCalibration): The calibration object to promote. + user (User): The user performing the promotion; recorded as the modifier. + force (bool, optional): If True, override an existing primary calibration by demoting it. + Defaults to False. + + Returns: + ScoreCalibration: The updated calibration instance now marked as primary. + + Raises: + ValueError: + - If the calibration is already primary. + - If the calibration is research-use-only. + - If the calibration is private. + - If another primary calibration exists for the score set and force is False. + + Side Effects: + - Marks the provided calibration as primary and updates its modified_by field. + - When force=True, demotes any existing primary calibration(s) in the same score set. + + Notes: + - The caller is responsible for committing the transaction after this function returns. + - Multiple existing primary calibrations (should not normally occur) are all demoted if force=True. + """ + if calibration.primary: + raise ValueError("Calibration is already primary.") + + if calibration.research_use_only: + raise ValueError("Cannot promote a research use only calibration to primary.") + + if calibration.private: + raise ValueError("Cannot promote a private calibration to primary.") + + existing_primary_calibrations = ( + db.query(ScoreCalibration) + .filter( + ScoreCalibration.score_set_id == calibration.score_set_id, + ScoreCalibration.primary.is_(True), + ScoreCalibration.id != calibration.id, + ) + .all() + ) + + if existing_primary_calibrations and not force: + raise ValueError("Another primary calibration already exists for this score set. Use force=True to override.") + elif force: + for primary_calibration in existing_primary_calibrations: + primary_calibration.primary = False + primary_calibration.modified_by = user + db.add(primary_calibration) + + calibration.primary = True + calibration.modified_by = user + + db.add(calibration) + return calibration + + +def demote_score_calibration_from_primary(db: Session, calibration: ScoreCalibration, user: User) -> ScoreCalibration: + """ + Demote a score calibration from primary status. + + This function marks the provided ScoreCalibration instance as non-primary by + setting its `primary` attribute to False and updating its `modified_by` field + with the acting user. The updated calibration is added to the SQLAlchemy session + but the session is not committed; callers are responsible for committing or + rolling back the transaction. + + Parameters: + db (Session): An active SQLAlchemy session used to persist the change. + calibration (ScoreCalibration): The score calibration object currently marked as primary. + user (User): The user performing the operation; recorded in `modified_by`. + + Returns: + ScoreCalibration: The updated calibration instance with `primary` set to False. + + Raises: + ValueError: If the provided calibration is not currently marked as primary. + """ + if not calibration.primary: + raise ValueError("Calibration is not primary.") + + calibration.primary = False + calibration.modified_by = user + + db.add(calibration) + return calibration + + +def delete_score_calibration(db: Session, calibration: ScoreCalibration) -> None: + """ + Delete a non-primary score calibration record from the database. + + This function removes the provided ScoreCalibration instance from the SQLAlchemy + session. Primary calibrations are protected from deletion and must be demoted + (i.e., have their `primary` flag unset) before they can be deleted. + + Parameters: + db (Session): An active SQLAlchemy session used to perform the delete operation. + calibration (ScoreCalibration): The calibration object to be deleted. + + Raises: + ValueError: If the calibration is marked as primary. + + Returns: + None + """ + if calibration.primary: + raise ValueError("Cannot delete a primary calibration. Demote it first.") + + db.delete(calibration) + return None diff --git a/src/mavedb/lib/urns.py b/src/mavedb/lib/urns.py index f58c8b96..e3903ac8 100644 --- a/src/mavedb/lib/urns.py +++ b/src/mavedb/lib/urns.py @@ -142,3 +142,14 @@ def generate_collection_urn(): :return: A new collection URN """ return f"urn:mavedb:collection-{uuid4()}" + + +def generate_calibration_urn(): + """ + Generate a new URN for a calibration. + + Calibration URNs include a 16-digit UUID. + + :return: A new calibration URN + """ + return f"urn:mavedb:calibration-{uuid4()}" diff --git a/src/mavedb/lib/validation/transform.py b/src/mavedb/lib/validation/transform.py index 0051cab8..2152eff9 100644 --- a/src/mavedb/lib/validation/transform.py +++ b/src/mavedb/lib/validation/transform.py @@ -9,11 +9,13 @@ from pydantic import TypeAdapter +from mavedb.models.enums.score_calibration_relation import ScoreCalibrationRelation from mavedb.models.enums.contribution_role import ContributionRole from mavedb.models.experiment_set import ExperimentSet from mavedb.models.collection_user_association import CollectionUserAssociation from mavedb.models.experiment_publication_identifier import ExperimentPublicationIdentifierAssociation from mavedb.models.score_set_publication_identifier import ScoreSetPublicationIdentifierAssociation +from mavedb.models.score_calibration_publication_identifier import ScoreCalibrationPublicationIdentifierAssociation from mavedb.models.experiment import Experiment from mavedb.models.score_set import ScoreSet from mavedb.models.target_gene import TargetGene @@ -51,20 +53,28 @@ def transform_score_set_to_urn(score_set: Optional[ScoreSet]) -> Optional[str]: return score_set.urn -PublicationIdentifierAssociation = Union[ +# TODO#523: Reduce code duplication during publication identifier transformation + +RecordPublicationIdentifierAssociation = Union[ ExperimentPublicationIdentifierAssociation, ScoreSetPublicationIdentifierAssociation ] -class TransformedPublicationIdentifiers(TypedDict): +class TransformedScoreSetPublicationIdentifiers(TypedDict): primary_publication_identifiers: list[PublicationIdentifier] secondary_publication_identifiers: list[PublicationIdentifier] -def transform_publication_identifiers_to_primary_and_secondary( - publication_identifiers: Optional[Sequence[PublicationIdentifierAssociation]], -) -> TransformedPublicationIdentifiers: - transformed_publication_identifiers = TransformedPublicationIdentifiers( +class TransformedCalibrationPublicationIdentifiers(TypedDict): + threshold_sources: list[PublicationIdentifier] + classification_sources: list[PublicationIdentifier] + method_sources: list[PublicationIdentifier] + + +def transform_record_publication_identifiers( + publication_identifiers: Optional[Sequence[RecordPublicationIdentifierAssociation]], +) -> TransformedScoreSetPublicationIdentifiers: + transformed_publication_identifiers = TransformedScoreSetPublicationIdentifiers( primary_publication_identifiers=[], secondary_publication_identifiers=[] ) @@ -85,6 +95,35 @@ def transform_publication_identifiers_to_primary_and_secondary( return transformed_publication_identifiers +def transform_score_calibration_publication_identifiers( + publication_identifiers: Optional[Sequence[ScoreCalibrationPublicationIdentifierAssociation]], +) -> TransformedCalibrationPublicationIdentifiers: + transformed_publication_identifiers = TransformedCalibrationPublicationIdentifiers( + threshold_sources=[], classification_sources=[], method_sources=[] + ) + + if not publication_identifiers: + return transformed_publication_identifiers + + transformed_publication_identifiers["threshold_sources"] = [ + TypeAdapter(PublicationIdentifier).validate_python(assc.publication) + for assc in publication_identifiers + if assc.relation is ScoreCalibrationRelation.threshold + ] + transformed_publication_identifiers["classification_sources"] = [ + TypeAdapter(PublicationIdentifier).validate_python(assc.publication) + for assc in publication_identifiers + if assc.relation is ScoreCalibrationRelation.classification + ] + transformed_publication_identifiers["method_sources"] = [ + TypeAdapter(PublicationIdentifier).validate_python(assc.publication) + for assc in publication_identifiers + if assc.relation is ScoreCalibrationRelation.method + ] + + return transformed_publication_identifiers + + def transform_external_identifier_offsets_to_list(data: TargetGene) -> list[ExternalGeneIdentifierOffset]: ensembl_offset = data.ensembl_offset refseq_offset = data.refseq_offset diff --git a/src/mavedb/models/__init__.py b/src/mavedb/models/__init__.py index 08a089f0..684b3c98 100644 --- a/src/mavedb/models/__init__.py +++ b/src/mavedb/models/__init__.py @@ -20,6 +20,7 @@ "refseq_offset", "role", "score_set", + "score_calibration", "target_gene", "target_sequence", "taxonomy", diff --git a/src/mavedb/models/enums/score_calibration_relation.py b/src/mavedb/models/enums/score_calibration_relation.py new file mode 100644 index 00000000..1c682479 --- /dev/null +++ b/src/mavedb/models/enums/score_calibration_relation.py @@ -0,0 +1,7 @@ +import enum + + +class ScoreCalibrationRelation(enum.Enum): + threshold = "threshold" + classification = "classification" + method = "method" diff --git a/src/mavedb/models/score_calibration.py b/src/mavedb/models/score_calibration.py new file mode 100644 index 00000000..988d4d04 --- /dev/null +++ b/src/mavedb/models/score_calibration.py @@ -0,0 +1,71 @@ +"""SQLAlchemy model for variant score calibrations.""" + +from __future__ import annotations + +from datetime import date +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, Column, Date, Float, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy +from sqlalchemy.orm import Mapped, relationship + +from mavedb.db.base import Base +from mavedb.lib.urns import generate_calibration_urn +from mavedb.models.score_calibration_publication_identifier import ScoreCalibrationPublicationIdentifierAssociation + +if TYPE_CHECKING: + from mavedb.models.publication_identifier import PublicationIdentifier + from mavedb.models.score_set import ScoreSet + from mavedb.models.user import User + + +class ScoreCalibration(Base): + __tablename__ = "score_calibrations" + # TODO#544: Add a partial unique index to enforce only one primary calibration per score set. + + id = Column(Integer, primary_key=True) + urn = Column(String(64), nullable=True, default=generate_calibration_urn, unique=True, index=True) + + score_set_id = Column(Integer, ForeignKey("scoresets.id"), nullable=False) + score_set: Mapped["ScoreSet"] = relationship("ScoreSet", back_populates="score_calibrations") + + title = Column(String, nullable=False) + research_use_only = Column(Boolean, nullable=False, default=False) + primary = Column(Boolean, nullable=False, default=False) + investigator_provided = Column(Boolean, nullable=False, default=False) + private = Column(Boolean, nullable=False, default=True) + notes = Column(String, nullable=True) + + baseline_score = Column(Float, nullable=True) + baseline_score_description = Column(String, nullable=True) + + # Ranges and sources are stored as JSONB (intersection structure) to avoid complex joins for now. + # ranges: list[ { label, description?, classification, range:[lower,upper], inclusive_lower_bound, inclusive_upper_bound } ] + functional_ranges = Column(JSONB(none_as_null=True), nullable=True) + + publication_identifier_associations: Mapped[list[ScoreCalibrationPublicationIdentifierAssociation]] = relationship( + "ScoreCalibrationPublicationIdentifierAssociation", + back_populates="score_calibration", + cascade="all, delete-orphan", + ) + publication_identifiers: AssociationProxy[list[PublicationIdentifier]] = association_proxy( + "publication_identifier_associations", + "publication", + creator=lambda p: ScoreCalibrationPublicationIdentifierAssociation(publication=p, relation=p.relation), + ) + + calibration_metadata = Column(JSONB(none_as_null=True), nullable=True) + + 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) + modified_by: Mapped["User"] = relationship("User", foreign_keys="ScoreCalibration.modified_by_id") + creation_date = Column(Date, nullable=False, default=date.today) + modification_date = Column(Date, nullable=False, default=date.today, onupdate=date.today) + + def __repr__(self) -> str: # pragma: no cover - repr utility + return ( + f"" + ) diff --git a/src/mavedb/models/score_calibration_publication_identifier.py b/src/mavedb/models/score_calibration_publication_identifier.py new file mode 100644 index 00000000..b0265825 --- /dev/null +++ b/src/mavedb/models/score_calibration_publication_identifier.py @@ -0,0 +1,32 @@ +# Prevent circular imports +from typing import TYPE_CHECKING + +from sqlalchemy import Column, ForeignKey, Integer, Enum +from sqlalchemy.orm import Mapped, relationship + +from mavedb.db.base import Base +from mavedb.models.enums.score_calibration_relation import ScoreCalibrationRelation + +if TYPE_CHECKING: + from mavedb.models.publication_identifier import PublicationIdentifier + from mavedb.models.score_calibration import ScoreCalibration + + +class ScoreCalibrationPublicationIdentifierAssociation(Base): + __tablename__ = "score_calibration_publication_identifiers" + + score_calibration_id = Column( + "score_calibration_id", Integer, ForeignKey("score_calibrations.id"), primary_key=True + ) + publication_identifier_id = Column(Integer, ForeignKey("publication_identifiers.id"), primary_key=True) + relation: Mapped["ScoreCalibrationRelation"] = Column( + Enum(ScoreCalibrationRelation, native_enum=False, validate_strings=True, length=32), + nullable=False, + default=ScoreCalibrationRelation.threshold, + primary_key=True, + ) + + score_calibration: Mapped["ScoreCalibration"] = relationship( + "mavedb.models.score_calibration.ScoreCalibration", back_populates="publication_identifier_associations" + ) + publication: Mapped["PublicationIdentifier"] = relationship("PublicationIdentifier") diff --git a/src/mavedb/models/score_set.py b/src/mavedb/models/score_set.py index 4fe85359..03723590 100644 --- a/src/mavedb/models/score_set.py +++ b/src/mavedb/models/score_set.py @@ -24,6 +24,7 @@ from mavedb.models.collection import Collection from mavedb.models.target_gene import TargetGene from mavedb.models.variant import Variant + from mavedb.models.score_calibration import ScoreCalibration # from .raw_read_identifier import SraIdentifier from mavedb.lib.temp_urns import generate_temp_urn @@ -182,7 +183,10 @@ class ScoreSet(Base): ) target_genes: Mapped[List["TargetGene"]] = relationship(back_populates="score_set", cascade="all, delete-orphan") - score_ranges = Column(JSONB, nullable=True) + + score_calibrations: Mapped[List["ScoreCalibration"]] = relationship( + "ScoreCalibration", back_populates="score_set", cascade="all, delete-orphan" + ) collections: Mapped[list["Collection"]] = relationship( "Collection", diff --git a/src/mavedb/routers/permissions.py b/src/mavedb/routers/permissions.py index c10f49e2..7a16f063 100644 --- a/src/mavedb/routers/permissions.py +++ b/src/mavedb/routers/permissions.py @@ -13,6 +13,7 @@ from mavedb.models.collection import Collection from mavedb.models.experiment import Experiment from mavedb.models.experiment_set import ExperimentSet +from mavedb.models.score_calibration import ScoreCalibration from mavedb.models.score_set import ScoreSet router = APIRouter( @@ -30,6 +31,7 @@ class ModelName(str, Enum): experiment = "experiment" experiment_set = "experiment-set" score_set = "score-set" + score_calibration = "score-calibration" @router.get( @@ -50,7 +52,7 @@ async def check_permission( """ save_to_logging_context({"requested_resource": urn}) - item: Optional[Union[Collection, ExperimentSet, Experiment, ScoreSet]] = None + item: Optional[Union[Collection, ExperimentSet, Experiment, ScoreSet, ScoreCalibration]] = None if model_name == ModelName.experiment_set: item = db.query(ExperimentSet).filter(ExperimentSet.urn == urn).one_or_none() @@ -60,6 +62,8 @@ async def check_permission( item = db.query(ScoreSet).filter(ScoreSet.urn == urn).one_or_none() elif model_name == ModelName.collection: item = db.query(Collection).filter(Collection.urn == urn).one_or_none() + elif model_name == ModelName.score_calibration: + item = db.query(ScoreCalibration).filter(ScoreCalibration.urn == urn).one_or_none() if item: permission = has_permission(user_data, item, action).permitted diff --git a/src/mavedb/routers/score_calibrations.py b/src/mavedb/routers/score_calibrations.py new file mode 100644 index 00000000..daac1950 --- /dev/null +++ b/src/mavedb/routers/score_calibrations.py @@ -0,0 +1,380 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import Optional +from sqlalchemy.orm import Session + +from mavedb import deps +from mavedb.lib.logging import LoggedRoute +from mavedb.lib.logging.context import ( + logging_context, + save_to_logging_context, +) +from mavedb.lib.authentication import get_current_user, UserData +from mavedb.lib.authorization import require_current_user +from mavedb.lib.permissions import Action, assert_permission, has_permission +from mavedb.lib.score_calibrations import ( + create_score_calibration_in_score_set, + modify_score_calibration, + delete_score_calibration, + demote_score_calibration_from_primary, + promote_score_calibration_to_primary, + publish_score_calibration, +) +from mavedb.models.score_calibration import ScoreCalibration +from mavedb.routers.score_sets import fetch_score_set_by_urn +from mavedb.view_models import score_calibration + + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/v1/score-calibrations", + tags=["score-calibrations"], + responses={404: {"description": "Not found"}}, + route_class=LoggedRoute, +) + + +@router.get( + "/{urn}", + response_model=score_calibration.ScoreCalibrationWithScoreSetUrn, + responses={404: {}}, +) +def get_score_calibration( + *, + urn: str, + db: Session = Depends(deps.get_db), + user_data: Optional[UserData] = Depends(get_current_user), +) -> ScoreCalibration: + """ + Retrieve a score calibration by its URN. + """ + save_to_logging_context({"requested_resource": urn}) + + item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + if not item: + logger.debug("The requested score calibration does not exist", extra=logging_context()) + raise HTTPException(status_code=404, detail="The requested score calibration does not exist") + + assert_permission(user_data, item, Action.READ) + return item + + +@router.get( + "/score-set/{score_set_urn}", + response_model=list[score_calibration.ScoreCalibrationWithScoreSetUrn], + responses={404: {}}, +) +async def get_score_calibrations_for_score_set( + *, + score_set_urn: str, + db: Session = Depends(deps.get_db), + user_data: Optional[UserData] = Depends(get_current_user), +) -> list[ScoreCalibration]: + """ + Retrieve all score calibrations for a given score set URN. + """ + save_to_logging_context({"requested_resource": score_set_urn, "resource_property": "calibrations"}) + score_set = await fetch_score_set_by_urn(db, score_set_urn, user_data, None, False) + + permitted_calibrations = [ + calibration + for calibration in score_set.score_calibrations + if has_permission(user_data, calibration, Action.READ).permitted + ] + 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") + + return permitted_calibrations + + +@router.get( + "/score-set/{score_set_urn}/primary", + response_model=score_calibration.ScoreCalibrationWithScoreSetUrn, + responses={404: {}}, +) +async def get_primary_score_calibrations_for_score_set( + *, + score_set_urn: str, + db: Session = Depends(deps.get_db), + user_data: Optional[UserData] = Depends(get_current_user), +) -> ScoreCalibration: + """ + Retrieve the primary score calibration for a given score set URN. + """ + save_to_logging_context({"requested_resource": score_set_urn, "resource_property": "calibrations"}) + score_set = await fetch_score_set_by_urn(db, score_set_urn, user_data, None, False) + + permitted_calibrations = [ + calibration + for calibration in score_set.score_calibrations + if has_permission(user_data, calibration, Action.READ) + ] + 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 primary score calibrations found for the requested score set") + + primary_calibrations = [c for c in permitted_calibrations if c.primary] + if not primary_calibrations: + logger.debug("No primary score calibrations found for the requested score set", extra=logging_context()) + raise HTTPException(status_code=404, detail="No primary score calibrations found for the requested score set") + elif len(primary_calibrations) > 1: + logger.error( + "Multiple primary score calibrations found for the requested score set", + extra={**logging_context(), "num_primary_calibrations": len(primary_calibrations)}, + ) + raise HTTPException( + status_code=500, + detail="Multiple primary score calibrations found for the requested score set", + ) + + return primary_calibrations[0] + + +@router.post( + "/", + response_model=score_calibration.ScoreCalibrationWithScoreSetUrn, + responses={404: {}}, +) +async def create_score_calibration_route( + *, + calibration: score_calibration.ScoreCalibrationCreate, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user), +) -> ScoreCalibration: + """ + Create a new score calibration. + + The score set URN must be provided to associate the calibration with an existing score set. + The user must have write permission on the associated score set. + """ + if not calibration.score_set_urn: + raise HTTPException(status_code=422, detail="score_set_urn must be provided to create a score calibration.") + + save_to_logging_context({"requested_resource": calibration.score_set_urn, "resource_property": "calibrations"}) + + score_set = await fetch_score_set_by_urn(db, calibration.score_set_urn, user_data, None, False) + # TODO#539: Allow any authenticated user to upload a score calibration for a score set, not just those with + # permission to update the score set itself. + assert_permission(user_data, score_set, Action.UPDATE) + + created_calibration = await create_score_calibration_in_score_set(db, calibration, user_data.user) + + db.commit() + db.refresh(created_calibration) + + return created_calibration + + +@router.put( + "/{urn}", + response_model=score_calibration.ScoreCalibrationWithScoreSetUrn, + responses={404: {}}, +) +async def modify_score_calibration_route( + *, + urn: str, + calibration_update: score_calibration.ScoreCalibrationModify, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user), +) -> ScoreCalibration: + """ + Modify an existing score calibration by its URN. + """ + save_to_logging_context({"requested_resource": urn}) + + # If the user supplies a new score_set_urn, validate it exists and the user has permission to use it. + if calibration_update.score_set_urn is not None: + score_set = await fetch_score_set_by_urn(db, calibration_update.score_set_urn, user_data, None, False) + + # TODO#539: Allow any authenticated user to upload a score calibration for a score set, not just those with + # permission to update the score set itself. + assert_permission(user_data, score_set, Action.UPDATE) + + item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + if not item: + logger.debug("The requested score calibration does not exist", extra=logging_context()) + raise HTTPException(status_code=404, detail="The requested score calibration does not exist") + + assert_permission(user_data, item, Action.UPDATE) + + updated_calibration = await modify_score_calibration(db, item, calibration_update, user_data.user) + + db.commit() + db.refresh(updated_calibration) + + return updated_calibration + + +@router.delete( + "/{urn}", + response_model=None, + responses={404: {}}, + status_code=204, +) +async def delete_score_calibration_route( + *, + urn: str, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user), +) -> None: + """ + Delete an existing score calibration by its URN. + """ + save_to_logging_context({"requested_resource": urn}) + + item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + if not item: + logger.debug("The requested score calibration does not exist", extra=logging_context()) + raise HTTPException(status_code=404, detail="The requested score calibration does not exist") + + assert_permission(user_data, item, Action.DELETE) + + delete_score_calibration(db, item) + db.commit() + + return None + + +@router.post( + "/{urn}/promote-to-primary", + response_model=score_calibration.ScoreCalibrationWithScoreSetUrn, + responses={404: {}}, +) +async def promote_score_calibration_to_primary_route( + *, + urn: str, + demote_existing_primary: bool = Query( + False, description="Whether to demote any existing primary calibration", alias="demoteExistingPrimary" + ), + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user), +) -> ScoreCalibration: + """ + Promote a score calibration to be the primary calibration for its associated score set. + """ + save_to_logging_context( + {"requested_resource": urn, "resource_property": "primary", "demote_existing_primary": demote_existing_primary} + ) + + item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + if not item: + logger.debug("The requested score calibration does not exist", extra=logging_context()) + raise HTTPException(status_code=404, detail="The requested score calibration does not exist") + + assert_permission(user_data, item, Action.CHANGE_RANK) + + if item.primary: + logger.debug("The requested score calibration is already primary", extra=logging_context()) + return item + + if item.research_use_only: + logger.debug("Research use only score calibrations cannot be promoted to primary", extra=logging_context()) + raise HTTPException( + status_code=400, detail="Research use only score calibrations cannot be promoted to primary" + ) + + if item.private: + 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") + + # 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) + if existing_primary_calibration and not demote_existing_primary: + logger.debug( + "A primary score calibration already exists for this score set", + extra={**logging_context(), "existing_primary_urn": existing_primary_calibration.urn}, + ) + raise HTTPException( + status_code=400, + detail="A primary score calibration already exists for this score set. Demote it first or pass demoteExistingPrimary=True.", + ) + elif existing_primary_calibration and demote_existing_primary: + assert_permission(user_data, existing_primary_calibration, Action.CHANGE_RANK) + + promoted_calibration = promote_score_calibration_to_primary(db, item, user_data.user, demote_existing_primary) + db.commit() + db.refresh(promoted_calibration) + + return promoted_calibration + + +@router.post( + "/{urn}/demote-from-primary", + response_model=score_calibration.ScoreCalibrationWithScoreSetUrn, + responses={404: {}}, +) +def demote_score_calibration_from_primary_route( + *, + urn: str, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user), +) -> ScoreCalibration: + """ + Demote a score calibration from being the primary calibration for its associated score set. + """ + save_to_logging_context({"requested_resource": urn, "resource_property": "primary"}) + + item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + if not item: + logger.debug("The requested score calibration does not exist", extra=logging_context()) + raise HTTPException(status_code=404, detail="The requested score calibration does not exist") + + assert_permission(user_data, item, Action.CHANGE_RANK) + + if not item.primary: + logger.debug("The requested score calibration is not primary", extra=logging_context()) + return item + + demoted_calibration = demote_score_calibration_from_primary(db, item, user_data.user) + db.commit() + db.refresh(demoted_calibration) + + return demoted_calibration + + +@router.post( + "/{urn}/publish", + response_model=score_calibration.ScoreCalibrationWithScoreSetUrn, + responses={404: {}}, +) +def publish_score_calibration_route( + *, + urn: str, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user), +) -> ScoreCalibration: + """ + Publish a score calibration, making it publicly visible. + """ + save_to_logging_context({"requested_resource": urn, "resource_property": "private"}) + + item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + if not item: + logger.debug("The requested score calibration does not exist", extra=logging_context()) + raise HTTPException(status_code=404, detail="The requested score calibration does not exist") + + assert_permission(user_data, item, Action.PUBLISH) + + if not item.private: + logger.debug("The requested score calibration is already public", extra=logging_context()) + return item + + # XXX: desired? + # if item.score_set.private: + # logger.debug( + # "Score calibrations associated with private score sets cannot be published", extra=logging_context() + # ) + # raise HTTPException( + # status_code=400, + # detail="Score calibrations associated with private score sets cannot be published. First publish the score set, then calibrations.", + # ) + + item = publish_score_calibration(db, item, user_data.user) + db.commit() + db.refresh(item) + + return item diff --git a/src/mavedb/routers/score_sets.py b/src/mavedb/routers/score_sets.py index 6ee49235..f3e2ba82 100644 --- a/src/mavedb/routers/score_sets.py +++ b/src/mavedb/routers/score_sets.py @@ -12,8 +12,8 @@ from ga4gh.va_spec.acmg_2015 import VariantPathogenicityEvidenceLine from ga4gh.va_spec.base.core import ExperimentalVariantFunctionalImpactStudyResult, Statement from pydantic import ValidationError -from sqlalchemy import null, or_, select -from sqlalchemy.exc import MultipleResultsFound, NoResultFound +from sqlalchemy import or_, select +from sqlalchemy.exc import MultipleResultsFound from sqlalchemy.orm import Session, contains_eager from mavedb import deps @@ -25,7 +25,6 @@ from mavedb.lib.annotation.exceptions import MappingDataDoesntExistException from mavedb.lib.authentication import UserData from mavedb.lib.authorization import ( - RoleRequirer, get_current_user, require_current_user, require_current_user_with_email, @@ -45,6 +44,7 @@ save_to_logging_context, ) from mavedb.lib.permissions import Action, assert_permission, has_permission +from mavedb.lib.score_calibrations import create_score_calibration from mavedb.lib.score_sets import ( csv_data_to_df, fetch_score_set_search_filter_options, @@ -66,17 +66,17 @@ from mavedb.models.clinical_control import ClinicalControl from mavedb.models.contributor import Contributor from mavedb.models.enums.processing_state import ProcessingState -from mavedb.models.enums.user_role import UserRole from mavedb.models.experiment import Experiment from mavedb.models.gnomad_variant import GnomADVariant from mavedb.models.license import License from mavedb.models.mapped_variant import MappedVariant +from mavedb.models.score_calibration import ScoreCalibration from mavedb.models.score_set import ScoreSet from mavedb.models.target_accession import TargetAccession from mavedb.models.target_gene import TargetGene from mavedb.models.target_sequence import TargetSequence from mavedb.models.variant import Variant -from mavedb.view_models import clinical_control, gnomad_variant, mapped_variant, score_range, score_set +from mavedb.view_models import clinical_control, gnomad_variant, mapped_variant, score_set from mavedb.view_models.contributor import ContributorCreate from mavedb.view_models.doi_identifier import DoiIdentifierCreate from mavedb.view_models.publication_identifier import PublicationIdentifierCreate @@ -126,10 +126,10 @@ async def enqueue_variant_creation( # "counts": item.dataset_columns["count_columns"], # } count_columns = [ - "hgvs_nt", - "hgvs_splice", - "hgvs_pro", - ] + item.dataset_columns["count_columns"] + "hgvs_nt", + "hgvs_splice", + "hgvs_pro", + ] + item.dataset_columns["count_columns"] existing_counts_df = pd.DataFrame( variants_to_csv_rows(item.variants, columns=count_columns, namespaced=False) ).replace("NA", pd.NA) @@ -184,7 +184,6 @@ async def score_set_update( for var, value in item_update_dict.items(): if var not in [ "contributors", - "score_ranges", "doi_identifiers", "experiment_urn", "license_id", @@ -277,9 +276,6 @@ async def score_set_update( # Score set has not been published and attributes affecting scores may still be edited. if item.private: - if "score_ranges" in item_update_dict: - item.score_ranges = item_update_dict.get("score_ranges", null()) - if "target_genes" in item_update_dict: # stash existing target gene ids to compare after update, to determine if variants need to be re-created assert all(tg.id is not None for tg in item.target_genes) @@ -483,6 +479,8 @@ 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] + return item @@ -647,8 +645,7 @@ def get_score_set_variants_csv( start: int = Query(default=None, description="Start index for pagination"), limit: int = Query(default=None, description="Maximum number of variants to return"), namespaces: List[Literal["scores", "counts"]] = Query( - default=["scores"], - description="One or more data types to include: scores, counts, clinVar, gnomAD" + default=["scores"], description="One or more data types to include: scores, counts, clinVar, gnomAD" ), drop_na_columns: Optional[bool] = None, include_custom_columns: Optional[bool] = None, @@ -1211,6 +1208,13 @@ async def create_score_set( for publication in publication_identifiers: setattr(publication, "primary", publication.identifier in primary_identifiers) + score_calibrations: list[ScoreCalibration] = [] + if item_create.score_calibrations: + for calibration_create in item_create.score_calibrations: + created_calibration_item = await create_score_calibration(db, calibration_create, user_data.user) + created_calibration_item.investigator_provided = True # necessarily true on score set creation + score_calibrations.append(created_calibration_item) + targets: list[TargetGene] = [] accessions = False for gene in item_create.target_genes: @@ -1314,7 +1318,7 @@ async def create_score_set( "secondary_publication_identifiers", "superseded_score_set_urn", "target_genes", - "score_ranges", + "score_calibrations", }, ), experiment=experiment, @@ -1328,8 +1332,8 @@ async def create_score_set( processing_state=ProcessingState.incomplete, created_by=user_data.user, modified_by=user_data.user, - score_ranges=item_create.score_ranges.model_dump() if item_create.score_ranges else null(), - ) # type: ignore + score_calibrations=score_calibrations, + ) # type: ignore[call-arg] db.add(item) db.commit() @@ -1412,42 +1416,6 @@ async def upload_score_set_variant_data( return score_set.ScoreSet.model_validate(item).copy(update={"experiment": enriched_experiment}) -@router.post( - "/score-sets/{urn}/ranges/data", - response_model=score_set.ScoreSet, - responses={422: {}}, - response_model_exclude_none=True, -) -async def update_score_set_range_data( - *, - urn: str, - range_update: score_range.ScoreSetRangesModify, - db: Session = Depends(deps.get_db), - user_data: UserData = Depends(RoleRequirer([UserRole.admin])), -): - """ - Update score ranges / calibrations for a score set. - """ - save_to_logging_context({"requested_resource": urn, "resource_property": "score_ranges"}) - - try: - item = db.scalars(select(ScoreSet).where(ScoreSet.urn == urn)).one() - except NoResultFound: - logger.info(msg="Failed to add score ranges; The requested score set does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") - - assert_permission(user_data, item, Action.UPDATE) - - item.score_ranges = range_update.dict() - db.add(item) - db.commit() - db.refresh(item) - - save_to_logging_context({"updated_resource": item.urn}) - enriched_experiment = enrich_experiment_with_num_score_sets(item.experiment, user_data) - return score_set.ScoreSet.model_validate(item).copy(update={"experiment": enriched_experiment}) - - @router.patch( "/score-sets-with-variants/{urn}", response_model=score_set.ScoreSet, diff --git a/src/mavedb/scripts/load_calibration_csv.py b/src/mavedb/scripts/load_calibration_csv.py new file mode 100644 index 00000000..5c3b2bba --- /dev/null +++ b/src/mavedb/scripts/load_calibration_csv.py @@ -0,0 +1,434 @@ +""" +This script loads calibration data from a CSV file into the database. + +CSV Format: +The CSV file must contain the following columns with their expected data types and formats: + +Core Metadata Columns: +- score_set_urn: The URN identifier for the score set (e.g., "urn:mavedb:00000657-a-1"). Can contain multiple URNs separated by commas. +- pp_data_set_tag: Tag identifying the PP data set (e.g., "ASPA_Grønbæk-Thygesen_2024_abundance"). +- calibration_name: Name of the calibration method (e.g., "investigator_provided", "cvfg_missense_vars", "cvfg_all_vars"). +- primary: Boolean value indicating if this is the primary calibration (TRUE/FALSE). +- calibration_notes_for_mavedb: Notes specific to MaveDB about this calibration (text, can be empty). +- notes: General notes about the calibration (text, can be empty). +- target_type: Type of target being analyzed (e.g., "synthetic", "endogenous"). +- calibration_notes: Additional calibration notes (text, can be empty). +- cite_brnich_method: Boolean indicating if Brnich method was cited (TRUE/FALSE). +- thresholds_pmid: PubMed ID for threshold methodology (numeric, can be empty). +- odds_path_pmid: PubMed ID for odds path methodology (e.g., "cvfg", numeric PMID, can be empty). + +Baseline Score Information: +- baseline_score: The baseline score value used for normalization (numeric, can be empty). +- baseline_score_notes: Additional notes about the baseline score (text, can be empty). + +Classification Class Columns (classes 1-5, following consistent naming pattern): +Class 1: +- class_1_range: The range for the first class (e.g., "(-Inf, 0.2)", "[-0.748, Inf)"). +- class_1_name: The name/label for the first class (e.g., "low abundance", "Functional"). +- class_1_functional_classification: The functional classification (e.g., "abnormal", "normal", "indeterminate"). +- class_1_odds_path: The odds path value for the first class (numeric, can be empty). +- class_1_strength: The strength of evidence (e.g., "PS3_MODERATE", "BS3_STRONG", can be empty). + +... + +Class 5: +- class_5: The range for the fifth class. +- class_5_name: The name/label for the fifth class. +- class_5_functional_classification: The functional classification for the fifth class. +- class_5_odds_path: The odds path value for the fifth class (numeric, can be empty). +- class_5_strength: The strength of evidence for the fifth class (can be empty). + +Usage: +This script loads calibration data from a CSV file into the database, creating score calibrations +for score sets based on the provided functional class ranges and evidence strengths. + +Command Line Interface: +The script uses Click for command-line argument parsing and requires a database session. + +Arguments: +- csv_path: Path to the input CSV file (required). Must exist and be readable. + +Options: +- --delimiter: CSV delimiter character (default: ",") +- --overwrite: Flag to overwrite existing calibration containers for each score set (default: False) +- --purge-publication-relationships: Flag to purge existing publication relationships (default: False) + +Behavior: +- Processes each row in the CSV file and creates score calibrations for the specified score sets +- Skips rows without valid URNs or functional class ranges +- Only replaces the targeted container key unless --overwrite is specified +- Uses the calibration_name field to determine the container key for the calibration +- Supports multiple URNs per row (comma-separated in the score_set_urn column) +- Automatically handles database session management through the @with_database_session decorator + +Example usage: +```bash +# Basic usage with default comma delimiter +python load_calibration_csv.py /path/to/calibration_data.csv + +# Use a different delimiter (e.g., semicolon) +python load_calibration_csv.py /path/to/calibration_data.csv --delimiter ";" + +# Overwrite existing calibration containers +python load_calibration_csv.py /path/to/calibration_data.csv --overwrite + +# Purge existing publication relationships before loading +python load_calibration_csv.py /path/to/calibration_data.csv --purge-publication-relationships + +# Combine multiple options +python load_calibration_csv.py /path/to/calibration_data.csv --delimiter ";" --overwrite --purge-publication-relationships +``` + +Exit Behavior: +The script will output summary statistics showing: +- Number of score sets updated +- Number of rows skipped (due to missing URNs or invalid ranges) +- Number of errors encountered +- Total number of rows processed + +""" + +import asyncio +import csv +import re +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional, Tuple + +import click +from sqlalchemy.orm import Session + +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.models import score_calibration +from mavedb.models.score_set import ScoreSet +from mavedb.models.user import User +from mavedb.scripts.environment import with_database_session +from mavedb.view_models.acmg_classification import ACMGClassificationCreate +from mavedb.view_models.publication_identifier import PublicationIdentifierCreate +from mavedb.view_models.score_calibration import FunctionalRangeCreate, ScoreCalibrationCreate + +BRNICH_PMID = "31892348" +RANGE_PATTERN = re.compile(r"^\s*([\[(])\s*([^,]+)\s*,\s*([^\])]+)\s*([])])\s*$", re.IGNORECASE) +INFINITY_TOKENS = {"inf", "+inf", "-inf", "infinity", "+infinity", "-infinity"} +MAX_RANGES = 5 + +NAME_ALIASES = { + "investigator_provided": "Investigator-provided functional classes", + "scott": "Scott calibration", + "cvfg_all_vars": "IGVF Coding Variant Focus Group -- Controls: All Variants", + "cvfg_missense_vars": "IGVF Coding Variant Focus Group -- Controls: Missense Variants Only", + "fayer": "Fayer calibration", +} + + +def parse_bound(raw: str) -> Optional[float]: + raw = raw.strip() + if not raw: + return None + rl = raw.lower() + if rl in INFINITY_TOKENS: + return None + try: + return float(raw) + except ValueError: + raise ValueError(f"Unparseable bound '{raw}'") + + +def parse_interval(text: str) -> Tuple[Optional[float], Optional[float], bool, bool]: + m = RANGE_PATTERN.match(text) + if not m: + raise ValueError(f"Invalid range format '{text}'") + left_br, lower_raw, upper_raw, right_br = m.groups() + lower = parse_bound(lower_raw) + upper = parse_bound(upper_raw) + inclusive_lower = left_br == "[" + inclusive_upper = right_br == "]" + if lower is not None and upper is not None: + if lower > upper: + raise ValueError("Lower bound greater than upper bound") + if lower == upper: + raise ValueError("Lower bound equals upper bound") + return lower, upper, inclusive_lower, inclusive_upper + + +def normalize_classification( + raw: Optional[str], strength: Optional[str] +) -> Literal["normal", "abnormal", "not_specified"]: + if raw: + r = raw.strip().lower() + if r in {"normal", "abnormal", "not_specified"}: + return r # type: ignore[return-value] + if r in {"indeterminate", "uncertain", "unknown"}: + return "not_specified" + + if strength: + if strength.upper().startswith("PS"): + return "abnormal" + if strength.upper().startswith("BS"): + return "normal" + + return "not_specified" + + +def build_publications( + cite_brnich: str, thresholds_pmid: str, oddspaths_pmid: str, calculation_pmid: str +) -> tuple[List[PublicationIdentifierCreate], List[PublicationIdentifierCreate], List[PublicationIdentifierCreate]]: + """Return (source_publications, oddspaths_publications). + + Rules: + - Brnich citation only goes to source when cite_brnich_method == TRUE. + - thresholds_pmid (if present) -> source only. + - oddspaths_pmid (if present) -> oddspaths_source only. + - calculation_pmid (if present) -> calculation_source only. + - Duplicates between lists preserved separately if same PMID used for both roles. + """ + threshold_pmids: set[str] = set() + method_pmids: set[str] = set() + calculation_pmids: set[str] = set() + + if cite_brnich and cite_brnich.strip().upper() == "TRUE": + method_pmids.add(BRNICH_PMID) + if thresholds_pmid and thresholds_pmid.strip(): + threshold_pmids.add(thresholds_pmid.strip()) + if oddspaths_pmid and oddspaths_pmid.strip(): + method_pmids.add(oddspaths_pmid.strip()) + if calculation_pmid and calculation_pmid.strip(): + calculation_pmids.add(calculation_pmid.strip()) + + threshold_pubs = [ + PublicationIdentifierCreate(identifier=p, db_name="PubMed") for p in sorted(threshold_pmids) if p != "cvfg" + ] + method_pubs = [ + PublicationIdentifierCreate(identifier=p, db_name="PubMed") for p in sorted(method_pmids) if p != "cvfg" + ] + calculation_pubs = [ + PublicationIdentifierCreate(identifier=p, db_name="PubMed") for p in sorted(calculation_pmids) if p != "cvfg" + ] + return threshold_pubs, method_pubs, calculation_pubs + + +def build_ranges(row: Dict[str, str], infer_strengths: bool = True) -> Tuple[List[Any], bool]: + ranges = [] + any_oddspaths = False + for i in range(1, MAX_RANGES + 1): + range_key = f"class_{i}_range" + interval_text = row.get(range_key, "").strip() + if not interval_text: + click.echo(f" Skipping empty interval in row: skipped class {i}", err=True) + continue + + try: + lower, upper, incl_lower, incl_upper = parse_interval(interval_text) + except ValueError as e: + click.echo(f" Skipping invalid interval in row: {e}; skipped class {i}", err=True) + continue + + strength_raw = row.get(f"class_{i}_strength", "").strip() + if strength_raw not in [ + "BS3_STRONG", + "BS3_MODERATE", + "BS3_SUPPORTING", + "INDETERMINATE", + "PS3_VERY_STRONG", + "PS3_STRONG", + "PS3_MODERATE", + "PS3_SUPPORTING", + "", + ]: + click.echo(f" Invalid strength '{strength_raw}' in row; inferring strength from oddspaths", err=True) + strength_raw = "" + + classification = normalize_classification(row.get(f"class_{i}_functional_classification"), strength_raw) + oddspaths_raw = row.get(f"class_{i}_odds_path", "").strip() + oddspaths_ratio = None + evidence_classification = None + if oddspaths_raw: + any_oddspaths = True + + try: + oddspaths_ratio = float(oddspaths_raw) + except ValueError: + click.echo(f" Skipping invalid odds_path '{oddspaths_raw}' in row; skipped class {i}", err=True) + continue + + if not strength_raw and infer_strengths: + criterion, strength = oddspaths_evidence_strength_equivalent(oddspaths_ratio) + elif strength_raw: + criterion = ACMGCriterion.PS3 if strength_raw.startswith("PS") else ACMGCriterion.BS3 + if strength_raw.endswith("VERY_STRONG"): + strength = StrengthOfEvidenceProvided.VERY_STRONG + elif strength_raw.endswith("STRONG"): + strength = StrengthOfEvidenceProvided.STRONG + elif strength_raw.endswith("MODERATE"): + strength = StrengthOfEvidenceProvided.MODERATE + elif strength_raw.endswith("SUPPORTING"): + strength = StrengthOfEvidenceProvided.SUPPORTING + else: + criterion, strength = None, None + + if criterion and strength: + evidence_classification = ACMGClassificationCreate(criterion=criterion, evidence_strength=strength) + else: + evidence_classification = None + + label = row.get(f"class_{i}_name", "").strip() + ranges.append( + FunctionalRangeCreate( + label=label, + classification=classification, + range=(lower, upper), + inclusive_lower_bound=incl_lower if lower is not None else False, + inclusive_upper_bound=incl_upper if upper is not None else False, + acmg_classification=evidence_classification, + oddspaths_ratio=oddspaths_ratio if oddspaths_ratio else None, + ) + ) + return ranges, any_oddspaths + + +@click.command() +@with_database_session +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, readable=True)) +@click.option("--delimiter", default=",", show_default=True, help="CSV delimiter") +@click.option("--overwrite", is_flag=True, default=False, help="Overwrite existing container for each score set") +@click.option( + "--purge-publication-relationships", is_flag=True, default=False, help="Purge existing publication relationships" +) +def main(db: Session, csv_path: str, delimiter: str, overwrite: bool, purge_publication_relationships: bool): + """Load calibration CSV into score set score_calibrations. + + Rows skipped if no URNs or no valid ranges. Only the targeted container key is replaced (unless --overwrite). + """ + path = Path(csv_path) + updated_sets = 0 + skipped_rows = 0 + errors = 0 + processed_rows = 0 + + with path.open(newline="", encoding="utf-8") as fh: + reader = csv.DictReader(fh, delimiter=delimiter) + for row in reader: + processed_rows += 1 + urn_cell = row.get("score_set_urn", "") + if not urn_cell: + skipped_rows += 1 + click.echo(f"No URN found in source CSV; skipping row {processed_rows}", err=True) + continue + + urns = [u.strip() for u in urn_cell.split(",") if u.strip()] + if not urns: + skipped_rows += 1 + click.echo(f"No URN found in source CSV; skipping row {processed_rows}", err=True) + continue + + click.echo(f"Processing row {processed_rows} for score set URNs: {', '.join(urns)}") + + threshold_pubs, method_pubs, calculation_pubs = build_publications( + row.get("cite_brnich_method", ""), + row.get("thresholds_pmid", ""), + row.get("methods_pmid", ""), + row.get("odds_path_pmid", ""), + ) + + ranges, any_oddspaths = build_ranges(row, infer_strengths=True) + + # baseline score only for brnich-style wrappers + baseline_raw = row.get("baseline_score", "").strip() + baseline_score = None + if baseline_raw: + try: + baseline_score = float(baseline_raw) + except ValueError: + click.echo( + f"Invalid baseline_score '{baseline_raw}' ignored; row {processed_rows} will still be processed", + err=True, + ) + + baseline_score_description_raw = row.get("baseline_score_notes", "").strip() + calibration_notes_raw = row.get("calibration_notes_for_mavedb", "").strip() + calibration_name_raw = row.get("calibration_name", "investigator_provided").strip().lower() + calibration_is_investigator_provided = calibration_name_raw == "investigator_provided" + calibration_name = NAME_ALIASES.get(calibration_name_raw, calibration_name_raw) + baseline_score_description = baseline_score_description_raw if baseline_score_description_raw else None + threshold_publications = threshold_pubs if threshold_pubs else [] + method_publications = method_pubs if method_pubs else [] + calculation_publications = calculation_pubs if calculation_pubs else [] + primary = row.get("primary", "").strip().upper() == "TRUE" + calibration_notes = calibration_notes_raw if calibration_notes_raw else None + + try: + created_score_calibration = ScoreCalibrationCreate( + title=calibration_name, + baseline_score=baseline_score, + baseline_score_description=baseline_score_description, + threshold_sources=threshold_publications, + method_sources=method_publications, + classification_sources=calculation_publications, + research_use_only=False, + functional_ranges=ranges, + notes=calibration_notes, + ) + except Exception as e: # broad to keep import running + errors += 1 + click.echo(f"Validation error building container: {e}; skipping row {processed_rows}", err=True) + continue + + for urn in urns: + created_score_calibration.score_set_urn = urn + score_set = db.query(ScoreSet).filter(ScoreSet.urn == urn).one_or_none() + if not score_set: + click.echo(f"Score set with URN {urn} not found; skipping row {processed_rows}", err=True) + errors += 1 + continue + + existing_calibration_object = ( + db.query(score_calibration.ScoreCalibration) + .filter( + score_calibration.ScoreCalibration.score_set_id == score_set.id, + score_calibration.ScoreCalibration.title == calibration_name, + ) + .one_or_none() + ) + if overwrite and existing_calibration_object: + replaced = True + db.delete(existing_calibration_object) + else: + replaced = False + + # Never purge primary relationships. + if purge_publication_relationships and score_set.publication_identifier_associations: + for assoc in score_set.publication_identifier_associations: + if {"identifier": assoc.publication.identifier, "db_name": assoc.publication.db_name} in [ + p.model_dump() + for p in threshold_publications + method_publications + calculation_publications + ] and not assoc.primary: + db.delete(assoc) + + if not replaced and existing_calibration_object: + skipped_rows += 1 + click.echo( + f"Calibration {existing_calibration_object.title} exists for {urn}; use --overwrite to replace; skipping row {processed_rows}", + err=True, + ) + continue + + system_user = db.query(User).filter(User.id == 1).one() + calibration_user = score_set.created_by if calibration_is_investigator_provided else system_user + new_calibration_object = asyncio.run( + create_score_calibration_in_score_set(db, created_score_calibration, calibration_user) + ) + new_calibration_object.primary = primary + new_calibration_object.private = False + + db.add(new_calibration_object) + db.flush() + updated_sets += 1 + + click.echo( + f"Processed {processed_rows} rows; Updated {updated_sets} score sets; Skipped {skipped_rows} rows; Errors {errors}." + ) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/mavedb/scripts/load_pp_style_calibration.py b/src/mavedb/scripts/load_pp_style_calibration.py index bfd46111..a05c9d67 100644 --- a/src/mavedb/scripts/load_pp_style_calibration.py +++ b/src/mavedb/scripts/load_pp_style_calibration.py @@ -1,18 +1,16 @@ -from typing import Callable import json import math -import click -from typing import List, Dict, Any, Optional +from typing import Any, Callable, Dict, List, Optional +import click from sqlalchemy.orm import Session -from mavedb.scripts.environment import with_database_session +from mavedb.lib.score_calibrations import create_score_calibration_in_score_set from mavedb.models.score_set import ScoreSet -from mavedb.view_models.score_range import ( - ZeibergCalibrationScoreRangeCreate, - ZeibergCalibrationScoreRangesCreate, - ScoreSetRangesCreate, -) +from mavedb.models.user import User +from mavedb.scripts.environment import with_database_session +from mavedb.view_models.acmg_classification import ACMGClassificationCreate +from mavedb.view_models.score_calibration import FunctionalRangeCreate, ScoreCalibrationCreate # Evidence strength ordering definitions PATH_STRENGTHS: List[int] = [1, 2, 3, 4, 8] @@ -47,9 +45,7 @@ def _collapse_duplicate_thresholds(m: dict[int, Optional[float]], comparator: Ca return collapsed -def build_pathogenic_ranges( - thresholds: List[Optional[float]], inverted: bool -) -> List[ZeibergCalibrationScoreRangeCreate]: +def build_pathogenic_ranges(thresholds: List[Optional[float]], inverted: bool) -> List[FunctionalRangeCreate]: raw_mapping = { strength: thresholds[idx] for idx, strength in enumerate(PATH_STRENGTHS) @@ -63,7 +59,7 @@ def build_pathogenic_ranges( available = [s for s in PATH_STRENGTHS if s in mapping] ordering = available[::-1] if not inverted else available - ranges: List[ZeibergCalibrationScoreRangeCreate] = [] + ranges: List[FunctionalRangeCreate] = [] for i, s in enumerate(ordering): lower: Optional[float] upper: Optional[float] @@ -76,20 +72,20 @@ def build_pathogenic_ranges( upper = mapping[s] ranges.append( - ZeibergCalibrationScoreRangeCreate( + FunctionalRangeCreate( label=str(s), classification="abnormal", - evidence_strength=s, range=(lower, upper), # Whichever bound interacts with infinity will always be exclusive, with the opposite always inclusive. inclusive_lower_bound=False if not inverted else True, inclusive_upper_bound=False if inverted else True, + acmg_classification=ACMGClassificationCreate(points=s), ) ) return ranges -def build_benign_ranges(thresholds: List[Optional[float]], inverted: bool) -> List[ZeibergCalibrationScoreRangeCreate]: +def build_benign_ranges(thresholds: List[Optional[float]], inverted: bool) -> List[FunctionalRangeCreate]: raw_mapping = { strength: thresholds[idx] for idx, strength in enumerate(BENIGN_STRENGTHS) @@ -103,7 +99,7 @@ def build_benign_ranges(thresholds: List[Optional[float]], inverted: bool) -> Li available = [s for s in BENIGN_STRENGTHS if s in mapping] ordering = available[::-1] if inverted else available - ranges: List[ZeibergCalibrationScoreRangeCreate] = [] + ranges: List[FunctionalRangeCreate] = [] for i, s in enumerate(ordering): lower: Optional[float] upper: Optional[float] @@ -116,14 +112,14 @@ def build_benign_ranges(thresholds: List[Optional[float]], inverted: bool) -> Li upper = mapping[s] ranges.append( - ZeibergCalibrationScoreRangeCreate( + FunctionalRangeCreate( label=str(s), classification="normal", - evidence_strength=s, range=(lower, upper), # Whichever bound interacts with infinity will always be exclusive, with the opposite always inclusive. inclusive_lower_bound=False if inverted else True, inclusive_upper_bound=False if not inverted else True, + acmg_classification=ACMGClassificationCreate(points=s), ) ) return ranges @@ -133,22 +129,17 @@ def build_benign_ranges(thresholds: List[Optional[float]], inverted: bool) -> Li @with_database_session @click.argument("json_path", type=click.Path(exists=True, dir_okay=False, readable=True)) @click.argument("score_set_urn", type=str) -@click.option("--overwrite", is_flag=True, default=False, help="Overwrite existing score_ranges if present.") -def main(db: Session, json_path: str, score_set_urn: str, overwrite: bool) -> None: +@click.argument("public", default=False, type=bool) +@click.argument("user_id", type=int) +def main(db: Session, json_path: str, score_set_urn: str, public: bool, user_id: int) -> None: """Load pillar project calibration JSON into a score set's zeiberg_calibration score ranges.""" score_set: Optional[ScoreSet] = db.query(ScoreSet).filter(ScoreSet.urn == score_set_urn).one_or_none() if not score_set: raise click.ClickException(f"Score set with URN {score_set_urn} not found") - if score_set.score_ranges and score_set.score_ranges["zeiberg_calibration"] and not overwrite: - raise click.ClickException( - "pillar project score ranges already present for this score set. Use --overwrite to replace them." - ) - - if not score_set.score_ranges: - existing_score_ranges = ScoreSetRangesCreate() - else: - existing_score_ranges = ScoreSetRangesCreate(**score_set.score_ranges) + user: Optional[User] = db.query(User).filter(User.id == user_id).one_or_none() + if not user: + raise click.ClickException(f"User with ID {user_id} not found") with open(json_path, "r") as fh: data: Dict[str, Any] = json.load(fh) @@ -164,10 +155,17 @@ def main(db: Session, json_path: str, score_set_urn: str, overwrite: bool) -> No if not path_ranges and not benign_ranges: raise click.ClickException("No valid thresholds found to build ranges.") - existing_score_ranges.zeiberg_calibration = ZeibergCalibrationScoreRangesCreate(ranges=path_ranges + benign_ranges) - score_set.score_ranges = existing_score_ranges.model_dump(exclude_none=True) - - db.add(score_set) + calibration_create = ScoreCalibrationCreate( + title="Zeiberg Calibration", + research_use_only=True, + primary=False, + investigator_provided=False, + private=not public, + functional_ranges=path_ranges + benign_ranges, + score_set_urn=score_set_urn, + ) + calibration = create_score_calibration_in_score_set(db, calibration_create, user) + db.add(calibration) click.echo( f"Loaded {len(path_ranges)} pathogenic and {len(benign_ranges)} benign ranges into score set {score_set_urn} (inverted={inverted})." ) diff --git a/src/mavedb/server_main.py b/src/mavedb/server_main.py index b3a2239f..1037b282 100644 --- a/src/mavedb/server_main.py +++ b/src/mavedb/server_main.py @@ -51,6 +51,7 @@ publication_identifiers, raw_read_identifiers, refget, + score_calibrations, score_sets, seqrepo, statistics, @@ -101,6 +102,7 @@ app.include_router(publication_identifiers.router) app.include_router(raw_read_identifiers.router) app.include_router(refget.router) +app.include_router(score_calibrations.router) app.include_router(score_sets.router) app.include_router(seqrepo.router) app.include_router(statistics.router) diff --git a/src/mavedb/view_models/__init__.py b/src/mavedb/view_models/__init__.py index 1aab1e14..d8fdfa27 100644 --- a/src/mavedb/view_models/__init__.py +++ b/src/mavedb/view_models/__init__.py @@ -6,5 +6,8 @@ def record_type_validator(): def set_record_type(cls, data): + if data is None: + return None + data.record_type = cls.__name__ return data diff --git a/src/mavedb/view_models/acmg_classification.py b/src/mavedb/view_models/acmg_classification.py new file mode 100644 index 00000000..05757442 --- /dev/null +++ b/src/mavedb/view_models/acmg_classification.py @@ -0,0 +1,83 @@ +"""Pydantic view models for ACMG-style classification and odds path entities. + +Provides validated structures for ACMG criteria, evidence strengths, point-based +classifications, and associated odds path ratios. +""" + +from typing import Optional +from pydantic import model_validator + +from mavedb.lib.exceptions import ValidationError +from mavedb.lib.acmg import ( + StrengthOfEvidenceProvided, + ACMGCriterion, + points_evidence_strength_equivalent, +) + +from mavedb.view_models import record_type_validator, set_record_type +from mavedb.view_models.base.base import BaseModel + + +class ACMGClassificationBase(BaseModel): + """Base ACMG classification model (criterion, evidence strength, points).""" + + criterion: Optional[ACMGCriterion] = None + evidence_strength: Optional[StrengthOfEvidenceProvided] = None + points: Optional[int] = None + + @model_validator(mode="after") + def criterion_and_evidence_strength_mutually_defined(self: "ACMGClassificationBase") -> "ACMGClassificationBase": + """Require criterion and evidence_strength to be provided together or both omitted.""" + if (self.criterion is None) != (self.evidence_strength is None): + raise ValidationError("Both a criterion and evidence_strength must be provided together") + return self + + @model_validator(mode="after") + def generate_criterion_and_evidence_strength_from_points( + self: "ACMGClassificationBase", + ) -> "ACMGClassificationBase": + """If points are provided but criterion and evidence_strength are not, infer them.""" + if self.points is not None and self.criterion is None and self.evidence_strength is None: + inferred_criterion, inferred_strength = points_evidence_strength_equivalent(self.points) + object.__setattr__(self, "criterion", inferred_criterion) + object.__setattr__(self, "evidence_strength", inferred_strength) + + return self + + @model_validator(mode="after") + def points_must_agree_with_evidence_strength(self: "ACMGClassificationBase") -> "ACMGClassificationBase": + """Validate that provided points imply the same criterion and evidence strength.""" + if self.points is not None: + inferred_criterion, inferred_strength = points_evidence_strength_equivalent(self.points) + if (self.criterion != inferred_criterion) or (self.evidence_strength != inferred_strength): + raise ValidationError( + "The provided points value does not agree with the provided criterion and evidence_strength. " + f"{self.points} points implies {inferred_criterion} and {inferred_strength}, but got {self.criterion} and {self.evidence_strength}." + ) + + return self + + +class ACMGClassificationModify(ACMGClassificationBase): + """Model used to modify an existing ACMG classification.""" + + pass + + +class ACMGClassificationCreate(ACMGClassificationModify): + """Model used to create a new ACMG classification.""" + + pass + + +class SavedACMGClassification(ACMGClassificationBase): + """Persisted ACMG classification model (includes record type metadata).""" + + record_type: str = None # type: ignore + _record_type_factory = record_type_validator()(set_record_type) + + +class ACMGClassification(SavedACMGClassification): + """Complete ACMG classification model returned by the API.""" + + pass diff --git a/src/mavedb/view_models/experiment.py b/src/mavedb/view_models/experiment.py index c7362bf3..b05766ff 100644 --- a/src/mavedb/view_models/experiment.py +++ b/src/mavedb/view_models/experiment.py @@ -7,7 +7,7 @@ from mavedb.lib.validation.transform import ( transform_experiment_set_to_urn, transform_score_set_list_to_urn_list, - transform_publication_identifiers_to_primary_and_secondary, + transform_record_publication_identifiers, ) from mavedb.lib.validation import urn_re from mavedb.lib.validation.utilities import is_null @@ -136,7 +136,7 @@ def generate_primary_and_secondary_publications(cls, data: Any): data, "secondary_publication_identifiers" ): try: - publication_identifiers = transform_publication_identifiers_to_primary_and_secondary( + publication_identifiers = transform_record_publication_identifiers( data.publication_identifier_associations ) data.__setattr__( diff --git a/src/mavedb/view_models/odds_path.py b/src/mavedb/view_models/odds_path.py deleted file mode 100644 index 9ab90b18..00000000 --- a/src/mavedb/view_models/odds_path.py +++ /dev/null @@ -1,44 +0,0 @@ -from typing import Literal, Optional -from pydantic import field_validator - -from mavedb.view_models import record_type_validator, set_record_type -from mavedb.view_models.base.base import BaseModel - - -class OddsPathBase(BaseModel): - ratio: float - evidence: Optional[ - Literal[ - "BS3_STRONG", - "BS3_MODERATE", - "BS3_SUPPORTING", - "INDETERMINATE", - "PS3_VERY_STRONG", - "PS3_STRONG", - "PS3_MODERATE", - "PS3_SUPPORTING", - ] - ] = None - - -class OddsPathModify(OddsPathBase): - @field_validator("ratio") - def ratio_must_be_positive(cls, value: float) -> float: - if value < 0: - raise ValueError("OddsPath value must be greater than or equal to 0") - - return value - - -class OddsPathCreate(OddsPathModify): - pass - - -class SavedOddsPath(OddsPathBase): - record_type: str = None # type: ignore - - _record_type_factory = record_type_validator()(set_record_type) - - -class OddsPath(SavedOddsPath): - pass diff --git a/src/mavedb/view_models/score_calibration.py b/src/mavedb/view_models/score_calibration.py new file mode 100644 index 00000000..00d5d692 --- /dev/null +++ b/src/mavedb/view_models/score_calibration.py @@ -0,0 +1,409 @@ +"""Pydantic view models for score calibration entities. + +Defines validated structures for functional score ranges, calibrations, and +associated publication/odds path references used by the API layer. +""" + +from datetime import date +from typing import Any, Collection, Literal, Optional, Sequence, Union + +from pydantic import field_validator, model_validator + +from mavedb.lib.oddspaths import oddspaths_evidence_strength_equivalent +from mavedb.lib.validation.exceptions import ValidationError +from mavedb.lib.validation.transform import ( + transform_score_calibration_publication_identifiers, + transform_score_set_to_urn, +) +from mavedb.lib.validation.utilities import inf_or_float +from mavedb.view_models import record_type_validator, set_record_type +from mavedb.view_models.acmg_classification import ( + ACMGClassification, + ACMGClassificationBase, + ACMGClassificationCreate, + ACMGClassificationModify, + SavedACMGClassification, +) +from mavedb.view_models.base.base import BaseModel +from mavedb.view_models.publication_identifier import ( + PublicationIdentifier, + PublicationIdentifierBase, + PublicationIdentifierCreate, + SavedPublicationIdentifier, +) +from mavedb.view_models.user import SavedUser, User + +### Functional range models + + +class FunctionalRangeBase(BaseModel): + """Base functional range model. + + Represents a labeled numeric score interval with optional evidence metadata. + Bounds are half-open by default (inclusive lower, exclusive upper) unless + overridden by inclusive flags. + """ + + label: str + description: Optional[str] = None + classification: Literal["normal", "abnormal", "not_specified"] = "not_specified" + + range: tuple[Union[float, None], Union[float, None]] + inclusive_lower_bound: bool = True + inclusive_upper_bound: bool = False + + acmg_classification: Optional[ACMGClassificationBase] = None + + oddspaths_ratio: Optional[float] = None + positive_likelihood_ratio: Optional[float] = None + + @field_validator("range") + def ranges_are_not_backwards( + cls, field_value: tuple[Union[float, None], Union[float, None]] + ) -> tuple[Union[float, None], Union[float, None]]: + """Reject reversed or zero-width intervals.""" + lower = inf_or_float(field_value[0], True) + upper = inf_or_float(field_value[1], False) + if lower > upper: + raise ValidationError("The lower bound cannot exceed the upper bound.") + if lower == upper: + raise ValidationError("The lower and upper bounds cannot be identical.") + + return field_value + + @field_validator("oddspaths_ratio", "positive_likelihood_ratio") + def ratios_must_be_positive(cls, field_value: Optional[float]) -> Optional[float]: + if field_value is not None and field_value < 0: + raise ValidationError("The ratio must be greater than or equal to 0.") + + return field_value + + @model_validator(mode="after") + def inclusive_bounds_do_not_include_infinity(self: "FunctionalRangeBase") -> "FunctionalRangeBase": + """Disallow inclusive bounds on unbounded (infinite) ends.""" + if self.inclusive_lower_bound and self.range[0] is None: + raise ValidationError("An inclusive lower bound may not include negative infinity.") + if self.inclusive_upper_bound and self.range[1] is None: + raise ValidationError("An inclusive upper bound may not include positive infinity.") + + return self + + @model_validator(mode="after") + def acmg_classification_evidence_agrees_with_classification(self: "FunctionalRangeBase") -> "FunctionalRangeBase": + """If oddspaths is provided, ensure its evidence agrees with the classification.""" + if self.acmg_classification is None or self.acmg_classification.criterion is None: + return self + + if ( + self.classification == "normal" + and self.acmg_classification.criterion.is_pathogenic + or self.classification == "abnormal" + and self.acmg_classification.criterion.is_benign + ): + raise ValidationError( + f"The ACMG classification criterion ({self.acmg_classification.criterion}) must agree with the functional range classification ({self.classification})." + ) + + return self + + @model_validator(mode="after") + def oddspaths_ratio_agrees_with_acmg_classification(self: "FunctionalRangeBase") -> "FunctionalRangeBase": + """If both oddspaths and acmg_classification are provided, ensure they agree.""" + if self.oddspaths_ratio is None or self.acmg_classification is None: + return self + + if self.acmg_classification.criterion is None and self.acmg_classification.evidence_strength is None: + return self + + equivalent_criterion, equivalent_strength = oddspaths_evidence_strength_equivalent(self.oddspaths_ratio) + if ( + self.acmg_classification.criterion != equivalent_criterion + or self.acmg_classification.evidence_strength != equivalent_strength + ): + raise ValidationError( + f"The provided oddspaths_ratio ({self.oddspaths_ratio}) implies criterion {equivalent_criterion} and evidence strength {equivalent_strength}," + f" which does not agree with the provided ACMG classification ({self.acmg_classification.criterion}, {self.acmg_classification.evidence_strength})." + ) + + return self + + def is_contained_by_range(self, score: float) -> bool: + """Determine if a given score falls within this functional range.""" + lower_bound, upper_bound = ( + inf_or_float(self.range[0], lower=True), + inf_or_float(self.range[1], lower=False), + ) + + lower_check = score > lower_bound or (self.inclusive_lower_bound and score == lower_bound) + upper_check = score < upper_bound or (self.inclusive_upper_bound and score == upper_bound) + + return lower_check and upper_check + + +class FunctionalRangeModify(FunctionalRangeBase): + """Model used to modify an existing functional range.""" + + acmg_classification: Optional[ACMGClassificationModify] = None + + +class FunctionalRangeCreate(FunctionalRangeModify): + """Model used to create a new functional range.""" + + acmg_classification: Optional[ACMGClassificationCreate] = None + + +class SavedFunctionalRange(FunctionalRangeBase): + """Persisted functional range model (includes record type metadata).""" + + record_type: str = None # type: ignore + acmg_classification: Optional[SavedACMGClassification] = None + + _record_type_factory = record_type_validator()(set_record_type) + + +class FunctionalRange(SavedFunctionalRange): + """Complete functional range model returned by the API.""" + + acmg_classification: Optional[ACMGClassification] = None + + +### Score calibration models + + +class ScoreCalibrationBase(BaseModel): + """Base score calibration model. + + Provides shared fields across create, modify, saved, and full models. + """ + + title: str + research_use_only: bool = False + + baseline_score: Optional[float] = None + baseline_score_description: Optional[str] = None + notes: Optional[str] = None + + functional_ranges: Optional[Sequence[FunctionalRangeBase]] = None + threshold_sources: Optional[Sequence[PublicationIdentifierBase]] = None + classification_sources: Optional[Sequence[PublicationIdentifierBase]] = None + method_sources: Optional[Sequence[PublicationIdentifierBase]] = None + calibration_metadata: Optional[dict] = None + + @field_validator("functional_ranges") + def ranges_do_not_overlap( + cls, field_value: Optional[Sequence[FunctionalRangeBase]] + ) -> Optional[Sequence[FunctionalRangeBase]]: + """Ensure that no two functional ranges overlap (respecting inclusivity).""" + + def test_overlap(range_test: FunctionalRangeBase, range_check: FunctionalRangeBase) -> bool: + # Allow 'not_specified' classifications to overlap with anything. + if range_test.classification == "not_specified" or range_check.classification == "not_specified": + return False + + if min(inf_or_float(range_test.range[0], True), inf_or_float(range_check.range[0], True)) == inf_or_float( + range_test.range[0], True + ): + first, second = range_test, range_check + else: + first, second = range_check, range_test + + touching_and_inclusive = ( + first.inclusive_upper_bound + and second.inclusive_lower_bound + and inf_or_float(first.range[1], False) == inf_or_float(second.range[0], True) + ) + if touching_and_inclusive: + return True + if inf_or_float(first.range[1], False) > inf_or_float(second.range[0], True): + return True + + return False + + if not field_value: # pragma: no cover + return None + + for i, a in enumerate(field_value): + for b in list(field_value)[i + 1 :]: + if test_overlap(a, b): + raise ValidationError( + f"Classified score ranges may not overlap; `{a.label}` ({a.range}) overlaps with `{b.label}` ({b.range}). To allow overlap, set one or both classifications to 'not_specified'.", + custom_loc=["body", i, "range"], + ) + return field_value + + @model_validator(mode="after") + def functional_range_labels_must_be_unique(self: "ScoreCalibrationBase") -> "ScoreCalibrationBase": + """Enforce uniqueness (post-strip) of functional range labels.""" + if not self.functional_ranges: + return self + + seen, dupes = set(), set() + for i, fr in enumerate(self.functional_ranges): + fr.label = fr.label.strip() + if fr.label in seen: + dupes.add((fr.label, i)) + else: + seen.add(fr.label) + + if dupes: + raise ValidationError( + f"Detected repeated label(s): {', '.join(label for label, _ in dupes)}. Functional range labels must be unique.", + custom_loc=["body", "functionalRanges", dupes.pop()[1], "label"], + ) + + return self + + @model_validator(mode="after") + def validate_baseline_score(self: "ScoreCalibrationBase") -> "ScoreCalibrationBase": + """If a baseline score is provided and it falls within a functional range, it may only be contained in a normal range.""" + if not self.functional_ranges: + return self + + if self.baseline_score is None: + return self + + for fr in self.functional_ranges: + if fr.is_contained_by_range(self.baseline_score) and fr.classification != "normal": + raise ValidationError( + f"The provided baseline score of {self.baseline_score} falls within a non-normal range ({fr.label}). Baseline scores may not fall within non-normal ranges.", + custom_loc=["body", "baselineScore"], + ) + + return self + + +class ScoreCalibrationModify(ScoreCalibrationBase): + """Model used to modify an existing score calibration.""" + + score_set_urn: Optional[str] = None + + functional_ranges: Optional[Sequence[FunctionalRangeModify]] = None + threshold_sources: Optional[Sequence[PublicationIdentifierCreate]] = None + classification_sources: Optional[Sequence[PublicationIdentifierCreate]] = None + method_sources: Optional[Sequence[PublicationIdentifierCreate]] = None + + +class ScoreCalibrationCreate(ScoreCalibrationModify): + """Model used to create a new score calibration.""" + + functional_ranges: Optional[Sequence[FunctionalRangeCreate]] = None + threshold_sources: Optional[Sequence[PublicationIdentifierCreate]] = None + classification_sources: Optional[Sequence[PublicationIdentifierCreate]] = None + method_sources: Optional[Sequence[PublicationIdentifierCreate]] = None + + +class SavedScoreCalibration(ScoreCalibrationBase): + """Persisted score calibration model (includes identifiers and source lists).""" + + record_type: str = None # type: ignore + + id: int + urn: str + + score_set_id: int + + investigator_provided: bool + primary: bool = False + private: bool = True + + functional_ranges: Optional[Sequence[SavedFunctionalRange]] = None + threshold_sources: Optional[Sequence[SavedPublicationIdentifier]] = None + classification_sources: Optional[Sequence[SavedPublicationIdentifier]] = None + method_sources: Optional[Sequence[SavedPublicationIdentifier]] = None + + created_by: Optional[SavedUser] = None + modified_by: Optional[SavedUser] = None + creation_date: date + modification_date: date + + _record_type_factory = record_type_validator()(set_record_type) + + class Config: + """Pydantic configuration (ORM mode).""" + + from_attributes = True + arbitrary_types_allowed = True + + @field_validator("threshold_sources", "classification_sources", "method_sources", mode="before") + def publication_identifiers_validator(cls, value: Any) -> Optional[list[PublicationIdentifier]]: + """Coerce association proxy collections to plain lists.""" + if value is None: + return None + + assert isinstance(value, Collection), "Publication identifier lists must be a collection" + return list(value) + + @model_validator(mode="after") + def primary_calibrations_may_not_be_research_use_only(self: "SavedScoreCalibration") -> "SavedScoreCalibration": + """Primary calibrations may not be marked as research use only.""" + if self.primary and self.research_use_only: + raise ValidationError( + "Primary score calibrations may not be marked as research use only.", + custom_loc=["body", "researchUseOnly"], + ) + + return self + + @model_validator(mode="after") + def primary_calibrations_may_not_be_private(self: "SavedScoreCalibration") -> "SavedScoreCalibration": + """Primary calibrations may not be marked as private.""" + if self.primary and self.private: + raise ValidationError( + "Primary score calibrations may not be marked as private.", custom_loc=["body", "private"] + ) + + return self + + @model_validator(mode="before") + def generate_threshold_classification_and_method_sources(cls, data: Any): # type: ignore[override] + """Populate threshold/classification/method source fields from association objects if missing.""" + association_keys = { + "threshold_sources", + "thresholdSources", + "classification_sources", + "classificationSources", + "method_sources", + "methodSources", + } + + if not any(hasattr(data, key) for key in association_keys): + try: + publication_identifiers = transform_score_calibration_publication_identifiers( + data.publication_identifier_associations + ) + data.__setattr__("threshold_sources", publication_identifiers["threshold_sources"]) + data.__setattr__("classification_sources", publication_identifiers["classification_sources"]) + data.__setattr__("method_sources", publication_identifiers["method_sources"]) + except AttributeError as exc: + raise ValidationError( + f"Unable to create {cls.__name__} without attribute: {exc}." # type: ignore + ) + return data + + +class ScoreCalibration(SavedScoreCalibration): + """Complete score calibration model returned by the API.""" + + functional_ranges: Optional[Sequence[FunctionalRange]] = None + threshold_sources: Optional[Sequence[PublicationIdentifier]] = None + classification_sources: Optional[Sequence[PublicationIdentifier]] = None + method_sources: Optional[Sequence[PublicationIdentifier]] = None + created_by: Optional[User] = None + modified_by: Optional[User] = None + + +class ScoreCalibrationWithScoreSetUrn(SavedScoreCalibration): + """Complete score calibration model returned by the API, with score_set_urn.""" + + score_set_urn: str + + @model_validator(mode="before") + def generate_score_set_urn(cls, data: Any): + if not hasattr(data, "score_set_urn"): + try: + data.__setattr__("score_set_urn", transform_score_set_to_urn(data.score_set)) + except AttributeError as exc: + raise ValidationError( + f"Unable to create {cls.__name__} without attribute: {exc}." # type: ignore + ) + return data diff --git a/src/mavedb/view_models/score_range.py b/src/mavedb/view_models/score_range.py deleted file mode 100644 index ee6c1e38..00000000 --- a/src/mavedb/view_models/score_range.py +++ /dev/null @@ -1,604 +0,0 @@ -from typing import Optional, Literal, Sequence, Union -from pydantic import field_validator, model_validator - -from mavedb.lib.validation.exceptions import ValidationError -from mavedb.lib.validation.utilities import inf_or_float -from mavedb.view_models import record_type_validator, set_record_type -from mavedb.view_models.base.base import BaseModel -from mavedb.view_models.publication_identifier import ( - PublicationIdentifierBase, - PublicationIdentifierCreate, -) -from mavedb.view_models.odds_path import OddsPathCreate, OddsPathBase, OddsPathModify, SavedOddsPath, OddsPath - - -############################################################################################################## -# Base score range models. To be inherited by other score range models. -############################################################################################################## - - -### Base range models - - -class ScoreRangeBase(BaseModel): - label: str - description: Optional[str] = None - classification: Literal["normal", "abnormal", "not_specified"] = "not_specified" - # Purposefully vague type hint because of some odd JSON Schema generation behavior. - # Typing this as tuple[Union[float, None], Union[float, None]] will generate an invalid - # jsonschema, and fail all tests that access the schema. This may be fixed in pydantic v2, - # but it's unclear. Even just typing it as Tuple[Any, Any] will generate an invalid schema! - range: tuple[Union[float, None], Union[float, None]] - inclusive_lower_bound: bool = True - inclusive_upper_bound: bool = False - - @field_validator("range") - def ranges_are_not_backwards( - cls, field_value: tuple[Union[float, None], Union[float, None]] - ) -> tuple[Union[float, None], Union[float, None]]: - lower = inf_or_float(field_value[0], True) - upper = inf_or_float(field_value[1], False) - - if lower > upper: - raise ValidationError("The lower bound of the score range may not be larger than the upper bound.") - elif lower == upper: - raise ValidationError("The lower and upper bound of the score range may not be the same.") - - return field_value - - # @root_validator - @model_validator(mode="after") - def inclusive_bounds_do_not_include_infinity(self: "ScoreRangeBase") -> "ScoreRangeBase": - """ - Ensure that if the lower bound is inclusive, it does not include negative infinity. - Similarly, if the upper bound is inclusive, it does not include positive infinity. - """ - range_values = self.range - inclusive_lower_bound = self.inclusive_lower_bound - inclusive_upper_bound = self.inclusive_upper_bound - - if inclusive_lower_bound and range_values[0] is None: - raise ValidationError("An inclusive lower bound may not include negative infinity.") - if inclusive_upper_bound and range_values[1] is None: - raise ValidationError("An inclusive upper bound may not include positive infinity.") - - return self - - -class ScoreRangeModify(ScoreRangeBase): - pass - - -class ScoreRangeCreate(ScoreRangeModify): - pass - - -class SavedScoreRange(ScoreRangeBase): - record_type: str = None # type: ignore - - _record_type_factory = record_type_validator()(set_record_type) - - -class ScoreRange(SavedScoreRange): - pass - - -### Base wrapper models - - -class ScoreRangesBase(BaseModel): - title: str - research_use_only: bool - ranges: Sequence[ScoreRangeBase] - source: Optional[Sequence[PublicationIdentifierBase]] = None - - @field_validator("ranges") - def ranges_do_not_overlap(cls, field_value: Sequence[ScoreRangeBase]) -> Sequence[ScoreRangeBase]: - def test_overlap(range_test: ScoreRangeBase, range_check: ScoreRangeBase) -> bool: - # Always check the tuple with the lowest lower bound. If we do not check - # overlaps in this manner, checking the overlap of (0,1) and (1,2) will - # yield different results depending on the ordering of tuples. - if min(inf_or_float(range_test.range[0], True), inf_or_float(range_check.range[0], True)) == inf_or_float( - range_test.range[0], True - ): - range_with_min_value = range_test - range_with_non_min_value = range_check - else: - range_with_min_value = range_check - range_with_non_min_value = range_test - - # If both ranges have inclusive bounds and their bounds intersect, we consider them overlapping. - if ( - range_with_min_value.inclusive_upper_bound - and range_with_non_min_value.inclusive_lower_bound - and ( - inf_or_float(range_with_min_value.range[1], False) - == inf_or_float(range_with_non_min_value.range[0], True) - ) - ): - return True - - # Since we have ordered the ranges, it's a guarantee that the lower bound of the first range is less - # than or equal to the lower bound of the second range. If the upper bound of the first range is greater - # than the lower bound of the second range, then the two ranges overlap. Inclusive bounds only come into - # play when the boundaries are equal and both bounds are inclusive. - if inf_or_float(range_with_min_value.range[1], False) > inf_or_float( - range_with_non_min_value.range[0], True - ): - return True - - return False - - for i, range_test in enumerate(field_value): - for range_check in list(field_value)[i + 1 :]: - if test_overlap(range_test, range_check): - raise ValidationError( - f"Score ranges may not overlap; `{range_test.label}` ({range_test.range}) overlaps with `{range_check.label}` ({range_check.range})." - ) - - return field_value - - -class ScoreRangesModify(ScoreRangesBase): - ranges: Sequence[ScoreRangeModify] - source: Optional[Sequence[PublicationIdentifierCreate]] = None - - -class ScoreRangesCreate(ScoreRangesModify): - ranges: Sequence[ScoreRangeCreate] - - -class SavedScoreRanges(ScoreRangesBase): - record_type: str = None # type: ignore - - ranges: Sequence[SavedScoreRange] - - _record_type_factory = record_type_validator()(set_record_type) - - -class ScoreRanges(SavedScoreRanges): - ranges: Sequence[ScoreRange] - - -############################################################################################################## -# Brnich style score range models -############################################################################################################## - - -class BrnichScoreRangeBase(ScoreRangeBase): - odds_path: Optional[OddsPathBase] = None - - -class BrnichScoreRangeModify(ScoreRangeModify, BrnichScoreRangeBase): - odds_path: Optional[OddsPathModify] = None - - -class BrnichScoreRangeCreate(ScoreRangeCreate, BrnichScoreRangeModify): - odds_path: Optional[OddsPathCreate] = None - - -class SavedBrnichScoreRange(SavedScoreRange, BrnichScoreRangeBase): - record_type: str = None # type: ignore - - odds_path: Optional[SavedOddsPath] = None - - _record_type_factory = record_type_validator()(set_record_type) - - -class BrnichScoreRange(ScoreRange, SavedBrnichScoreRange): - odds_path: Optional[OddsPath] = None - - -### Brnich score range wrapper model - - -class BrnichScoreRangesBase(ScoreRangesBase): - baseline_score: Optional[float] = None - baseline_score_description: Optional[str] = None - ranges: Sequence[BrnichScoreRangeBase] - odds_path_source: Optional[Sequence[PublicationIdentifierBase]] = None - - @model_validator(mode="after") - def validate_baseline_score(self: "BrnichScoreRangesBase") -> "BrnichScoreRangesBase": - ranges = getattr(self, "ranges", []) or [] - baseline_score = getattr(self, "baseline_score", None) - - if baseline_score is not None: - if not any(range_model.classification == "normal" for range_model in ranges): - # For now, we do not raise an error if a baseline score is provided but no normal range exists. - # raise ValidationError("A baseline score has been provided, but no normal classification range exists.") - return self - - normal_ranges = [range_model.range for range_model in ranges if range_model.classification == "normal"] - - if normal_ranges and baseline_score is None: - # For now, we do not raise an error if a normal range is provided but no baseline score. - return self - - if baseline_score is None: - return self - - for r in normal_ranges: - if baseline_score >= inf_or_float(r[0], lower=True) and baseline_score < inf_or_float(r[1], lower=False): - return self - - raise ValidationError( - f"The provided baseline score of {baseline_score} is not within any of the provided normal ranges. This score should be within a normal range.", - custom_loc=["body", "scoreRanges", "baselineScore"], - ) - - -class BrnichScoreRangesModify(ScoreRangesModify, BrnichScoreRangesBase): - ranges: Sequence[BrnichScoreRangeModify] - odds_path_source: Optional[Sequence[PublicationIdentifierCreate]] = None - - -class BrnichScoreRangesCreate(ScoreRangesCreate, BrnichScoreRangesModify): - ranges: Sequence[BrnichScoreRangeCreate] - - -class SavedBrnichScoreRanges(SavedScoreRanges, BrnichScoreRangesBase): - record_type: str = None # type: ignore - - ranges: Sequence[SavedBrnichScoreRange] - - _record_type_factory = record_type_validator()(set_record_type) - - -class BrnichScoreRanges(ScoreRanges, SavedBrnichScoreRanges): - ranges: Sequence[BrnichScoreRange] - - -############################################################################################################## -# Investigator provided score range models -############################################################################################################## - - -# NOTE: Pydantic takes the first occurence of a field definition in the MRO for default values. It feels most -# natural to define these classes like -# class InvestigatorScoreRangesBase(BrnichScoreRangesBase): -# title: str = "Investigator-provided functional classes" -# -# class InvestigatorScoreRangesModify(BrnichScoreRangesModify, InvestigatorScoreRangesBase): -# pass -# -# however, this does not work because the title field is defined in BrnichScoreRangesBase, and the default -# value from that class is taken instead of the one in InvestigatorScoreRangesBase. Note the opposite problem -# would occur if we defined the classes in the opposite order. -# -# We'd also like to retain the inheritance chain from Base -> Modify -> Create and Base -> Saved -> Full for -# each score range type as this makes it much easier to use these classes in inherited types from other -# modules (like the ScoreSet models). So although a mixin class might seem natural, we can't use one here -# since our MRO resolution wouldn't be linear. -# -# Just duplicating the defaults across each of the classes is the simplest solution for now, despite the -# code duplication. - - -class InvestigatorScoreRangesBase(BrnichScoreRangesBase): - title: str = "Investigator-provided functional classes" - research_use_only: bool = False - - -class InvestigatorScoreRangesModify(BrnichScoreRangesModify, InvestigatorScoreRangesBase): - title: str = "Investigator-provided functional classes" - research_use_only: bool = False - - -class InvestigatorScoreRangesCreate(BrnichScoreRangesCreate, InvestigatorScoreRangesModify): - title: str = "Investigator-provided functional classes" - research_use_only: bool = False - - -class SavedInvestigatorScoreRanges(SavedBrnichScoreRanges, InvestigatorScoreRangesBase): - record_type: str = None # type: ignore - - title: str = "Investigator-provided functional classes" - research_use_only: bool = False - - _record_type_factory = record_type_validator()(set_record_type) - - -class InvestigatorScoreRanges(BrnichScoreRanges, SavedInvestigatorScoreRanges): - title: str = "Investigator-provided functional classes" - research_use_only: bool = False - - -############################################################################################################## -# Scott score range models -############################################################################################################## - - -class ScottScoreRangesBase(BrnichScoreRangesBase): - title: str = "Scott calibration" - research_use_only: bool = False - - -class ScottScoreRangesModify(BrnichScoreRangesModify, ScottScoreRangesBase): - title: str = "Scott calibration" - research_use_only: bool = False - - -class ScottScoreRangesCreate(BrnichScoreRangesCreate, ScottScoreRangesModify): - title: str = "Scott calibration" - research_use_only: bool = False - - -class SavedScottScoreRanges(SavedBrnichScoreRanges, ScottScoreRangesBase): - record_type: str = None # type: ignore - - title: str = "Scott calibration" - research_use_only: bool = False - - _record_type_factory = record_type_validator()(set_record_type) - - -class ScottScoreRanges(BrnichScoreRanges, SavedScottScoreRanges): - title: str = "Scott calibration" - research_use_only: bool = False - - -############################################################################################################## -# IGVF Coding Variant Focus Group (CVFG) range models -############################################################################################################## - -# Controls: All Variants - - -class IGVFCodingVariantFocusGroupControlScoreRangesBase(BrnichScoreRangesBase): - title: str = "IGVF Coding Variant Focus Group -- Controls: All Variants" - research_use_only: bool = False - - -class IGVFCodingVariantFocusGroupControlScoreRangesModify( - BrnichScoreRangesModify, IGVFCodingVariantFocusGroupControlScoreRangesBase -): - title: str = "IGVF Coding Variant Focus Group -- Controls: All Variants" - research_use_only: bool = False - - -class IGVFCodingVariantFocusGroupControlScoreRangesCreate( - BrnichScoreRangesCreate, IGVFCodingVariantFocusGroupControlScoreRangesModify -): - title: str = "IGVF Coding Variant Focus Group -- Controls: All Variants" - research_use_only: bool = False - - -class SavedIGVFCodingVariantFocusGroupControlScoreRanges( - SavedBrnichScoreRanges, IGVFCodingVariantFocusGroupControlScoreRangesBase -): - record_type: str = None # type: ignore - - title: str = "IGVF Coding Variant Focus Group -- Controls: All Variants" - research_use_only: bool = False - - _record_type_factory = record_type_validator()(set_record_type) - - -class IGVFCodingVariantFocusGroupControlScoreRanges( - BrnichScoreRanges, SavedIGVFCodingVariantFocusGroupControlScoreRanges -): - title: str = "IGVF Coding Variant Focus Group -- Controls: All Variants" - research_use_only: bool = False - - -# Controls: Missense Variants - - -class IGVFCodingVariantFocusGroupMissenseScoreRangesBase(BrnichScoreRangesBase): - title: str = "IGVF Coding Variant Focus Group -- Controls: Missense Variants Only" - research_use_only: bool = False - - -class IGVFCodingVariantFocusGroupMissenseScoreRangesModify( - BrnichScoreRangesModify, IGVFCodingVariantFocusGroupMissenseScoreRangesBase -): - title: str = "IGVF Coding Variant Focus Group -- Controls: Missense Variants Only" - research_use_only: bool = False - - -class IGVFCodingVariantFocusGroupMissenseScoreRangesCreate( - BrnichScoreRangesCreate, IGVFCodingVariantFocusGroupMissenseScoreRangesModify -): - title: str = "IGVF Coding Variant Focus Group -- Controls: Missense Variants Only" - research_use_only: bool = False - - -class SavedIGVFCodingVariantFocusGroupMissenseScoreRanges( - SavedBrnichScoreRanges, IGVFCodingVariantFocusGroupMissenseScoreRangesBase -): - record_type: str = None # type: ignore - - title: str = "IGVF Coding Variant Focus Group -- Controls: Missense Variants Only" - research_use_only: bool = False - - _record_type_factory = record_type_validator()(set_record_type) - - -class IGVFCodingVariantFocusGroupMissenseScoreRanges( - BrnichScoreRanges, SavedIGVFCodingVariantFocusGroupMissenseScoreRanges -): - title: str = "IGVF Coding Variant Focus Group -- Controls: Missense Variants Only" - research_use_only: bool = False - - -############################################################################################################## -# Zeiberg specific calibration models -############################################################################################################## - -### Zeiberg score range model - - -class ZeibergCalibrationScoreRangeBase(ScoreRangeBase): - positive_likelihood_ratio: Optional[float] = None - evidence_strength: int - # path (normal) / benign (abnormal) -> classification - - @model_validator(mode="after") - def evidence_strength_cardinality_must_agree_with_classification( - self: "ZeibergCalibrationScoreRangeBase", - ) -> "ZeibergCalibrationScoreRangeBase": - classification = getattr(self, "classification") - field_value = getattr(self, "evidence_strength") - - if classification == "normal" and field_value >= 0: - raise ValidationError( - "The evidence strength for a normal range must be negative.", - ) - elif classification == "abnormal" and field_value <= 0: - raise ValidationError( - "The evidence strength for an abnormal range must be positive.", - ) - - return self - - -class ZeibergCalibrationScoreRangeModify(ScoreRangeModify, ZeibergCalibrationScoreRangeBase): - pass - - -class ZeibergCalibrationScoreRangeCreate(ScoreRangeCreate, ZeibergCalibrationScoreRangeModify): - pass - - -class SavedZeibergCalibrationScoreRange(SavedScoreRange, ZeibergCalibrationScoreRangeBase): - record_type: str = None # type: ignore - - _record_type_factory = record_type_validator()(set_record_type) - - -class ZeibergCalibrationScoreRange(ScoreRange, SavedZeibergCalibrationScoreRange): - pass - - -### Zeiberg score range wrapper model - - -class ZeibergCalibrationParameters(BaseModel): - skew: float - location: float - scale: float - - -class ZeibergCalibrationParameterSet(BaseModel): - functionally_altering: ZeibergCalibrationParameters - functionally_normal: ZeibergCalibrationParameters - fraction_functionally_altering: float - - -class ZeibergCalibrationScoreRangesBase(ScoreRangesBase): - title: str = "Zeiberg calibration" - research_use_only: bool = True - - prior_probability_pathogenicity: Optional[float] = None - parameter_sets: list[ZeibergCalibrationParameterSet] = [] - ranges: Sequence[ZeibergCalibrationScoreRangeBase] - - -class ZeibergCalibrationScoreRangesModify(ScoreRangesModify, ZeibergCalibrationScoreRangesBase): - title: str = "Zeiberg calibration" - research_use_only: bool = True - ranges: Sequence[ZeibergCalibrationScoreRangeModify] - - -class ZeibergCalibrationScoreRangesCreate(ScoreRangesCreate, ZeibergCalibrationScoreRangesModify): - title: str = "Zeiberg calibration" - research_use_only: bool = True - ranges: Sequence[ZeibergCalibrationScoreRangeCreate] - - -class SavedZeibergCalibrationScoreRanges(SavedScoreRanges, ZeibergCalibrationScoreRangesBase): - record_type: str = None # type: ignore - - title: str = "Zeiberg calibration" - research_use_only: bool = True - ranges: Sequence[SavedZeibergCalibrationScoreRange] - - _record_type_factory = record_type_validator()(set_record_type) - - -class ZeibergCalibrationScoreRanges(ScoreRanges, SavedZeibergCalibrationScoreRanges): - title: str = "Zeiberg calibration" - research_use_only: bool = True - ranges: Sequence[ZeibergCalibrationScoreRange] - - -############################################################################################################### -# Score range container objects -############################################################################################################### - -### Score set range container models - -# TODO#518: Generic score range keys for supported calibration formats. - - -class ScoreSetRangesBase(BaseModel): - investigator_provided: Optional[InvestigatorScoreRangesBase] = None - scott_calibration: Optional[ScottScoreRangesBase] = None - zeiberg_calibration: Optional[ZeibergCalibrationScoreRangesBase] = None - cvfg_all_variants: Optional[IGVFCodingVariantFocusGroupControlScoreRangesBase] = None - cvfg_missense_variants: Optional[IGVFCodingVariantFocusGroupMissenseScoreRangesBase] = None - - _fields_to_exclude_for_validatation = {"record_type"} - - @model_validator(mode="after") - def score_range_labels_must_be_unique(self: "ScoreSetRangesBase") -> "ScoreSetRangesBase": - for container in ( - self.investigator_provided, - self.zeiberg_calibration, - self.scott_calibration, - self.cvfg_all_variants, - self.cvfg_missense_variants, - ): - if container is None: - continue - - existing_labels, duplicate_labels = set(), set() - for range_model in container.ranges: - range_model.label = range_model.label.strip() - if range_model.label in existing_labels: - duplicate_labels.add(range_model.label) - else: - existing_labels.add(range_model.label) - - if duplicate_labels: - raise ValidationError( - f"Detected repeated label(s): {', '.join(duplicate_labels)}. Range labels must be unique.", - ) - return self - - -class ScoreSetRangesModify(ScoreSetRangesBase): - investigator_provided: Optional[InvestigatorScoreRangesModify] = None - scott_calibration: Optional[ScottScoreRangesModify] = None - zeiberg_calibration: Optional[ZeibergCalibrationScoreRangesModify] = None - cvfg_all_variants: Optional[IGVFCodingVariantFocusGroupControlScoreRangesModify] = None - cvfg_missense_variants: Optional[IGVFCodingVariantFocusGroupMissenseScoreRangesModify] = None - - -class ScoreSetRangesCreate(ScoreSetRangesModify): - investigator_provided: Optional[InvestigatorScoreRangesCreate] = None - scott_calibration: Optional[ScottScoreRangesCreate] = None - zeiberg_calibration: Optional[ZeibergCalibrationScoreRangesCreate] = None - cvfg_all_variants: Optional[IGVFCodingVariantFocusGroupControlScoreRangesCreate] = None - cvfg_missense_variants: Optional[IGVFCodingVariantFocusGroupMissenseScoreRangesCreate] = None - - -class SavedScoreSetRanges(ScoreSetRangesBase): - record_type: str = None # type: ignore - - investigator_provided: Optional[SavedInvestigatorScoreRanges] = None - scott_calibration: Optional[SavedScottScoreRanges] = None - zeiberg_calibration: Optional[SavedZeibergCalibrationScoreRanges] = None - cvfg_all_variants: Optional[SavedIGVFCodingVariantFocusGroupControlScoreRanges] = None - cvfg_missense_variants: Optional[SavedIGVFCodingVariantFocusGroupMissenseScoreRanges] = None - - _record_type_factory = record_type_validator()(set_record_type) - - -class ScoreSetRanges(SavedScoreSetRanges): - investigator_provided: Optional[InvestigatorScoreRanges] = None - scott_calibration: Optional[ScottScoreRanges] = None - zeiberg_calibration: Optional[ZeibergCalibrationScoreRanges] = None - cvfg_all_variants: Optional[IGVFCodingVariantFocusGroupControlScoreRanges] = None - cvfg_missense_variants: Optional[IGVFCodingVariantFocusGroupMissenseScoreRanges] = None diff --git a/src/mavedb/view_models/score_set.py b/src/mavedb/view_models/score_set.py index 1b3328d5..9f53cf64 100644 --- a/src/mavedb/view_models/score_set.py +++ b/src/mavedb/view_models/score_set.py @@ -11,7 +11,7 @@ from mavedb.lib.validation import urn_re from mavedb.lib.validation.exceptions import ValidationError from mavedb.lib.validation.transform import ( - transform_publication_identifiers_to_primary_and_secondary, + transform_record_publication_identifiers, transform_score_set_list_to_urn_list, ) from mavedb.lib.validation.utilities import is_null @@ -31,7 +31,11 @@ PublicationIdentifierCreate, SavedPublicationIdentifier, ) -from mavedb.view_models.score_range import SavedScoreSetRanges, ScoreSetRanges, ScoreSetRangesCreate +from mavedb.view_models.score_calibration import ( + SavedScoreCalibration, + ScoreCalibration, + ScoreCalibrationCreate, +) from mavedb.view_models.score_set_dataset_columns import DatasetColumns, SavedDatasetColumns from mavedb.view_models.target_gene import ( SavedTargetGene, @@ -76,7 +80,6 @@ class ScoreSetModifyBase(ScoreSetBase): secondary_publication_identifiers: Optional[list[PublicationIdentifierCreate]] = None doi_identifiers: Optional[list[DoiIdentifierCreate]] = None target_genes: list[TargetGeneCreate] - score_ranges: Optional[ScoreSetRangesCreate] = None class ScoreSetModify(ScoreSetModifyBase): @@ -167,58 +170,6 @@ def target_accession_base_editor_targets_are_consistent(cls, field_value, values return field_value - @model_validator(mode="after") - def validate_score_range_sources_exist_in_publication_identifiers(self): - def _check_source_in_score_set(source: Any) -> bool: - # It looks like you could just do values.get("primary_publication_identifiers", []), but the value of the Pydantic - # field is not guaranteed to be a list and could be None, so we need to check if it exists and only then add the list - # as the default value. - primary_publication_identifiers = self.primary_publication_identifiers or [] - secondary_publication_identifiers = self.secondary_publication_identifiers or [] - - if source not in primary_publication_identifiers and source not in secondary_publication_identifiers: - return False - - return True - - score_ranges = self.score_ranges - if not score_ranges: - return self - - # Use the model_fields_set attribute to iterate over the defined containers in score_ranges. - # This allows us to validate each range definition within the range containers. - for range_name in score_ranges.model_fields_set: - range_definition = getattr(score_ranges, range_name) - if not range_definition: - continue - - # investigator_provided score ranges can have an odds path source as well. - if range_name == "investigator_provided" and range_definition.odds_path_source is not None: - for idx, pub in enumerate(range_definition.odds_path_source): - odds_path_source_exists = _check_source_in_score_set(pub) - - if not odds_path_source_exists: - raise ValidationError( - f"Odds path source publication at index {idx} is not defined in score set publications. " - "To use a publication identifier in the odds path source, it must be defined in the primary or secondary publication identifiers for this score set.", - custom_loc=["body", "scoreRanges", range_name, "oddsPathSource", idx], - ) - - if not range_definition.source: - continue - - for idx, pub in enumerate(range_definition.source): - source_exists = _check_source_in_score_set(pub) - - if not source_exists: - raise ValidationError( - f"Score range source publication at index {idx} is not defined in score set publications. " - "To use a publication identifier in the score range source, it must be defined in the primary or secondary publication identifiers for this score set.", - custom_loc=["body", "scoreRanges", range_name, "source", idx], - ) - - return self - class ScoreSetCreate(ScoreSetModify): """View model for creating a new score set.""" @@ -227,6 +178,12 @@ class ScoreSetCreate(ScoreSetModify): license_id: int superseded_score_set_urn: Optional[str] = None meta_analyzes_score_set_urns: Optional[list[str]] = None + # NOTE: The primary field of score calibrations is not available to the creation view model + # and new calibrations are currently not able to be created in a primary state. + # If this propertie ever became available during calibration creation, + # validation criteria which enforces constraints on there being a single primary + # calibration per score set would need to be added at this model level. + score_calibrations: Optional[Sequence[ScoreCalibrationCreate]] = None @field_validator("superseded_score_set_urn") def validate_superseded_score_set_urn(cls, v: Optional[str]) -> Optional[str]: @@ -307,7 +264,6 @@ def as_form(cls, **kwargs: Any) -> "ScoreSetUpdateAllOptional": else None, "doi_identifiers": lambda data: [DoiIdentifierCreate.model_validate(d) for d in data] if data else None, "target_genes": lambda data: [TargetGeneCreate.model_validate(t) for t in data] if data else None, - "score_ranges": lambda data: ScoreSetRangesCreate.model_validate(data) if data else None, "extra_metadata": lambda data: data, } @@ -358,9 +314,11 @@ class Config: # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. @model_validator(mode="before") def generate_primary_and_secondary_publications(cls, data: Any): - if not hasattr(data, "primary_publication_identifiers") or not hasattr(data, "primary_publication_identifiers"): + if not hasattr(data, "primary_publication_identifiers") or not hasattr( + data, "secondary_publication_identifiers" + ): try: - publication_identifiers = transform_publication_identifiers_to_primary_and_secondary( + publication_identifiers = transform_record_publication_identifiers( data.publication_identifier_associations ) data.__setattr__( @@ -410,7 +368,7 @@ class SavedScoreSet(ScoreSetBase): dataset_columns: Optional[SavedDatasetColumns] = None external_links: dict[str, ExternalLink] contributors: Sequence[Contributor] - score_ranges: Optional[SavedScoreSetRanges] = None + score_calibrations: Optional[Sequence[SavedScoreCalibration]] = None _record_type_factory = record_type_validator()(set_record_type) @@ -429,9 +387,11 @@ def publication_identifiers_validator(cls, value: Any) -> list[PublicationIdenti # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. @model_validator(mode="before") def generate_primary_and_secondary_publications(cls, data: Any): - if not hasattr(data, "primary_publication_identifiers") or not hasattr(data, "primary_publication_identifiers"): + if not hasattr(data, "primary_publication_identifiers") or not hasattr( + data, "secondary_publication_identifiers" + ): try: - publication_identifiers = transform_publication_identifiers_to_primary_and_secondary( + publication_identifiers = transform_record_publication_identifiers( data.publication_identifier_associations ) data.__setattr__( @@ -486,7 +446,7 @@ class ScoreSet(SavedScoreSet): processing_errors: Optional[dict] = None mapping_state: Optional[MappingState] = None mapping_errors: Optional[dict] = None - score_ranges: Optional[ScoreSetRanges] = None # type: ignore[assignment] + score_calibrations: Optional[Sequence[ScoreCalibration]] = None # type: ignore[assignment] dataset_columns: Optional[DatasetColumns] = None # type: ignore[assignment] @@ -521,7 +481,7 @@ class ScoreSetPublicDump(SavedScoreSet): processing_errors: Optional[dict] = None mapping_state: Optional[MappingState] = None mapping_errors: Optional[dict] = None - score_ranges: Optional[ScoreSetRanges] = None # type: ignore[assignment] + score_calibrations: Optional[Sequence[ScoreCalibration]] = None # type: ignore[assignment] # ruff: noqa: E402 diff --git a/tests/conftest.py b/tests/conftest.py index 9dfd01a6..c79c033e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,7 +38,8 @@ TEST_PUBMED_IDENTIFIER, TEST_VALID_POST_MAPPED_VRS_ALLELE_VRS2_X, TEST_VALID_PRE_MAPPED_VRS_ALLELE_VRS2_X, - TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, + TEST_BRNICH_SCORE_CALIBRATION, + TEST_PATHOGENICITY_SCORE_CALIBRATION, ) sys.path.append(".") @@ -144,7 +145,7 @@ def mock_experiment(): def mock_score_set(mock_user, mock_experiment, mock_publication_associations): score_set = mock.Mock(spec=ScoreSet) score_set.urn = VALID_SCORE_SET_URN - score_set.score_ranges = TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT + score_set.score_calibrations = [TEST_BRNICH_SCORE_CALIBRATION, TEST_PATHOGENICITY_SCORE_CALIBRATION] score_set.license.short_name = "MIT" score_set.created_by = mock_user score_set.modified_by = mock_user @@ -181,3 +182,159 @@ def mock_mapped_variant(mock_variant): mv.mapped_date = datetime(2023, 1, 2) mv.modification_date = datetime(2023, 1, 3) return mv + + +@pytest.fixture +def mock_publication_fetch(request, requests_mock): + """ + Mocks the request that would be sent for the provided publication. + + To use this fixture for a test on which you would like to mock the creation of a publication identifier, + mark the test with: + + @pytest.mark.parametrize( + "mock_publication_fetch", + [ + { + "dbName": "", + "identifier": "" + }, + ... + ], + indirect=["mock_publication_fetch"], + ) + def test_needing_publication_identifier_mock(mock_publication_fetch, ...): + ... + + If your test requires use of the mocked publication identifier, this fixture returns it. Just assign the fixture + to a variable (or use it directly). + + def test_needing_publication_identifier_mock(mock_publication_fetch, ...): + ... + mocked_publication = mock_publication_fetch + experiment = create_experiment(client, {"primaryPublicationIdentifiers": [mocked_publication]}) + ... + """ + # Support passing either a single publication dict or an iterable (list/tuple) of them. + raw_param = request.param + if isinstance(raw_param, (list, tuple)): + publications_to_mock = list(raw_param) + else: + publications_to_mock = [raw_param] + + mocked_publications = [] + + for publication_to_mock in publications_to_mock: + if publication_to_mock["dbName"] == "PubMed": + # minimal xml to pass validation + requests_mock.post( + "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi", + text=f""" + + + + {publication_to_mock["identifier"]} +
+ + test + + + 1999 + + + + + test + +
+
+ + + test + + +
+
+ """, + ) + + # Since 6 digit PubMed identifiers may also be valid bioRxiv identifiers, the code checks that this isn't also a valid bioxriv ID. We return nothing. + requests_mock.get( + f"https://api.biorxiv.org/details/medrxiv/10.1101/{publication_to_mock['identifier']}/na/json", + json={"collection": []}, + ) + + elif publication_to_mock["dbName"] == "bioRxiv": + requests_mock.get( + f"https://api.biorxiv.org/details/biorxiv/10.1101/{publication_to_mock['identifier']}/na/json", + json={ + "collection": [ + { + "title": "test biorxiv", + "doi": "test:test:test", + "category": "test3", + "authors": "", + "author_corresponding": "test6", + "author_corresponding_institution": "test7", + "date": "1999-12-31", + "version": "test8", + "type": "test9", + "license": "test10", + "jatsxml": "test11", + "abstract": "test abstract", + "published": "Preprint", + "server": "test14", + } + ] + }, + ) + elif publication_to_mock["dbName"] == "medRxiv": + requests_mock.get( + f"https://api.biorxiv.org/details/medrxiv/10.1101/{publication_to_mock['identifier']}/na/json", + json={ + "collection": [ + { + "title": "test1", + "doi": "test2", + "category": "test3", + "authors": "test4; test5", + "author_corresponding": "test6", + "author_corresponding_institution": "test7", + "date": "1999-12-31", + "version": "test8", + "type": "test9", + "license": "test10", + "jatsxml": "test11", + "abstract": "test12", + "published": "test13", + "server": "test14", + } + ] + }, + ) + elif publication_to_mock["dbName"] == "Crossref": + requests_mock.get( + f"https://api.crossref.org/works/{publication_to_mock['identifier']}", + json={ + "status": "ok", + "message-type": "work", + "message-version": "1.0.0", + "message": { + "DOI": "10.10/1.2.3", + "source": "Crossref", + "title": ["Crossref test pub title"], + "prefix": "10.10", + "author": [ + {"given": "author", "family": "one", "sequence": "first", "affiliation": []}, + {"given": "author", "family": "two", "sequence": "additional", "affiliation": []}, + ], + "container-title": ["American Heart Journal"], + "abstract": "Abstracttext test", + "URL": "http://dx.doi.org/10.10/1.2.3", + "published": {"date-parts": [[2024, 5]]}, + }, + }, + ) + mocked_publications.append(publication_to_mock) + # Return a single dict (original behavior) if only one was provided; otherwise the list. + return mocked_publications[0] if len(mocked_publications) == 1 else mocked_publications diff --git a/tests/helpers/constants.py b/tests/helpers/constants.py index 78fc8d01..517c26cb 100644 --- a/tests/helpers/constants.py +++ b/tests/helpers/constants.py @@ -9,6 +9,8 @@ VALID_SCORE_SET_URN = f"{VALID_EXPERIMENT_URN}-1" VALID_TMP_URN = "tmp:79471b5b-2dbd-4a96-833c-c33023862437" VALID_VARIANT_URN = f"{VALID_SCORE_SET_URN}#1" +VALID_COLLECTION_URN = "urn:mavedb:collection-79471b5b-2dbd-4a96-833c-c33023862437" +VALID_CALIBRATION_URN = "urn:mavedb:calibration-79471b5b-2dbd-4a96-833c-c33023862437" TEST_PUBMED_IDENTIFIER = "20711194" TEST_PUBMED_URL_IDENTIFIER = "https://pubmed.ncbi.nlm.nih.gov/37162834/" @@ -150,6 +152,25 @@ "id": 1, } +TEST_BIORXIV_PUBLICATION = { + "identifier": TEST_BIORXIV_IDENTIFIER, + "db_name": "bioRxiv", + "title": "test biorxiv", + "authors": [{"name": "", "primary": True}], + "abstract": "test abstract", + "doi": "test:test:test", + "publication_year": 1999, + "publication_journal": "Preprint", + "url": "https://www.biorxiv.org/content/10.1101/2021.06.21.212592", + "reference_html": ". test biorxiv. (None). 1999; (Unknown volume):(Unknown pages). test:test:test", +} + +SAVED_BIORXIV_PUBLICATION = { + "recordType": "PublicationIdentifier", + "id": 2, + **{camelize(k): v for k, v in TEST_BIORXIV_PUBLICATION.items()}, +} + SAVED_DOI_IDENTIFIER = { "recordType": "DoiIdentifier", "identifier": TEST_CROSSREF_IDENTIFIER, @@ -276,6 +297,11 @@ "is_first_login": True, } +TEST_SAVED_USER = { + "recordType": "SavedUser", + **{camelize(k): v for k, v in TEST_USER.items()}, +} + TEST_USER2 = { "username": "1111-2222-3333-4444", "first_name": "First", @@ -807,6 +833,7 @@ "metaAnalyzesScoreSetUrns": [], "metaAnalyzedByScoreSetUrns": [], "contributors": [], + "scoreCalibrations": [], "doiIdentifiers": [], "primaryPublicationIdentifiers": [], "secondaryPublicationIdentifiers": [], @@ -1354,90 +1381,109 @@ TEST_BASELINE_SCORE = 1.0 -TEST_BS3_ODDS_PATH = { - "ratio": 0.5, - "evidence": "BS3_STRONG", +TEST_ACMG_BS3_STRONG_CLASSIFICATION = { + "criterion": "BS3", + "evidence_strength": "strong", +} + +TEST_SAVED_ACMG_BS3_STRONG_CLASSIFICATION = { + "recordType": "ACMGClassification", + **{camelize(k): v for k, v in TEST_ACMG_BS3_STRONG_CLASSIFICATION.items()}, +} + +TEST_ACMG_PS3_STRONG_CLASSIFICATION = { + "criterion": "PS3", + "evidence_strength": "strong", +} + +TEST_SAVED_ACMG_PS3_STRONG_CLASSIFICATION = { + "recordType": "ACMGClassification", + **{camelize(k): v for k, v in TEST_ACMG_PS3_STRONG_CLASSIFICATION.items()}, } -TEST_PS3_ODDS_PATH = { - "ratio": 0.5, - "evidence": "BS3_STRONG", +TEST_ACMG_BS3_STRONG_CLASSIFICATION_WITH_POINTS = { + "criterion": "BS3", + "evidence_strength": "strong", + "points": -4, } -TEST_SAVED_BS3_ODDS_PATH = { - "recordType": "OddsPath", - "ratio": 0.5, - "evidence": "BS3_STRONG", +TEST_SAVED_ACMG_BS3_STRONG_CLASSIFICATION_WITH_POINTS = { + "recordType": "ACMGClassification", + **{camelize(k): v for k, v in TEST_ACMG_BS3_STRONG_CLASSIFICATION_WITH_POINTS.items()}, } +TEST_ACMG_PS3_STRONG_CLASSIFICATION_WITH_POINTS = { + "criterion": "PS3", + "evidence_strength": "strong", + "points": 4, +} -TEST_SAVED_PS3_ODDS_PATH = { - "recordType": "OddsPath", - "ratio": 0.5, - "evidence": "BS3_STRONG", +TEST_SAVED_ACMG_PS3_STRONG_CLASSIFICATION_WITH_POINTS = { + "recordType": "ACMGClassification", + **{camelize(k): v for k, v in TEST_ACMG_PS3_STRONG_CLASSIFICATION_WITH_POINTS.items()}, } +TEST_BS3_STRONG_ODDS_PATH_RATIO = 0.052 +TEST_PS3_STRONG_ODDS_PATH_RATIO = 18.7 + -TEST_SCORE_SET_NORMAL_RANGE = { - "label": "test1", +TEST_FUNCTIONAL_RANGE_NORMAL = { + "label": "test normal functional range", + "description": "A normal functional range", "classification": "normal", - "range": [0, 2.0], + "range": [1.0, 5.0], + "acmg_classification": TEST_ACMG_BS3_STRONG_CLASSIFICATION, + "oddspaths_ratio": TEST_BS3_STRONG_ODDS_PATH_RATIO, "inclusive_lower_bound": True, "inclusive_upper_bound": False, } -TEST_SAVED_SCORE_SET_NORMAL_RANGE = { - "recordType": "ScoreRange", - "label": "test1", - "classification": "normal", - "range": [0.0, 2.0], - "inclusiveLowerBound": True, - "inclusiveUpperBound": False, +TEST_SAVED_FUNCTIONAL_RANGE_NORMAL = { + "recordType": "FunctionalRange", + **{camelize(k): v for k, v in TEST_FUNCTIONAL_RANGE_NORMAL.items() if k not in ("acmg_classification",)}, + "acmgClassification": TEST_SAVED_ACMG_BS3_STRONG_CLASSIFICATION, } -TEST_SCORE_SET_ABNORMAL_RANGE = { - "label": "test2", +TEST_FUNCTIONAL_RANGE_ABNORMAL = { + "label": "test abnormal functional range", + "description": "An abnormal functional range", "classification": "abnormal", - "range": [-2.0, 0.0], + "range": [-5.0, -1.0], + "acmg_classification": TEST_ACMG_PS3_STRONG_CLASSIFICATION, + "oddspaths_ratio": TEST_PS3_STRONG_ODDS_PATH_RATIO, "inclusive_lower_bound": True, "inclusive_upper_bound": False, } -TEST_SAVED_SCORE_SET_ABNORMAL_RANGE = { - "recordType": "ScoreRange", - "label": "test2", - "classification": "abnormal", - "range": [-2.0, 0.0], - "inclusiveLowerBound": True, - "inclusiveUpperBound": False, +TEST_SAVED_FUNCTIONAL_RANGE_ABNORMAL = { + "recordType": "FunctionalRange", + **{camelize(k): v for k, v in TEST_FUNCTIONAL_RANGE_ABNORMAL.items() if k not in ("acmg_classification",)}, + "acmgClassification": TEST_SAVED_ACMG_PS3_STRONG_CLASSIFICATION, } -TEST_SCORE_SET_NOT_SPECIFIED_RANGE = { - "label": "test3", +TEST_FUNCTIONAL_RANGE_NOT_SPECIFIED = { + "label": "test not specified functional range", "classification": "not_specified", - "range": [-8.0, -2.0], + "range": [-1.0, 1.0], "inclusive_lower_bound": True, "inclusive_upper_bound": False, } -TEST_SAVED_SCORE_SET_NOT_SPECIFIED_RANGE = { - "recordType": "ScoreRange", - "label": "test3", - "classification": "not_specified", - "range": [-8.0, -2.0], - "inclusiveLowerBound": True, - "inclusiveUpperBound": False, +TEST_SAVED_FUNCTIONAL_RANGE_NOT_SPECIFIED = { + "recordType": "FunctionalRange", + **{camelize(k): v for k, v in TEST_FUNCTIONAL_RANGE_NOT_SPECIFIED.items()}, } -TEST_SCORE_SET_NEGATIVE_INFINITY_RANGE = { - "label": "test4", +TEST_FUNCTIONAL_RANGE_INCLUDING_NEGATIVE_INFINITY = { + "label": "test functional range including negative infinity", + "description": "A functional range including negative infinity", "classification": "not_specified", "range": [None, 0.0], "inclusive_lower_bound": False, @@ -1445,18 +1491,15 @@ } -TEST_SAVED_SCORE_SET_NEGATIVE_INFINITY_RANGE = { - "recordType": "ScoreRange", - "label": "test4", - "classification": "not_specified", - "range": [None, 0.0], - "inclusiveLowerBound": False, - "inclusiveUpperBound": False, +TEST_SAVED_FUNCTIONAL_RANGE_INCLUDING_NEGATIVE_INFINITY = { + "recordType": "FunctionalRange", + **{camelize(k): v for k, v in TEST_FUNCTIONAL_RANGE_INCLUDING_NEGATIVE_INFINITY.items()}, } -TEST_SCORE_SET_POSITIVE_INFINITY_RANGE = { - "label": "test5", +TEST_FUNCTIONAL_RANGE_INCLUDING_POSITIVE_INFINITY = { + "label": "test functional range including positive infinity", + "description": "A functional range including positive infinity", "classification": "not_specified", "range": [0.0, None], "inclusive_lower_bound": False, @@ -1464,687 +1507,129 @@ } -TEST_SAVED_SCORE_SET_POSITIVE_INFINITY_RANGE = { - "recordType": "ScoreRange", - "label": "test5", - "classification": "not_specified", - "range": [0.0, None], - "inclusiveLowerBound": False, - "inclusiveUpperBound": False, -} - -TEST_SAVED_SCORE_SET_NO_SUPPORTING_EVIDENCE_RANGE = { - "recordType": "ScoreRange", - "label": "test1", - "classification": "not_specified", - "range": [-0.5, 0.5], - "inclusiveLowerBound": True, - "inclusiveUpperBound": False, -} - -TEST_SCORE_SET_BS3_SUPPORTING_RANGE = { - "label": "test1", - "classification": "normal", - "range": [-1.5, -0.5], - "inclusive_lower_bound": True, - "inclusive_upper_bound": False, -} - -TEST_SAVED_SCORE_SET_BS3_SUPPORTING_RANGE = { - "recordType": "ScoreRange", - "label": "test1", - "classification": "normal", - "range": [-1.5, -0.5], - "inclusiveLowerBound": True, - "inclusiveUpperBound": False, -} - -TEST_SCORE_SET_BS3_MODERATE_RANGE = { - "label": "test1", - "classification": "normal", - "range": [-3.5, -1.5], - "inclusive_lower_bound": True, - "inclusive_upper_bound": False, -} - -TEST_SAVED_SCORE_SET_BS3_MODERATE_RANGE = { - "recordType": "ScoreRange", - "label": "test1", - "classification": "normal", - "range": [-3.5, -1.5], - "inclusiveLowerBound": True, - "inclusiveUpperBound": False, -} - -TEST_SCORE_SET_BS3_STRONG_RANGE = { - "label": "test1", - "classification": "normal", - "range": [-7.5, -3.5], - "inclusive_lower_bound": True, - "inclusive_upper_bound": False, -} - -TEST_SAVED_SCORE_SET_BS3_STRONG_RANGE = { - "recordType": "ScoreRange", - "label": "test1", - "classification": "normal", - "range": [-7.5, -3.5], - "inclusiveLowerBound": True, - "inclusiveUpperBound": False, -} - -TEST_SCORE_SET_BS3_VERY_STRONG_RANGE = { - "label": "test1", - "classification": "normal", - "range": [None, -7.5], - "inclusive_lower_bound": False, - "inclusive_upper_bound": False, -} - -TEST_SAVED_SCORE_SET_BS3_VERY_STRONG_RANGE = { - "recordType": "ScoreRange", - "label": "test1", - "classification": "normal", - "range": [None, -7.5], - "inclusiveLowerBound": False, - "inclusiveUpperBound": False, -} - -TEST_SCORE_SET_PS3_SUPPORTING_RANGE = { - "label": "test1", - "classification": "abnormal", - "range": [0.5, 1.5], - "inclusive_lower_bound": True, - "inclusive_upper_bound": False, -} - -TEST_SAVED_SCORE_SET_PS3_SUPPORTING_RANGE = { - "recordType": "ScoreRange", - "label": "test1", - "classification": "abnormal", - "range": [0.5, 1.5], - "inclusiveLowerBound": True, - "inclusiveUpperBound": False, -} - -TEST_SCORE_SET_PS3_MODERATE_RANGE = { - "label": "test1", - "classification": "abnormal", - "range": [1.5, 3.5], - "inclusive_lower_bound": True, - "inclusive_upper_bound": False, -} - -TEST_SAVED_SCORE_SET_PS3_MODERATE_RANGE = { - "recordType": "ScoreRange", - "label": "test1", - "classification": "abnormal", - "range": [1.5, 3.5], - "inclusiveLowerBound": True, - "inclusiveUpperBound": False, -} - -TEST_SCORE_SET_PS3_STRONG_RANGE = { - "label": "test1", - "classification": "abnormal", - "range": [3.5, 7.5], - "inclusive_lower_bound": True, - "inclusive_upper_bound": False, -} - -TEST_SAVED_SCORE_SET_PS3_STRONG_RANGE = { - "recordType": "ScoreRange", - "label": "test1", - "classification": "abnormal", - "range": [3.5, 7.5], - "inclusiveLowerBound": True, - "inclusiveUpperBound": False, -} - -TEST_SCORE_SET_PS3_VERY_STRONG_RANGE = { - "label": "test1", - "classification": "abnormal", - "range": [7.5, None], - "inclusive_lower_bound": True, - "inclusive_upper_bound": False, -} - -TEST_SAVED_SCORE_SET_PS3_VERY_STRONG_RANGE = { - "recordType": "ScoreRange", - "label": "test1", - "classification": "abnormal", - "range": [7.5, None], - "inclusiveLowerBound": True, - "inclusiveUpperBound": False, -} - -TEST_SCORE_SET_RANGE = { - "baseline_score": TEST_BASELINE_SCORE, - "ranges": [ - TEST_SCORE_SET_NORMAL_RANGE, - TEST_SCORE_SET_ABNORMAL_RANGE, - ], +TEST_MINIMAL_CALIBRATION = { + "title": "Test BRNICH Score Calibration", "research_use_only": False, - "title": "Test Base Ranges", - "source": None, -} - - -TEST_SCORE_SET_RANGE_WITH_SOURCE = { - "baseline_score": TEST_BASELINE_SCORE, - "ranges": [ - TEST_SCORE_SET_NORMAL_RANGE, - TEST_SCORE_SET_ABNORMAL_RANGE, + "investigator_provided": False, + "functional_ranges": [ + TEST_FUNCTIONAL_RANGE_NORMAL, + TEST_FUNCTIONAL_RANGE_ABNORMAL, + TEST_FUNCTIONAL_RANGE_NOT_SPECIFIED, ], - "research_use_only": False, - "title": "Test Base Ranges with Source", - "source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], -} - - -TEST_BRNICH_SCORE_SET_NORMAL_RANGE = { - **TEST_SCORE_SET_NORMAL_RANGE, - "odds_path": TEST_BS3_ODDS_PATH, -} - - -TEST_SAVED_BRNICH_SCORE_SET_NORMAL_RANGE = { - **TEST_SAVED_SCORE_SET_NORMAL_RANGE, - "oddsPath": TEST_SAVED_BS3_ODDS_PATH, - "recordType": "BrnichScoreRange", -} - - -TEST_BRNICH_SCORE_SET_ABNORMAL_RANGE = { - **TEST_SCORE_SET_ABNORMAL_RANGE, - "odds_path": TEST_PS3_ODDS_PATH, -} - - -TEST_SAVED_BRNICH_SCORE_SET_ABNORMAL_RANGE = { - **TEST_SAVED_SCORE_SET_ABNORMAL_RANGE, - "oddsPath": TEST_SAVED_PS3_ODDS_PATH, - "recordType": "BrnichScoreRange", -} - - -TEST_BRNICH_SCORE_SET_NOT_SPECIFIED_RANGE = { - **TEST_SCORE_SET_NOT_SPECIFIED_RANGE, - "odds_path": TEST_PS3_ODDS_PATH, -} - - -TEST_SAVED_BRNICH_SCORE_SET_NOT_SPECIFIED_RANGE = { - **TEST_SAVED_SCORE_SET_NOT_SPECIFIED_RANGE, - "oddsPath": TEST_SAVED_PS3_ODDS_PATH, - "recordType": "BrnichScoreRange", + "threshold_sources": [], + "classification_sources": [], + "method_sources": [], + "calibration_metadata": {}, } -TEST_BRNICH_SCORE_SET_RANGE = { - "baseline_score": TEST_BASELINE_SCORE, - "ranges": [ - TEST_BRNICH_SCORE_SET_NORMAL_RANGE, - TEST_BRNICH_SCORE_SET_ABNORMAL_RANGE, - TEST_BRNICH_SCORE_SET_NOT_SPECIFIED_RANGE, - ], +TEST_BRNICH_SCORE_CALIBRATION = { + "title": "Test BRNICH Score Calibration", "research_use_only": False, - "title": "Test Brnich Functional Ranges", - "odds_path_source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], - "source": None, -} - - -TEST_SAVED_BRNICH_SCORE_SET_RANGE = { - "recordType": "BrnichScoreRanges", - "baselineScore": TEST_BASELINE_SCORE, - "ranges": [ - TEST_SAVED_BRNICH_SCORE_SET_NORMAL_RANGE, - TEST_SAVED_BRNICH_SCORE_SET_ABNORMAL_RANGE, - TEST_SAVED_BRNICH_SCORE_SET_NOT_SPECIFIED_RANGE, - ], - "researchUseOnly": False, - "title": "Test Brnich Functional Ranges", - "oddsPathSource": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}], - "source": None, -} - - -TEST_BRNICH_SCORE_SET_RANGE_WITH_SOURCE = { - **TEST_BRNICH_SCORE_SET_RANGE, - "source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], -} - - -TEST_SAVED_BRNICH_SCORE_SET_RANGE_WITH_SOURCE = { - **TEST_SAVED_BRNICH_SCORE_SET_RANGE, - "source": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}], -} - -TEST_SCOTT_SCORE_SET_NORMAL_RANGE = { - **TEST_SCORE_SET_NORMAL_RANGE, - "odds_path": TEST_BS3_ODDS_PATH, -} - - -TEST_SAVED_SCOTT_SCORE_SET_NORMAL_RANGE = { - **TEST_SAVED_SCORE_SET_NORMAL_RANGE, - "oddsPath": TEST_SAVED_BS3_ODDS_PATH, - "recordType": "BrnichScoreRange", -} - - -TEST_SCOTT_SCORE_SET_ABNORMAL_RANGE = { - **TEST_SCORE_SET_ABNORMAL_RANGE, - "odds_path": TEST_PS3_ODDS_PATH, -} - - -TEST_SAVED_SCOTT_SCORE_SET_ABNORMAL_RANGE = { - **TEST_SAVED_SCORE_SET_ABNORMAL_RANGE, - "oddsPath": TEST_SAVED_PS3_ODDS_PATH, - "recordType": "BrnichScoreRange", -} - - -TEST_SCOTT_SCORE_SET_NOT_SPECIFIED_RANGE = { - **TEST_SCORE_SET_NOT_SPECIFIED_RANGE, - "odds_path": TEST_PS3_ODDS_PATH, -} - - -TEST_SAVED_SCOTT_SCORE_SET_NOT_SPECIFIED_RANGE = { - **TEST_SAVED_SCORE_SET_NOT_SPECIFIED_RANGE, - "oddsPath": TEST_SAVED_PS3_ODDS_PATH, - "recordType": "BrnichScoreRange", -} - - -TEST_SCOTT_SCORE_SET_RANGE = { "baseline_score": TEST_BASELINE_SCORE, - "ranges": [ - TEST_SCOTT_SCORE_SET_NORMAL_RANGE, - TEST_SCOTT_SCORE_SET_ABNORMAL_RANGE, - TEST_SCOTT_SCORE_SET_NOT_SPECIFIED_RANGE, + "baseline_score_description": "Test baseline score description", + "functional_ranges": [ + TEST_FUNCTIONAL_RANGE_NORMAL, + TEST_FUNCTIONAL_RANGE_ABNORMAL, + TEST_FUNCTIONAL_RANGE_NOT_SPECIFIED, ], - "research_use_only": False, - "title": "Test Scott Functional Ranges", - "odds_path_source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], - "source": None, -} - - -TEST_SAVED_SCOTT_SCORE_SET_RANGE = { - "recordType": "ScottScoreRanges", - "baselineScore": TEST_BASELINE_SCORE, - "ranges": [ - TEST_SAVED_SCOTT_SCORE_SET_NORMAL_RANGE, - TEST_SAVED_SCOTT_SCORE_SET_ABNORMAL_RANGE, - TEST_SAVED_SCOTT_SCORE_SET_NOT_SPECIFIED_RANGE, - ], - "researchUseOnly": False, - "title": "Test Scott Functional Ranges", - "oddsPathSource": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}], - "source": None, -} - - -TEST_SCOTT_SCORE_SET_RANGE_WITH_SOURCE = { - **TEST_SCOTT_SCORE_SET_RANGE, - "source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], -} - - -TEST_SAVED_SCOTT_SCORE_SET_RANGE_WITH_SOURCE = { - **TEST_SAVED_SCOTT_SCORE_SET_RANGE, - "source": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}], -} - -TEST_INVESTIGATOR_PROVIDED_SCORE_SET_NORMAL_RANGE = { - **TEST_SCORE_SET_NORMAL_RANGE, - "odds_path": TEST_BS3_ODDS_PATH, -} - - -TEST_SAVED_INVESTIGATOR_PROVIDED_SCORE_SET_NORMAL_RANGE = { - **TEST_SAVED_SCORE_SET_NORMAL_RANGE, - "oddsPath": TEST_SAVED_BS3_ODDS_PATH, - "recordType": "BrnichScoreRange", -} - - -TEST_INVESTIGATOR_PROVIDED_SCORE_SET_ABNORMAL_RANGE = { - **TEST_SCORE_SET_ABNORMAL_RANGE, - "odds_path": TEST_PS3_ODDS_PATH, -} - - -TEST_SAVED_INVESTIGATOR_PROVIDED_SCORE_SET_ABNORMAL_RANGE = { - **TEST_SAVED_SCORE_SET_ABNORMAL_RANGE, - "oddsPath": TEST_SAVED_PS3_ODDS_PATH, - "recordType": "BrnichScoreRange", -} - - -TEST_INVESTIGATOR_PROVIDED_SCORE_SET_NOT_SPECIFIED_RANGE = { - **TEST_SCORE_SET_NOT_SPECIFIED_RANGE, - "odds_path": TEST_PS3_ODDS_PATH, -} - - -TEST_SAVED_INVESTIGATOR_PROVIDED_SCORE_SET_NOT_SPECIFIED_RANGE = { - **TEST_SAVED_SCORE_SET_NOT_SPECIFIED_RANGE, - "oddsPath": TEST_SAVED_PS3_ODDS_PATH, - "recordType": "BrnichScoreRange", -} - - -TEST_INVESTIGATOR_PROVIDED_SCORE_SET_RANGE = { - "baseline_score": TEST_BASELINE_SCORE, - "ranges": [ - TEST_INVESTIGATOR_PROVIDED_SCORE_SET_NORMAL_RANGE, - TEST_INVESTIGATOR_PROVIDED_SCORE_SET_ABNORMAL_RANGE, - TEST_INVESTIGATOR_PROVIDED_SCORE_SET_NOT_SPECIFIED_RANGE, + "threshold_sources": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], + "classification_sources": [ + {"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}, + {"identifier": TEST_BIORXIV_IDENTIFIER, "db_name": "bioRxiv"}, ], - "research_use_only": False, - "title": "Test Investigator-provided Functional Ranges", - "odds_path_source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], - "source": None, + "method_sources": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], + "calibration_metadata": {}, } - -TEST_SAVED_INVESTIGATOR_PROVIDED_SCORE_SET_RANGE = { - "recordType": "InvestigatorScoreRanges", - "baselineScore": TEST_BASELINE_SCORE, - "ranges": [ - TEST_SAVED_INVESTIGATOR_PROVIDED_SCORE_SET_NORMAL_RANGE, - TEST_SAVED_INVESTIGATOR_PROVIDED_SCORE_SET_ABNORMAL_RANGE, - TEST_SAVED_INVESTIGATOR_PROVIDED_SCORE_SET_NOT_SPECIFIED_RANGE, +TEST_SAVED_BRNICH_SCORE_CALIBRATION = { + "recordType": "ScoreCalibration", + **{ + camelize(k): v + for k, v in TEST_BRNICH_SCORE_CALIBRATION.items() + if k not in ("functional_ranges", "classification_sources", "threshold_sources", "method_sources") + }, + "functionalRanges": [ + TEST_SAVED_FUNCTIONAL_RANGE_NORMAL, + TEST_SAVED_FUNCTIONAL_RANGE_ABNORMAL, + TEST_SAVED_FUNCTIONAL_RANGE_NOT_SPECIFIED, ], - "researchUseOnly": False, - "title": "Test Investigator-provided Functional Ranges", - "oddsPathSource": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}], - "source": None, -} - - -TEST_INVESTIGATOR_PROVIDED_SCORE_SET_RANGE_WITH_SOURCE = { - **TEST_INVESTIGATOR_PROVIDED_SCORE_SET_RANGE, - "source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], -} - - -TEST_SAVED_INVESTIGATOR_PROVIDED_SCORE_SET_RANGE_WITH_SOURCE = { - **TEST_SAVED_INVESTIGATOR_PROVIDED_SCORE_SET_RANGE, - "source": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}], -} - - -# no camel casing required, and no need for a 'recordType' key -TEST_ZEIBERG_CALIBRATION_FUNCTIONALY_ALTERING_PARAMETERS = ( - TEST_SAVED_ZEIBERG_CALIBRATION_FUNCTIONALY_ALTERING_PARAMETERS -) = { - "skew": 1.15, - "location": -2.20, - "scale": 1.20, -} - - -# no camel casing required, and no need for a 'recordType' key -TEST_ZEIBERG_CALIBRATION_FUNCTIONALY_NORMAL_PARAMETERS = ( - TEST_SAVED_ZEIBERG_CALIBRATION_FUNCTIONALY_NORMAL_PARAMETERS -) = { - "skew": -1.5, - "location": 2.25, - "scale": 0.8, -} - - -TEST_ZEIBERG_CALIBRATION_PARAMETER_SETS = [ - { - "functionally_altering": TEST_ZEIBERG_CALIBRATION_FUNCTIONALY_ALTERING_PARAMETERS, - "functionally_normal": TEST_ZEIBERG_CALIBRATION_FUNCTIONALY_NORMAL_PARAMETERS, - "fraction_functionally_altering": 0.20, - } -] - - -TEST_SAVED_ZEIBERG_CALIBRATION_PARAMETER_SETS = [ - { - "functionallyAltering": TEST_SAVED_ZEIBERG_CALIBRATION_FUNCTIONALY_ALTERING_PARAMETERS, - "functionallyNormal": TEST_SAVED_ZEIBERG_CALIBRATION_FUNCTIONALY_NORMAL_PARAMETERS, - "fractionFunctionallyAltering": 0.20, - } -] - - -TEST_ZEIBERG_CALIBRATION_SCORE_SET_BS3_SUPPORTING_RANGE = { - **TEST_SCORE_SET_BS3_SUPPORTING_RANGE, - "positive_likelihood_ratio": 100.0, - "evidence_strength": -1, - "label": "BS3_SUPPORTING", -} - - -TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_BS3_SUPPORTING_RANGE = { - **TEST_SAVED_SCORE_SET_BS3_SUPPORTING_RANGE, - "recordType": "ZeibergCalibrationScoreRange", - "label": "BS3_SUPPORTING", - "evidenceStrength": -1, - "positiveLikelihoodRatio": 100.0, -} - -TEST_ZEIBERG_CALIBRATION_SCORE_SET_PS3_SUPPORTING_RANGE = { - **TEST_SCORE_SET_PS3_SUPPORTING_RANGE, - "positive_likelihood_ratio": 10.0, - "evidence_strength": 1, - "label": "PS3_SUPPORTING", -} - - -TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_PS3_SUPPORTING_RANGE = { - **TEST_SAVED_SCORE_SET_PS3_SUPPORTING_RANGE, - "recordType": "ZeibergCalibrationScoreRange", - "label": "PS3_SUPPORTING", - "positiveLikelihoodRatio": 10.0, - "evidenceStrength": 1, -} - - -TEST_ZEIBERG_CALIBRATION_SCORE_SET_BS3_MODERATE_RANGE = { - **TEST_SCORE_SET_BS3_MODERATE_RANGE, - "positive_likelihood_ratio": 100.0, - "evidence_strength": -2, - "label": "BS3_MODERATE", -} - - -TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_BS3_MODERATE_RANGE = { - **TEST_SAVED_SCORE_SET_BS3_MODERATE_RANGE, - "recordType": "ZeibergCalibrationScoreRange", - "label": "BS3_MODERATE", - "evidenceStrength": -2, - "positiveLikelihoodRatio": 100.0, -} - -TEST_ZEIBERG_CALIBRATION_SCORE_SET_PS3_MODERATE_RANGE = { - **TEST_SCORE_SET_PS3_MODERATE_RANGE, - "positive_likelihood_ratio": 10.0, - "evidence_strength": 2, - "label": "PS3_MODERATE", -} - - -TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_PS3_MODERATE_RANGE = { - **TEST_SAVED_SCORE_SET_PS3_MODERATE_RANGE, - "recordType": "ZeibergCalibrationScoreRange", - "label": "PS3_MODERATE", - "positiveLikelihoodRatio": 10.0, - "evidenceStrength": 2, -} - - -TEST_ZEIBERG_CALIBRATION_SCORE_SET_BS3_STRONG_RANGE = { - **TEST_SCORE_SET_BS3_STRONG_RANGE, - "positive_likelihood_ratio": 100.0, - "evidence_strength": -4, - "label": "BS3_STRONG", -} - - -TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_BS3_STRONG_RANGE = { - **TEST_SAVED_SCORE_SET_BS3_STRONG_RANGE, - "recordType": "ZeibergCalibrationScoreRange", - "label": "BS3_STRONG", - "evidenceStrength": -4, - "positiveLikelihoodRatio": 100.0, -} - -TEST_ZEIBERG_CALIBRATION_SCORE_SET_PS3_STRONG_RANGE = { - **TEST_SCORE_SET_PS3_STRONG_RANGE, - "positive_likelihood_ratio": 10.0, - "evidence_strength": 4, - "label": "PS3_STRONG", -} - - -TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_PS3_STRONG_RANGE = { - **TEST_SAVED_SCORE_SET_PS3_STRONG_RANGE, - "recordType": "ZeibergCalibrationScoreRange", - "label": "PS3_STRONG", - "positiveLikelihoodRatio": 10.0, - "evidenceStrength": 4, -} - - -TEST_ZEIBERG_CALIBRATION_SCORE_SET_BS3_VERY_STRONG_RANGE = { - **TEST_SCORE_SET_BS3_VERY_STRONG_RANGE, - "positive_likelihood_ratio": 100.0, - "evidence_strength": -8, - "label": "BS3_VERY_STRONG", -} - - -TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_BS3_VERY_STRONG_RANGE = { - **TEST_SAVED_SCORE_SET_BS3_VERY_STRONG_RANGE, - "recordType": "ZeibergCalibrationScoreRange", - "label": "BS3_VERY_STRONG", - "evidenceStrength": -8, - "positiveLikelihoodRatio": 100.0, -} - -TEST_ZEIBERG_CALIBRATION_SCORE_SET_PS3_VERY_STRONG_RANGE = { - **TEST_SCORE_SET_PS3_VERY_STRONG_RANGE, - "positive_likelihood_ratio": 10.0, - "evidence_strength": 8, - "label": "PS3_VERY_STRONG", -} - - -TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_PS3_VERY_STRONG_RANGE = { - **TEST_SAVED_SCORE_SET_PS3_VERY_STRONG_RANGE, - "recordType": "ZeibergCalibrationScoreRange", - "label": "PS3_VERY_STRONG", - "positiveLikelihoodRatio": 10.0, - "evidenceStrength": 8, + "thresholdSources": [SAVED_PUBMED_PUBLICATION], + "classificationSources": [SAVED_PUBMED_PUBLICATION, SAVED_BIORXIV_PUBLICATION], + "methodSources": [SAVED_PUBMED_PUBLICATION], + "id": 1, + "urn": VALID_CALIBRATION_URN, + "investigatorProvided": True, + "primary": True, + "private": False, + "scoreSetId": 1, + "createdBy": { + "recordType": "User", + "firstName": TEST_USER["first_name"], + "lastName": TEST_USER["last_name"], + "orcidId": TEST_USER["username"], + }, + "modifiedBy": { + "recordType": "User", + "firstName": TEST_USER["first_name"], + "lastName": TEST_USER["last_name"], + "orcidId": TEST_USER["username"], + }, + "creationDate": date.today().isoformat(), + "modificationDate": date.today().isoformat(), } - -TEST_ZEIBERG_CALIBRATION_SCORE_SET_RANGE = { - "ranges": [ - TEST_ZEIBERG_CALIBRATION_SCORE_SET_BS3_SUPPORTING_RANGE, - TEST_ZEIBERG_CALIBRATION_SCORE_SET_BS3_MODERATE_RANGE, - TEST_ZEIBERG_CALIBRATION_SCORE_SET_BS3_STRONG_RANGE, - TEST_ZEIBERG_CALIBRATION_SCORE_SET_BS3_VERY_STRONG_RANGE, - TEST_ZEIBERG_CALIBRATION_SCORE_SET_PS3_SUPPORTING_RANGE, - TEST_ZEIBERG_CALIBRATION_SCORE_SET_PS3_MODERATE_RANGE, - TEST_ZEIBERG_CALIBRATION_SCORE_SET_PS3_STRONG_RANGE, - TEST_ZEIBERG_CALIBRATION_SCORE_SET_PS3_VERY_STRONG_RANGE, +TEST_PATHOGENICITY_SCORE_CALIBRATION = { + "title": "Test Pathogenicity Score Calibration", + "research_use_only": False, + "baseline_score": TEST_BASELINE_SCORE, + "baseline_score_description": "Test baseline score description", + "functional_ranges": [ + TEST_FUNCTIONAL_RANGE_NORMAL, + TEST_FUNCTIONAL_RANGE_ABNORMAL, ], - "research_use_only": True, - "title": "Test Zeiberg Calibration", - "parameter_sets": TEST_ZEIBERG_CALIBRATION_PARAMETER_SETS, - "prior_probability_pathogenicity": 0.20, - "source": None, -} - - -TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_RANGE = { - "recordType": "ZeibergCalibrationScoreRanges", - "ranges": [ - TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_BS3_SUPPORTING_RANGE, - TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_BS3_MODERATE_RANGE, - TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_BS3_STRONG_RANGE, - TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_BS3_VERY_STRONG_RANGE, - TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_PS3_SUPPORTING_RANGE, - TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_PS3_MODERATE_RANGE, - TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_PS3_STRONG_RANGE, - TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_PS3_VERY_STRONG_RANGE, + "threshold_sources": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], + "classification_sources": None, + "method_sources": None, + "calibration_metadata": {}, +} + +TEST_SAVED_PATHOGENICITY_SCORE_CALIBRATION = { + "recordType": "ScoreCalibration", + **{ + camelize(k): v + for k, v in TEST_PATHOGENICITY_SCORE_CALIBRATION.items() + if k not in ("functional_ranges", "classification_sources", "threshold_sources", "method_sources") + }, + "functionalRanges": [ + TEST_SAVED_FUNCTIONAL_RANGE_NORMAL, + TEST_SAVED_FUNCTIONAL_RANGE_ABNORMAL, ], - "researchUseOnly": True, - "title": "Test Zeiberg Calibration", - "parameterSets": TEST_SAVED_ZEIBERG_CALIBRATION_PARAMETER_SETS, - "priorProbabilityPathogenicity": 0.20, -} - -TEST_ZEIBERG_CALIBRATION_SCORE_SET_RANGE_WITH_SOURCE = { - **TEST_ZEIBERG_CALIBRATION_SCORE_SET_RANGE, - "source": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], -} - - -TEST_SAVED_ZEIBERG_CALIBRATION_SCORE_SET_RANGE_WITH_SOURCE = { - **TEST_ZEIBERG_CALIBRATION_SAVED_SCORE_SET_RANGE, - "source": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}], -} - - -TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED = { - "investigator_provided": TEST_INVESTIGATOR_PROVIDED_SCORE_SET_RANGE_WITH_SOURCE, -} - - -TEST_SAVED_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED = { - "recordType": "ScoreSetRanges", - "investigatorProvided": TEST_SAVED_INVESTIGATOR_PROVIDED_SCORE_SET_RANGE_WITH_SOURCE, -} - -TEST_SCORE_SET_RANGES_ONLY_SCOTT = { - "scott_calibration": TEST_SCOTT_SCORE_SET_RANGE_WITH_SOURCE, -} - - -TEST_SAVED_SCORE_SET_RANGES_ONLY_SCOTT = { - "recordType": "ScoreSetRanges", - "scottCalibration": TEST_SAVED_SCOTT_SCORE_SET_RANGE_WITH_SOURCE, -} - - -TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION = { - "zeiberg_calibration": TEST_ZEIBERG_CALIBRATION_SCORE_SET_RANGE_WITH_SOURCE, -} - - -TEST_SAVED_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION = { - "recordType": "ScoreSetRanges", - "zeibergCalibration": TEST_SAVED_ZEIBERG_CALIBRATION_SCORE_SET_RANGE_WITH_SOURCE, -} - - -TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT = { - **TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - **TEST_SCORE_SET_RANGES_ONLY_SCOTT, - **TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, -} - - -TEST_SAVED_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT = { - **TEST_SAVED_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - **TEST_SAVED_SCORE_SET_RANGES_ONLY_SCOTT, - **TEST_SAVED_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, + "thresholdSources": [SAVED_PUBMED_PUBLICATION], + "classificationSources": None, + "methodSources": None, + "id": 2, + "investigatorProvided": True, + "primary": False, + "private": False, + "urn": VALID_CALIBRATION_URN, + "scoreSetId": 1, + "createdBy": { + "recordType": "User", + "firstName": TEST_USER["first_name"], + "lastName": TEST_USER["last_name"], + "orcidId": TEST_USER["username"], + }, + "modifiedBy": { + "recordType": "User", + "firstName": TEST_USER["first_name"], + "lastName": TEST_USER["last_name"], + "orcidId": TEST_USER["username"], + }, + "creationDate": date.today().isoformat(), + "modificationDate": date.today().isoformat(), } - TEST_COLLECTION = {"name": "Test collection", "description": None, "private": True} diff --git a/tests/helpers/util/common.py b/tests/helpers/util/common.py index 11df7fe8..01309eff 100644 --- a/tests/helpers/util/common.py +++ b/tests/helpers/util/common.py @@ -1,4 +1,5 @@ from typing import Dict, Any +from humps import camelize def update_expected_response_for_created_resources( @@ -33,3 +34,12 @@ class Object(object): attr_obj.__setattr__(k, v) return attr_obj + + +def deepcamelize(data: Any) -> Any: + if isinstance(data, dict): + return {camelize(k): deepcamelize(v) for k, v in data.items()} + elif isinstance(data, list): + return [deepcamelize(item) for item in data] + else: + return data diff --git a/tests/helpers/util/score_calibration.py b/tests/helpers/util/score_calibration.py new file mode 100644 index 00000000..8c432e8f --- /dev/null +++ b/tests/helpers/util/score_calibration.py @@ -0,0 +1,81 @@ +from typing import TYPE_CHECKING + +import jsonschema + +from mavedb.lib.score_calibrations import create_score_calibration_in_score_set +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 + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + from fastapi.testclient import TestClient + + +async def create_test_score_calibration_in_score_set(db: "Session", score_set_urn: str, user: User) -> ScoreCalibration: + calibration_create = ScoreCalibrationCreate(**TEST_BRNICH_SCORE_CALIBRATION, score_set_urn=score_set_urn) + created_score_calibration = await create_score_calibration_in_score_set(db, calibration_create, user) + assert created_score_calibration is not None + + db.commit() + db.refresh(created_score_calibration) + + return created_score_calibration + + +def create_test_score_calibration_in_score_set_via_client( + client: "TestClient", score_set_urn: str, calibration_data: dict +): + calibration_payload = {**calibration_data, "scoreSetUrn": score_set_urn} + jsonschema.validate(instance=calibration_payload, schema=ScoreCalibrationCreate.model_json_schema()) + + response = client.post( + "/api/v1/score-calibrations/", + json=calibration_payload, + ) + + assert response.status_code == 200, "Could not create score calibration" + + calibration = response.json() + assert calibration["scoreSetUrn"] == score_set_urn + + jsonschema.validate(instance=calibration, schema=ScoreCalibrationWithScoreSetUrn.model_json_schema()) + return calibration + + +def publish_test_score_calibration_via_client(client: "TestClient", calibration_urn: str): + response = client.post(f"/api/v1/score-calibrations/{calibration_urn}/publish") + + assert response.status_code == 200, "Could not publish score calibration" + + calibration = response.json() + assert calibration["private"] is False + + jsonschema.validate(instance=calibration, schema=ScoreCalibrationWithScoreSetUrn.model_json_schema()) + return calibration + + +def promote_test_score_calibration_to_primary_via_client( + client: "TestClient", calibration_urn: str, demote_existing_primary: bool = False +): + response = client.post( + f"/api/v1/score-calibrations/{calibration_urn}/promote-to-primary", + params={"demoteExistingPrimary": demote_existing_primary}, + ) + + assert response.status_code == 200, "Could not promote score calibration to primary" + + calibration = response.json() + assert calibration["primary"] is True + + jsonschema.validate(instance=calibration, schema=ScoreCalibrationWithScoreSetUrn.model_json_schema()) + return calibration + + +def create_publish_and_promote_score_calibration(client, score_set_urn: str, calibration_data: dict): + calibration = create_test_score_calibration_in_score_set_via_client(client, score_set_urn, calibration_data) + publish_test_score_calibration_via_client(client, calibration["urn"]) + promote_test_score_calibration_to_primary_via_client(client, calibration["urn"]) + return calibration diff --git a/tests/lib/annotation/test_annotate.py b/tests/lib/annotation/test_annotate.py index 7ae7daec..9c1846cb 100644 --- a/tests/lib/annotation/test_annotate.py +++ b/tests/lib/annotation/test_annotate.py @@ -1,3 +1,5 @@ +from copy import deepcopy + from mavedb.lib.annotation.annotate import variant_study_result from mavedb.lib.annotation.annotate import variant_functional_impact_statement from mavedb.lib.annotation.annotate import variant_pathogenicity_evidence @@ -12,29 +14,31 @@ def test_variant_study_result(mock_mapped_variant): assert result.type == "ExperimentalVariantFunctionalImpactStudyResult" -def test_variant_functional_impact_statement_no_score_ranges(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = None +def test_variant_functional_impact_statement_no_calibrations(mock_mapped_variant): result = variant_functional_impact_statement(mock_mapped_variant) assert result is None -def test_variant_functional_impact_statement_no_score_range_data(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges["investigator_provided"]["ranges"] = [] - result = variant_functional_impact_statement(mock_mapped_variant) +def test_variant_functional_impact_statement_no_primary_calibrations( + mock_mapped_variant_with_functional_calibration_score_set, +): + for calibration in mock_mapped_variant_with_functional_calibration_score_set.variant.score_set.score_calibrations: + calibration.primary = not calibration.primary + result = variant_functional_impact_statement(mock_mapped_variant_with_functional_calibration_score_set) assert result is None -def test_variant_functional_impact_statement_no_score(mock_mapped_variant): - mock_mapped_variant.variant.data = {"score_data": {"score": None}} - result = variant_functional_impact_statement(mock_mapped_variant) +def test_variant_functional_impact_statement_no_score(mock_mapped_variant_with_functional_calibration_score_set): + mock_mapped_variant_with_functional_calibration_score_set.variant.data = {"score_data": {"score": None}} + result = variant_functional_impact_statement(mock_mapped_variant_with_functional_calibration_score_set) assert result is None -def test_variant_functional_impact_statement_with_score_ranges(mock_mapped_variant): - result = variant_functional_impact_statement(mock_mapped_variant) +def test_valid_variant_functional_impact_statement(mock_mapped_variant_with_functional_calibration_score_set): + result = variant_functional_impact_statement(mock_mapped_variant_with_functional_calibration_score_set) assert result is not None assert result.type == "Statement" @@ -46,49 +50,47 @@ def test_variant_functional_impact_statement_with_score_ranges(mock_mapped_varia ) -def test_variant_pathogenicity_evidence_no_score_ranges_no_thresholds(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = None - mock_mapped_variant.variant.score_set.score_calibrations = None +def test_variant_pathogenicity_evidence_no_calibrations(mock_mapped_variant): result = variant_pathogenicity_evidence(mock_mapped_variant) assert result is None -def test_variant_pathogenicity_evidence_no_score(mock_mapped_variant): - mock_mapped_variant.variant.data = {"score_data": {"score": None}} - result = variant_pathogenicity_evidence(mock_mapped_variant) +def test_variant_pathogenicity_evidence_no_score(mock_mapped_variant_with_pathogenicity_calibration_score_set): + mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.data = {"score_data": {"score": None}} + result = variant_pathogenicity_evidence(mock_mapped_variant_with_pathogenicity_calibration_score_set) assert result is None -def test_variant_pathogenicity_evidence_no_score_ranges_with_thresholds(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges.pop("investigator_provided") - result = variant_pathogenicity_evidence(mock_mapped_variant) - - assert result is not None - assert result.targetProposition.type == "VariantPathogenicityProposition" - assert all( - evidence_item.root.type == "ExperimentalVariantFunctionalImpactStudyResult" - for evidence_item in result.hasEvidenceItems - ) - - -def test_variant_pathogenicity_evidence_with_score_ranges_no_thresholds(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges.pop("zeiberg_calibration") - result = variant_pathogenicity_evidence(mock_mapped_variant) +def test_variant_pathogenicity_evidence_with_no_primary_calibration( + mock_mapped_variant_with_pathogenicity_calibration_score_set, +): + for ( + calibration + ) in mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.score_set.score_calibrations: + calibration.primary = not calibration.primary + result = variant_pathogenicity_evidence(mock_mapped_variant_with_pathogenicity_calibration_score_set) assert result is None -def test_variant_pathogenicity_evidence_with_score_ranges_no_threshold_data(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges["zeiberg_calibration"]["ranges"] = [] - result = variant_pathogenicity_evidence(mock_mapped_variant) +def test_variant_pathogenicity_evidence_with_no_acmg_classifications( + mock_mapped_variant_with_pathogenicity_calibration_score_set, +): + for ( + calibration + ) in mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.score_set.score_calibrations: + calibration.functional_ranges = [ + {**deepcopy(r), "acmgClassification": None} for r in calibration.functional_ranges + ] + result = variant_pathogenicity_evidence(mock_mapped_variant_with_pathogenicity_calibration_score_set) assert result is None -def test_variant_pathogenicity_evidence_with_score_ranges_with_thresholds(mock_mapped_variant): - result = variant_pathogenicity_evidence(mock_mapped_variant) +def test_variant_pathogenicity_evidence_with_calibrations(mock_mapped_variant_with_pathogenicity_calibration_score_set): + result = variant_pathogenicity_evidence(mock_mapped_variant_with_pathogenicity_calibration_score_set) assert result is not None assert result.targetProposition.type == "VariantPathogenicityProposition" diff --git a/tests/lib/annotation/test_classification.py b/tests/lib/annotation/test_classification.py index ecbf5140..83f2388d 100644 --- a/tests/lib/annotation/test_classification.py +++ b/tests/lib/annotation/test_classification.py @@ -1,12 +1,11 @@ import pytest - from ga4gh.va_spec.acmg_2015 import VariantPathogenicityEvidenceLine from ga4gh.va_spec.base.enums import StrengthOfEvidenceProvided from mavedb.lib.annotation.classification import ( - functional_classification_of_variant, - zeiberg_calibration_clinical_classification_of_variant, ExperimentalVariantFunctionalImpactClassification, + functional_classification_of_variant, + pathogenicity_classification_of_variant, ) @@ -14,65 +13,218 @@ "score,expected_classification", [ ( - -4, + 50000, + ExperimentalVariantFunctionalImpactClassification.INDETERMINATE, + ), + ( + 0, ExperimentalVariantFunctionalImpactClassification.INDETERMINATE, ), ( - 1, + 2, ExperimentalVariantFunctionalImpactClassification.NORMAL, ), ( - -1, + -2, ExperimentalVariantFunctionalImpactClassification.ABNORMAL, ), ], ) -def test_functional_classification_of_variant_with_ranges(mock_mapped_variant, score, expected_classification): - mock_mapped_variant.variant.data["score_data"]["score"] = score +def test_functional_classification_of_variant_with_ranges( + mock_mapped_variant_with_functional_calibration_score_set, score, expected_classification +): + mock_mapped_variant_with_functional_calibration_score_set.variant.data["score_data"]["score"] = score - result = functional_classification_of_variant(mock_mapped_variant) + result = functional_classification_of_variant(mock_mapped_variant_with_functional_calibration_score_set) assert result == expected_classification def test_functional_classification_of_variant_without_ranges(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = None - with pytest.raises(ValueError) as exc: functional_classification_of_variant(mock_mapped_variant) - assert f"Variant {mock_mapped_variant.variant.urn} does not have a score set with score ranges" in str(exc.value) + assert f"Variant {mock_mapped_variant.variant.urn} does not have a score set with score calibrations" in str( + exc.value + ) + + +def test_functional_classification_of_variant_without_score(mock_mapped_variant_with_functional_calibration_score_set): + mock_mapped_variant_with_functional_calibration_score_set.variant.data["score_data"]["score"] = None + + with pytest.raises(ValueError) as exc: + functional_classification_of_variant(mock_mapped_variant_with_functional_calibration_score_set) + + assert ( + f"Variant {mock_mapped_variant_with_functional_calibration_score_set.variant.urn} does not have a functional score" + in str(exc.value) + ) + + +def test_functional_classification_of_variant_without_primary_calibration( + mock_mapped_variant_with_functional_calibration_score_set, +): + for cal in mock_mapped_variant_with_functional_calibration_score_set.variant.score_set.score_calibrations: + cal.primary = False + + with pytest.raises(ValueError) as exc: + functional_classification_of_variant(mock_mapped_variant_with_functional_calibration_score_set) + + assert ( + f"Variant {mock_mapped_variant_with_functional_calibration_score_set.variant.urn} does not have a primary score calibration" + in str(exc.value) + ) + + +def test_functional_classification_of_variant_without_ranges_in_primary_calibration( + mock_mapped_variant_with_functional_calibration_score_set, +): + primary_cal = next( + ( + c + for c in mock_mapped_variant_with_functional_calibration_score_set.variant.score_set.score_calibrations + if c.primary + ), + None, + ) + assert primary_cal is not None + primary_cal.functional_ranges = None + + with pytest.raises(ValueError) as exc: + functional_classification_of_variant(mock_mapped_variant_with_functional_calibration_score_set) + + assert ( + f"Variant {mock_mapped_variant_with_functional_calibration_score_set.variant.urn} does not have ranges defined in its primary score calibration" + in str(exc.value) + ) @pytest.mark.parametrize( "score,expected_classification,expected_strength_of_evidence", [ (0, VariantPathogenicityEvidenceLine.Criterion.PS3, None), - (-1, VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.SUPPORTING), - (1, VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.SUPPORTING), - (-2, VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.MODERATE), - (2, VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.MODERATE), - (-4, VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.STRONG), - (4, VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.STRONG), - (-8, VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.VERY_STRONG), - (8, VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG), + (-2, VariantPathogenicityEvidenceLine.Criterion.PS3, StrengthOfEvidenceProvided.STRONG), + (2, VariantPathogenicityEvidenceLine.Criterion.BS3, StrengthOfEvidenceProvided.STRONG), ], ) -def test_clinical_classification_of_variant_with_thresholds( - score, mock_mapped_variant, expected_classification, expected_strength_of_evidence +def test_pathogenicity_classification_of_variant_with_thresholds( + score, + mock_mapped_variant_with_pathogenicity_calibration_score_set, + expected_classification, + expected_strength_of_evidence, ): - mock_mapped_variant.variant.data["score_data"]["score"] = score + mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.data["score_data"]["score"] = score - classification, strength = zeiberg_calibration_clinical_classification_of_variant(mock_mapped_variant) + classification, strength = pathogenicity_classification_of_variant( + mock_mapped_variant_with_pathogenicity_calibration_score_set + ) assert classification == expected_classification assert strength == expected_strength_of_evidence -def test_clinical_classification_of_variant_without_thresholds(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = None - +def test_pathogenicity_classification_of_variant_without_thresholds(mock_mapped_variant): with pytest.raises(ValueError) as exc: - zeiberg_calibration_clinical_classification_of_variant(mock_mapped_variant) + pathogenicity_classification_of_variant(mock_mapped_variant) - assert f"Variant {mock_mapped_variant.variant.urn} does not have a score set with score thresholds" in str( + assert f"Variant {mock_mapped_variant.variant.urn} does not have a score set with score calibrations" in str( exc.value ) + + +def test_pathogenicity_classification_of_variant_without_score( + mock_mapped_variant_with_pathogenicity_calibration_score_set, +): + mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.data["score_data"]["score"] = None + + with pytest.raises(ValueError) as exc: + pathogenicity_classification_of_variant(mock_mapped_variant_with_pathogenicity_calibration_score_set) + + assert ( + f"Variant {mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.urn} does not have a functional score" + in str(exc.value) + ) + + +def test_pathogenicity_classification_of_variant_without_primary_calibration( + mock_mapped_variant_with_pathogenicity_calibration_score_set, +): + for cal in mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.score_set.score_calibrations: + cal.primary = False + + with pytest.raises(ValueError) as exc: + pathogenicity_classification_of_variant(mock_mapped_variant_with_pathogenicity_calibration_score_set) + + assert ( + f"Variant {mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.urn} does not have a primary score calibration" + in str(exc.value) + ) + + +def test_pathogenicity_classification_of_variant_without_ranges_in_primary_calibration( + mock_mapped_variant_with_pathogenicity_calibration_score_set, +): + primary_cal = next( + ( + c + for c in mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.score_set.score_calibrations + if c.primary + ), + None, + ) + assert primary_cal is not None + primary_cal.functional_ranges = None + + with pytest.raises(ValueError) as exc: + pathogenicity_classification_of_variant(mock_mapped_variant_with_pathogenicity_calibration_score_set) + + assert ( + f"Variant {mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.urn} does not have ranges defined in its primary score calibration" + in str(exc.value) + ) + + +def test_pathogenicity_classification_of_variant_without_acmg_classification_in_ranges( + mock_mapped_variant_with_pathogenicity_calibration_score_set, +): + primary_cal = next( + ( + c + for c in mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.score_set.score_calibrations + if c.primary + ), + None, + ) + assert primary_cal is not None + for r in primary_cal.functional_ranges: + r["acmgClassification"] = None + + criterion, strength = pathogenicity_classification_of_variant( + mock_mapped_variant_with_pathogenicity_calibration_score_set + ) + + assert criterion == VariantPathogenicityEvidenceLine.Criterion.PS3 + assert strength is None + + +def test_pathogenicity_classification_of_variant_with_invalid_evidence_strength_in_acmg_classification( + mock_mapped_variant_with_pathogenicity_calibration_score_set, +): + primary_cal = next( + ( + c + for c in mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.score_set.score_calibrations + if c.primary + ), + None, + ) + assert primary_cal is not None + for r in primary_cal.functional_ranges: + r["acmgClassification"]["evidenceStrength"] = "moderate_plus" + r["oddspathsRatio"] = None + + with pytest.raises(ValueError) as exc: + pathogenicity_classification_of_variant(mock_mapped_variant_with_pathogenicity_calibration_score_set) + + assert ( + f"Variant {mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.urn} is contained in a clinical calibration range with an invalid evidence strength" + in str(exc.value) + ) diff --git a/tests/lib/annotation/test_evidence_line.py b/tests/lib/annotation/test_evidence_line.py index d51edd51..e5099c62 100644 --- a/tests/lib/annotation/test_evidence_line.py +++ b/tests/lib/annotation/test_evidence_line.py @@ -26,15 +26,22 @@ ], ) def test_acmg_evidence_line_with_met_valid_clinical_classification( - mock_mapped_variant, expected_outcome, expected_strength, expected_direction + mock_mapped_variant_with_pathogenicity_calibration_score_set, + expected_outcome, + expected_strength, + expected_direction, ): with patch( - "mavedb.lib.annotation.evidence_line.zeiberg_calibration_clinical_classification_of_variant", + "mavedb.lib.annotation.evidence_line.pathogenicity_classification_of_variant", return_value=(expected_outcome, expected_strength), ): - proposition = mapped_variant_to_experimental_variant_clinical_impact_proposition(mock_mapped_variant) - evidence = variant_functional_impact_statement(mock_mapped_variant) - result = acmg_evidence_line(mock_mapped_variant, proposition, [evidence]) + proposition = mapped_variant_to_experimental_variant_clinical_impact_proposition( + mock_mapped_variant_with_pathogenicity_calibration_score_set + ) + evidence = variant_functional_impact_statement(mock_mapped_variant_with_pathogenicity_calibration_score_set) + result = acmg_evidence_line( + mock_mapped_variant_with_pathogenicity_calibration_score_set, proposition, [evidence] + ) if expected_strength == StrengthOfEvidenceProvided.STRONG: expected_evidence_outcome = expected_outcome.value @@ -42,7 +49,10 @@ def test_acmg_evidence_line_with_met_valid_clinical_classification( expected_evidence_outcome = f"{expected_outcome.value}_{expected_strength.name.lower()}" assert isinstance(result, VariantPathogenicityEvidenceLine) - assert result.description == f"Pathogenicity evidence line {mock_mapped_variant.variant.urn}." + assert ( + result.description + == f"Pathogenicity evidence line {mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.urn}." + ) assert result.evidenceOutcome.primaryCoding.code.root == expected_evidence_outcome assert result.evidenceOutcome.primaryCoding.system == "ACMG Guidelines, 2015" assert result.evidenceOutcome.name == f"ACMG 2015 {expected_outcome.name} Criterion Met" @@ -56,21 +66,30 @@ def test_acmg_evidence_line_with_met_valid_clinical_classification( assert result.hasEvidenceItems[0] == evidence -def test_acmg_evidence_line_with_not_met_clinical_classification(mock_mapped_variant): +def test_acmg_evidence_line_with_not_met_clinical_classification( + mock_mapped_variant_with_pathogenicity_calibration_score_set, +): expected_outcome = VariantPathogenicityEvidenceLine.Criterion.PS3 expected_strength = None expected_evidence_outcome = f"{expected_outcome.value}_not_met" with patch( - "mavedb.lib.annotation.evidence_line.zeiberg_calibration_clinical_classification_of_variant", + "mavedb.lib.annotation.evidence_line.pathogenicity_classification_of_variant", return_value=(expected_outcome, expected_strength), ): - proposition = mapped_variant_to_experimental_variant_clinical_impact_proposition(mock_mapped_variant) - evidence = variant_functional_impact_statement(mock_mapped_variant) - result = acmg_evidence_line(mock_mapped_variant, proposition, [evidence]) + proposition = mapped_variant_to_experimental_variant_clinical_impact_proposition( + mock_mapped_variant_with_pathogenicity_calibration_score_set + ) + evidence = variant_functional_impact_statement(mock_mapped_variant_with_pathogenicity_calibration_score_set) + result = acmg_evidence_line( + mock_mapped_variant_with_pathogenicity_calibration_score_set, proposition, [evidence] + ) assert isinstance(result, VariantPathogenicityEvidenceLine) - assert result.description == f"Pathogenicity evidence line {mock_mapped_variant.variant.urn}." + assert ( + result.description + == f"Pathogenicity evidence line {mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.urn}." + ) assert result.evidenceOutcome.primaryCoding.code.root == expected_evidence_outcome assert result.evidenceOutcome.primaryCoding.system == "ACMG Guidelines, 2015" assert result.evidenceOutcome.name == f"ACMG 2015 {expected_outcome.name} Criterion Not Met" @@ -83,15 +102,15 @@ def test_acmg_evidence_line_with_not_met_clinical_classification(mock_mapped_var assert result.hasEvidenceItems[0] == evidence -def test_acmg_evidence_line_with_no_score_thresholds(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = None +def test_acmg_evidence_line_with_no_calibrations(mock_mapped_variant): + mock_mapped_variant.variant.score_set.score_calibrations = None with pytest.raises(ValueError) as exc: proposition = mapped_variant_to_experimental_variant_clinical_impact_proposition(mock_mapped_variant) evidence = variant_functional_impact_statement(mock_mapped_variant) acmg_evidence_line(mock_mapped_variant, proposition, [evidence]) - assert f"Variant {mock_mapped_variant.variant.urn} does not have a score set with score thresholds" in str( + assert f"Variant {mock_mapped_variant.variant.urn} does not have a score set with score calibrations" in str( exc.value ) diff --git a/tests/lib/annotation/test_statement.py b/tests/lib/annotation/test_statement.py index ae19fc1c..c3cec32d 100644 --- a/tests/lib/annotation/test_statement.py +++ b/tests/lib/annotation/test_statement.py @@ -39,8 +39,8 @@ def test_mapped_variant_to_functional_statement(mock_mapped_variant, classificat assert result.hasEvidenceLines[0] == evidence -def test_mapped_variant_to_functional_statement_no_score_ranges(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = None +def test_mapped_variant_to_functional_statement_no_calibrations(mock_mapped_variant): + mock_mapped_variant.variant.score_set.score_calibrations = None proposition = mapped_variant_to_experimental_variant_functional_impact_proposition(mock_mapped_variant) evidence = functional_evidence_line(mock_mapped_variant, [variant_study_result(mock_mapped_variant)]) @@ -48,4 +48,6 @@ def test_mapped_variant_to_functional_statement_no_score_ranges(mock_mapped_vari with pytest.raises(ValueError) as exc: mapped_variant_to_functional_statement(mock_mapped_variant, proposition, [evidence]) - assert f"Variant {mock_mapped_variant.variant.urn} does not have a score set with score ranges" in str(exc.value) + assert f"Variant {mock_mapped_variant.variant.urn} does not have a score set with score calibrations" in str( + exc.value + ) diff --git a/tests/lib/annotation/test_util.py b/tests/lib/annotation/test_util.py index e21f61de..afb19cbe 100644 --- a/tests/lib/annotation/test_util.py +++ b/tests/lib/annotation/test_util.py @@ -1,19 +1,16 @@ +from copy import deepcopy import pytest from mavedb.lib.annotation.exceptions import MappingDataDoesntExistException from mavedb.lib.annotation.util import ( variation_from_mapped_variant, _can_annotate_variant_base_assumptions, - _variant_score_ranges_have_required_keys_and_ranges_for_annotation, + _variant_score_calibrations_have_required_calibrations_and_ranges_for_annotation, can_annotate_variant_for_functional_statement, can_annotate_variant_for_pathogenicity_evidence, ) -from tests.helpers.constants import ( - TEST_VALID_POST_MAPPED_VRS_ALLELE, - TEST_SEQUENCE_LOCATION_ACCESSION, - TEST_BRNICH_SCORE_SET_RANGE_WITH_SOURCE, -) +from tests.helpers.constants import TEST_VALID_POST_MAPPED_VRS_ALLELE, TEST_SEQUENCE_LOCATION_ACCESSION from unittest.mock import patch @@ -53,88 +50,163 @@ def test_base_assumption_check_returns_true_when_all_conditions_met(mock_mapped_ ## Test variant score ranges have required keys for annotation -def test_score_range_check_returns_false_when_keys_are_none(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = None - key_options = ["required_key1", "required_key2"] +@pytest.mark.parametrize("kind", ["functional", "pathogenicity"]) +def test_score_range_check_returns_false_when_no_calibrations_present(mock_mapped_variant, kind): + assert ( + _variant_score_calibrations_have_required_calibrations_and_ranges_for_annotation(mock_mapped_variant, kind) + is False + ) - assert _variant_score_ranges_have_required_keys_and_ranges_for_annotation(mock_mapped_variant, key_options) is False +@pytest.mark.parametrize( + "kind,variant_fixture", + [ + ("functional", "mock_mapped_variant_with_functional_calibration_score_set"), + ("pathogenicity", "mock_mapped_variant_with_pathogenicity_calibration_score_set"), + ], +) +def test_score_range_check_returns_false_when_no_primary_calibration(kind, variant_fixture, request): + mock_mapped_variant = request.getfixturevalue(variant_fixture) + for calibration in mock_mapped_variant.variant.score_set.score_calibrations: + calibration.primary = False -def test_score_range_check_returns_false_when_no_keys_present(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = {"other_key": TEST_BRNICH_SCORE_SET_RANGE_WITH_SOURCE} - key_options = ["required_key1", "required_key2"] + assert ( + _variant_score_calibrations_have_required_calibrations_and_ranges_for_annotation(mock_mapped_variant, kind) + is False + ) - assert _variant_score_ranges_have_required_keys_and_ranges_for_annotation(mock_mapped_variant, key_options) is False +@pytest.mark.parametrize( + "kind,variant_fixture", + [ + ("functional", "mock_mapped_variant_with_functional_calibration_score_set"), + ("pathogenicity", "mock_mapped_variant_with_pathogenicity_calibration_score_set"), + ], +) +def test_score_range_check_returns_false_when_calibrations_present_with_empty_ranges(kind, variant_fixture, request): + mock_mapped_variant = request.getfixturevalue(variant_fixture) -def test_score_range_check_returns_false_when_key_present_but_value_is_none(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = {"required_key1": None} - key_options = ["required_key1", "required_key2"] + for calibration in mock_mapped_variant.variant.score_set.score_calibrations: + calibration.functional_ranges = None - assert _variant_score_ranges_have_required_keys_and_ranges_for_annotation(mock_mapped_variant, key_options) is False + assert ( + _variant_score_calibrations_have_required_calibrations_and_ranges_for_annotation(mock_mapped_variant, kind) + is False + ) -def test_score_range_check_returns_false_when_key_present_but_range_value_is_empty(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = {"required_key1": {"ranges": []}} - key_options = ["required_key1", "required_key2"] +def test_pathogenicity_range_check_returns_false_when_no_acmg_calibration( + mock_mapped_variant_with_pathogenicity_calibration_score_set, +): + for ( + calibration + ) in mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.score_set.score_calibrations: + acmg_classification_removed = [deepcopy(r) for r in calibration.functional_ranges] + for fr in acmg_classification_removed: + fr["acmgClassification"] = None - assert _variant_score_ranges_have_required_keys_and_ranges_for_annotation(mock_mapped_variant, key_options) is False + calibration.functional_ranges = acmg_classification_removed + assert ( + _variant_score_calibrations_have_required_calibrations_and_ranges_for_annotation( + mock_mapped_variant_with_pathogenicity_calibration_score_set, "pathogenicity" + ) + is False + ) -def test_score_range_check_returns_none_when_at_least_one_key_has_value(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = {"required_key1": TEST_BRNICH_SCORE_SET_RANGE_WITH_SOURCE} - key_options = ["required_key1", "required_key2"] - assert _variant_score_ranges_have_required_keys_and_ranges_for_annotation(mock_mapped_variant, key_options) is True +def test_pathogenicity_range_check_returns_true_when_some_acmg_calibration( + mock_mapped_variant_with_pathogenicity_calibration_score_set, +): + for ( + calibration + ) in mock_mapped_variant_with_pathogenicity_calibration_score_set.variant.score_set.score_calibrations: + acmg_classification_removed = [deepcopy(r) for r in calibration.functional_ranges] + acmg_classification_removed[0]["acmgClassification"] = None + + calibration.functional_ranges = acmg_classification_removed + + assert ( + _variant_score_calibrations_have_required_calibrations_and_ranges_for_annotation( + mock_mapped_variant_with_pathogenicity_calibration_score_set, "pathogenicity" + ) + is True + ) + + +@pytest.mark.parametrize( + "kind,variant_fixture", + [ + ("functional", "mock_mapped_variant_with_functional_calibration_score_set"), + ("pathogenicity", "mock_mapped_variant_with_pathogenicity_calibration_score_set"), + ], +) +def test_score_range_check_returns_true_when_calibration_kind_exists_with_ranges(kind, variant_fixture, request): + mock_mapped_variant = request.getfixturevalue(variant_fixture) + + assert ( + _variant_score_calibrations_have_required_calibrations_and_ranges_for_annotation(mock_mapped_variant, kind) + is True + ) ## Test clinical range check -def test_clinical_range_check_returns_false_when_base_assumptions_fail(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = None - result = can_annotate_variant_for_pathogenicity_evidence(mock_mapped_variant) +def test_pathogenicity_range_check_returns_false_when_base_assumptions_fail(mock_mapped_variant): + with patch("mavedb.lib.annotation.util._can_annotate_variant_base_assumptions", return_value=False): + result = can_annotate_variant_for_pathogenicity_evidence(mock_mapped_variant) assert result is False -@pytest.mark.parametrize("clinical_ranges", [["clinical_range"], ["other_clinical_range"]]) -def test_clinical_range_check_returns_false_when_clinical_ranges_check_fails(mock_mapped_variant, clinical_ranges): - mock_mapped_variant.variant.score_set.score_ranges = {"unrelated_key": TEST_BRNICH_SCORE_SET_RANGE_WITH_SOURCE} - - with patch("mavedb.lib.annotation.util.CLINICAL_RANGES", clinical_ranges): +def test_pathogenicity_range_check_returns_false_when_pathogenicity_ranges_check_fails(mock_mapped_variant): + with patch( + "mavedb.lib.annotation.util._variant_score_calibrations_have_required_calibrations_and_ranges_for_annotation", + return_value=False, + ): result = can_annotate_variant_for_pathogenicity_evidence(mock_mapped_variant) assert result is False # The default mock_mapped_variant object should be valid -def test_clinical_range_check_returns_true_when_all_conditions_met(mock_mapped_variant): - assert can_annotate_variant_for_pathogenicity_evidence(mock_mapped_variant) is True +def test_pathogenicity_range_check_returns_true_when_all_conditions_met( + mock_mapped_variant_with_pathogenicity_calibration_score_set, +): + assert ( + can_annotate_variant_for_pathogenicity_evidence(mock_mapped_variant_with_pathogenicity_calibration_score_set) + is True + ) ## Test functional range check def test_functional_range_check_returns_false_when_base_assumptions_fail(mock_mapped_variant): - mock_mapped_variant.variant.score_set.score_ranges = None - result = can_annotate_variant_for_functional_statement(mock_mapped_variant) + with patch( + "mavedb.lib.annotation.util._can_annotate_variant_base_assumptions", + return_value=False, + ): + result = can_annotate_variant_for_functional_statement(mock_mapped_variant) assert result is False -@pytest.mark.parametrize("functional_ranges", [["functional_range"], ["other_functional_range"]]) -def test_functional_range_check_returns_false_when_functional_ranges_check_fails( - mock_mapped_variant, functional_ranges -): - mock_mapped_variant.variant.score_set.score_ranges = {"unrelated_key": TEST_BRNICH_SCORE_SET_RANGE_WITH_SOURCE} - - with patch("mavedb.lib.annotation.util.FUNCTIONAL_RANGES", functional_ranges): +def test_functional_range_check_returns_false_when_functional_ranges_check_fails(mock_mapped_variant): + with patch( + "mavedb.lib.annotation.util._variant_score_calibrations_have_required_calibrations_and_ranges_for_annotation", + return_value=False, + ): result = can_annotate_variant_for_functional_statement(mock_mapped_variant) assert result is False # The default mock_mapped_variant object should be valid -def test_functional_range_check_returns_true_when_all_conditions_met(mock_mapped_variant): - assert can_annotate_variant_for_functional_statement(mock_mapped_variant) is True +def test_functional_range_check_returns_true_when_all_conditions_met( + mock_mapped_variant_with_functional_calibration_score_set, +): + assert ( + can_annotate_variant_for_functional_statement(mock_mapped_variant_with_functional_calibration_score_set) is True + ) diff --git a/tests/lib/conftest.py b/tests/lib/conftest.py index 5a797e80..5cffa374 100644 --- a/tests/lib/conftest.py +++ b/tests/lib/conftest.py @@ -1,3 +1,4 @@ +from humps import decamelize from copy import deepcopy from datetime import datetime from pathlib import Path @@ -6,6 +7,7 @@ from unittest import mock from mavedb.models.enums.user_role import UserRole +from mavedb.models.score_calibration import ScoreCalibration from mavedb.models.experiment_set import ExperimentSet from mavedb.models.experiment import Experiment from mavedb.models.license import License @@ -35,7 +37,8 @@ VALID_SCORE_SET_URN, VALID_EXPERIMENT_URN, VALID_EXPERIMENT_SET_URN, - TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, + TEST_SAVED_BRNICH_SCORE_CALIBRATION, + TEST_SAVED_PATHOGENICITY_SCORE_CALIBRATION, TEST_PUBMED_IDENTIFIER, ) @@ -61,13 +64,18 @@ def setup_lib_db_with_score_set(session, setup_lib_db): """ Sets up the lib test db with a user, reference, license, and a score set. """ + user = session.query(User).filter(User.username == TEST_USER["username"]).first() experiment_set = ExperimentSet(**TEST_EXPERIMENT_SET, urn=VALID_EXPERIMENT_SET_URN) + experiment_set.created_by = user + experiment_set.modified_by = user session.add(experiment_set) session.commit() session.refresh(experiment_set) experiment = Experiment(**TEST_EXPERIMENT, urn=VALID_EXPERIMENT_URN, experiment_set_id=experiment_set.id) + experiment.created_by = user + experiment.modified_by = user session.add(experiment) session.commit() session.refresh(experiment) @@ -77,7 +85,8 @@ def setup_lib_db_with_score_set(session, setup_lib_db): score_set = ScoreSet( **score_set_scaffold, urn=VALID_SCORE_SET_URN, experiment_id=experiment.id, licence_id=TEST_LICENSE["id"] ) - + score_set.created_by = user + score_set.modified_by = user session.add(score_set) session.commit() session.refresh(score_set) @@ -121,6 +130,8 @@ def setup_lib_db_with_mapped_variant(session, setup_lib_db_with_variant): def mock_user(): mv = mock.Mock(spec=User) mv.username = TEST_USER["username"] + mv.first_name = TEST_USER["first_name"] + mv.last_name = TEST_USER["last_name"] return mv @@ -162,11 +173,41 @@ def mock_experiment(): return experiment +@pytest.fixture +def mock_functional_calibration(mock_user): + calibration = mock.Mock(spec=ScoreCalibration) + + for key, value in TEST_SAVED_BRNICH_SCORE_CALIBRATION.items(): + setattr(calibration, decamelize(key), deepcopy(value)) + + calibration.primary = True # Ensure functional calibration is primary for tests + calibration.notes = None + calibration.publication_identifier_associations = [] + calibration.created_by = mock_user + calibration.modified_by = mock_user + return calibration + + +@pytest.fixture +def mock_pathogenicity_calibration(mock_user): + calibration = mock.Mock(spec=ScoreCalibration) + + for key, value in TEST_SAVED_PATHOGENICITY_SCORE_CALIBRATION.items(): + setattr(calibration, decamelize(key), deepcopy(value)) + + calibration.primary = True # Ensure pathogenicity calibration is primary for tests + calibration.notes = None + calibration.publication_identifier_associations = [] + calibration.created_by = mock_user + calibration.modified_by = mock_user + return calibration + + @pytest.fixture def mock_score_set(mock_user, mock_experiment, mock_publication_associations): score_set = mock.Mock(spec=ScoreSet) + score_set.score_calibrations = [] score_set.urn = VALID_SCORE_SET_URN - score_set.score_ranges = deepcopy(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT) score_set.license.short_name = "MIT" score_set.created_by = mock_user score_set.modified_by = mock_user @@ -182,6 +223,18 @@ def mock_score_set(mock_user, mock_experiment, mock_publication_associations): return score_set +@pytest.fixture +def mock_score_set_with_functional_calibrations(mock_score_set, mock_functional_calibration): + mock_score_set.score_calibrations = [mock_functional_calibration] + return mock_score_set + + +@pytest.fixture +def mock_score_set_with_pathogenicity_calibrations(mock_score_set, mock_pathogenicity_calibration): + mock_score_set.score_calibrations = [mock_pathogenicity_calibration] + return mock_score_set + + @pytest.fixture def mock_variant(mock_score_set): variant = mock.Mock(spec=Variant) @@ -193,6 +246,18 @@ def mock_variant(mock_score_set): return variant +@pytest.fixture +def mock_variant_with_functional_calibration_score_set(mock_variant, mock_score_set_with_functional_calibrations): + mock_variant.score_set = mock_score_set_with_functional_calibrations + return mock_variant + + +@pytest.fixture +def mock_variant_with_pathogenicity_calibration_score_set(mock_variant, mock_score_set_with_pathogenicity_calibrations): + mock_variant.score_set = mock_score_set_with_pathogenicity_calibrations + return mock_variant + + @pytest.fixture def mock_mapped_variant(mock_variant): mv = mock.Mock(spec=MappedVariant) @@ -207,6 +272,22 @@ def mock_mapped_variant(mock_variant): return mv +@pytest.fixture +def mock_mapped_variant_with_functional_calibration_score_set( + mock_mapped_variant, mock_variant_with_functional_calibration_score_set +): + mock_mapped_variant.variant = mock_variant_with_functional_calibration_score_set + return mock_mapped_variant + + +@pytest.fixture +def mock_mapped_variant_with_pathogenicity_calibration_score_set( + mock_mapped_variant, mock_variant_with_pathogenicity_calibration_score_set +): + mock_mapped_variant.variant = mock_variant_with_pathogenicity_calibration_score_set + return mock_mapped_variant + + @pytest.fixture def mocked_gnomad_variant_row(): gnomad_variant = mock.Mock() diff --git a/tests/lib/test_acmg.py b/tests/lib/test_acmg.py new file mode 100644 index 00000000..db458439 --- /dev/null +++ b/tests/lib/test_acmg.py @@ -0,0 +1,81 @@ +import pytest + +from mavedb.lib.acmg import ( + ACMGCriterion, + StrengthOfEvidenceProvided, + points_evidence_strength_equivalent, +) + + +@pytest.mark.parametrize( + "points,expected_criterion,expected_strength", + [ + (8, ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG), + (7, ACMGCriterion.PS3, StrengthOfEvidenceProvided.STRONG), + (4, ACMGCriterion.PS3, StrengthOfEvidenceProvided.STRONG), + (3, ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE_PLUS), + (2, ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE), + (1, ACMGCriterion.PS3, StrengthOfEvidenceProvided.SUPPORTING), + (0, None, None), + (-1, ACMGCriterion.BS3, StrengthOfEvidenceProvided.SUPPORTING), + (-2, ACMGCriterion.BS3, StrengthOfEvidenceProvided.MODERATE), + (-3, ACMGCriterion.BS3, StrengthOfEvidenceProvided.MODERATE_PLUS), + (-4, ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG), + (-5, ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG), + (-7, ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG), + (-8, ACMGCriterion.BS3, StrengthOfEvidenceProvided.VERY_STRONG), + ], +) +def test_points_mapping(points, expected_criterion, expected_strength): + criterion, strength = points_evidence_strength_equivalent(points) + assert criterion == expected_criterion + assert strength == expected_strength + + +@pytest.mark.parametrize("invalid_points", [-9, 9, 100, -100]) +def test_out_of_points_range_raises(invalid_points): + with pytest.raises( + ValueError, + match="Points value must be between -8 and 8 inclusive", + ): + points_evidence_strength_equivalent(invalid_points) + + +def test_pathogenic_vs_benign_flags(): + for p in range(-8, 9): + criterion, strength = points_evidence_strength_equivalent(p) + if p > 0: + assert criterion is not None + assert criterion.is_pathogenic + assert not criterion.is_benign + elif p < 0: + assert criterion is not None + assert criterion.is_benign + assert not criterion.is_pathogenic + else: + assert criterion is None + assert strength is None + + +def test_positive_always_ps3_negative_always_bs3(): + positives = [p for p in range(1, 9)] + negatives = [p for p in range(-8, 0)] + for p in positives: + c, _ = points_evidence_strength_equivalent(p) + assert c == ACMGCriterion.PS3 + for p in negatives: + c, _ = points_evidence_strength_equivalent(p) + assert c == ACMGCriterion.BS3 + + +def test_all_strength_categories_covered(): + seen = set() + for p in range(-8, 9): + _, strength = points_evidence_strength_equivalent(p) + if strength: + seen.add(strength) + assert StrengthOfEvidenceProvided.VERY_STRONG in seen + assert StrengthOfEvidenceProvided.STRONG in seen + assert StrengthOfEvidenceProvided.MODERATE_PLUS in seen + assert StrengthOfEvidenceProvided.MODERATE in seen + assert StrengthOfEvidenceProvided.SUPPORTING in seen diff --git a/tests/lib/test_odds_paths.py b/tests/lib/test_odds_paths.py new file mode 100644 index 00000000..ce44546b --- /dev/null +++ b/tests/lib/test_odds_paths.py @@ -0,0 +1,100 @@ +import pytest + +from mavedb.lib.acmg import ACMGCriterion, StrengthOfEvidenceProvided +from mavedb.lib.oddspaths import oddspaths_evidence_strength_equivalent + + +@pytest.mark.parametrize( + "ratio,expected_criterion,expected_strength", + [ + # Upper pathogenic tiers (strict >) + (351, ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG), + (350.0001, ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG), + (350, ACMGCriterion.PS3, StrengthOfEvidenceProvided.STRONG), # boundary + (19, ACMGCriterion.PS3, StrengthOfEvidenceProvided.STRONG), + (18.60001, ACMGCriterion.PS3, StrengthOfEvidenceProvided.STRONG), + (18.6, ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE), # boundary + (5, ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE), + (4.30001, ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE), + (4.3, ACMGCriterion.PS3, StrengthOfEvidenceProvided.SUPPORTING), # boundary + (2.10001, ACMGCriterion.PS3, StrengthOfEvidenceProvided.SUPPORTING), + # Indeterminate band + (2.1, None, None), # boundary just below >2.1 + (0.48, None, None), + (0.50001, None, None), + # Benign supporting + (0.479999, ACMGCriterion.BS3, StrengthOfEvidenceProvided.SUPPORTING), + (0.23, ACMGCriterion.BS3, StrengthOfEvidenceProvided.SUPPORTING), + # Benign moderate + (0.229999, ACMGCriterion.BS3, StrengthOfEvidenceProvided.MODERATE), + (0.053, ACMGCriterion.BS3, StrengthOfEvidenceProvided.MODERATE), + # Benign strong + (0.052999, ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG), + (0.01, ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG), + (0.0, ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG), + # Very high ratio + (1000, ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG), + ], +) +def test_oddspaths_classification(ratio, expected_criterion, expected_strength): + criterion, strength = oddspaths_evidence_strength_equivalent(ratio) + assert criterion == expected_criterion + assert strength == expected_strength + + +@pytest.mark.parametrize("neg_ratio", [-1e-9, -0.01, -5]) +def test_negative_ratio_raises_value_error(neg_ratio): + with pytest.raises(ValueError): + oddspaths_evidence_strength_equivalent(neg_ratio) + + +def test_each_interval_is_exclusive(): + # Sorted representative ratios spanning all tiers + samples = [ + (0.0, 0.0529999), # BS3 STRONG + (0.053, 0.229999), # BS3 MODERATE + (0.23, 0.479999), # BS3 SUPPORTING + (0.48, 2.1), # Indeterminate + (2.10001, 4.3), # PS3 SUPPORTING + (4.30001, 18.6), # PS3 MODERATE + (18.60001, 350), # PS3 STRONG + (350.0001, float("inf")), # PS3 VERY_STRONG (no upper bound) + ] + seen = set() + for r in samples: + lower_result = oddspaths_evidence_strength_equivalent(r[0]) + upper_result = oddspaths_evidence_strength_equivalent(r[1]) + assert lower_result == upper_result, f"Mismatch at interval {r}" + + assert all( + result not in seen for result in [lower_result, upper_result] + ), f"Duplicate classification for ratio {r}" + seen.add(lower_result) + + +@pytest.mark.parametrize( + "lower,upper", + [ + (0.053, 0.23), # BS3 MODERATE -> BS3 SUPPORTING transition + (0.23, 0.48), # BS3 SUPPORTING -> Indeterminate + (0.48, 2.1), # Indeterminate band + (2.1, 4.3), # Indeterminate -> PS3 SUPPORTING + (4.3, 18.6), # PS3 SUPPORTING -> PS3 MODERATE + (18.6, 350), # PS3 MODERATE -> PS3 STRONG + (350, 351), # PS3 STRONG -> PS3 VERY_STRONG + ], +) +def test_monotonic_direction(lower, upper): + crit_low, strength_low = oddspaths_evidence_strength_equivalent(lower) + crit_high, strength_high = oddspaths_evidence_strength_equivalent(upper) + # If categories differ, ensure ordering progression (not regression to benign when moving upward) + benign_set = {ACMGCriterion.BS3} + pathogenic_set = {ACMGCriterion.PS3} + if crit_low != crit_high: + # Moving upward should not go from pathogenic to benign + assert not (crit_low in pathogenic_set and crit_high in benign_set) + + +def test_return_types(): + c, s = oddspaths_evidence_strength_equivalent(0.7) + assert (c is None and s is None) or (isinstance(c, ACMGCriterion) and isinstance(s, StrengthOfEvidenceProvided)) diff --git a/tests/lib/test_score_calibrations.py b/tests/lib/test_score_calibrations.py new file mode 100644 index 00000000..9ca1b010 --- /dev/null +++ b/tests/lib/test_score_calibrations.py @@ -0,0 +1,1252 @@ +# ruff: noqa: E402 + +import pytest + +pytest.importorskip("psycopg2") + +from unittest import mock + +from pydantic import create_model +from sqlalchemy import select +from sqlalchemy.exc import NoResultFound + +from mavedb.lib.score_calibrations import ( + create_score_calibration, + create_score_calibration_in_score_set, + delete_score_calibration, + demote_score_calibration_from_primary, + modify_score_calibration, + promote_score_calibration_to_primary, + publish_score_calibration, +) +from mavedb.models.enums.score_calibration_relation import ScoreCalibrationRelation +from mavedb.models.score_calibration import ScoreCalibration +from mavedb.models.score_set import ScoreSet +from mavedb.models.user import User +from mavedb.view_models.score_calibration import ScoreCalibrationCreate, ScoreCalibrationModify + +from tests.helpers.constants import ( + TEST_BIORXIV_IDENTIFIER, + TEST_BRNICH_SCORE_CALIBRATION, + TEST_CROSSREF_IDENTIFIER, + TEST_LICENSE, + TEST_PATHOGENICITY_SCORE_CALIBRATION, + TEST_PUBMED_IDENTIFIER, + TEST_SEQ_SCORESET, + VALID_SCORE_SET_URN, + EXTRA_USER, +) +from tests.helpers.util.contributor import add_contributor +from tests.helpers.util.score_calibration import create_test_score_calibration_in_score_set + +################################################################################ +# Tests for create_score_calibration +################################################################################ + + +### create_score_calibration_in_score_set + + +@pytest.mark.asyncio +async def test_create_score_set_in_score_set_raises_value_error_when_score_set_urn_is_missing( + setup_lib_db, session, mock_user +): + MockCalibrationCreate = create_model("MockCalibrationCreate", score_set_urn=(str | None, None)) + with pytest.raises( + ValueError, + match="score_set_urn must be provided to create a score calibration.", + ): + await create_score_calibration_in_score_set(session, MockCalibrationCreate(), mock_user) + + +@pytest.mark.asyncio +async def test_create_score_set_in_score_set_raises_no_result_found_error_when_score_set_does_not_exist( + setup_lib_db, session, mock_user +): + MockCalibrationCreate = create_model("MockCalibrationCreate", score_set_urn=(str | None, "urn:invalid")) + with pytest.raises( + NoResultFound, + match="No row was found when one was required", + ): + await create_score_calibration_in_score_set(session, MockCalibrationCreate(), mock_user) + + +@pytest.mark.asyncio +async def test_create_score_calibration_in_score_set_creates_score_calibration_when_score_set_exists( + setup_lib_db_with_score_set, session +): + test_user = session.execute(select(User)).scalars().first() + + MockCalibrationCreate = create_model( + "MockCalibrationCreate", + score_set_urn=(str | None, setup_lib_db_with_score_set.urn), + threshold_sources=(list, []), + classification_sources=(list, []), + method_sources=(list, []), + ) + + calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), test_user) + assert calibration is not None + assert calibration.score_set == setup_lib_db_with_score_set + + +@pytest.mark.asyncio +async def test_create_score_calibration_in_score_set_investigator_provided_set_when_creator_is_owner( + setup_lib_db_with_score_set, session, mock_user +): + test_user = session.execute(select(User)).scalars().first() + + MockCalibrationCreate = create_model( + "MockCalibrationCreate", + score_set_urn=(str | None, setup_lib_db_with_score_set.urn), + threshold_sources=(list, []), + classification_sources=(list, []), + method_sources=(list, []), + ) + + calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), test_user) + assert calibration is not None + assert calibration.score_set == setup_lib_db_with_score_set + assert calibration.created_by == test_user + assert calibration.modified_by == test_user + assert calibration.investigator_provided is True + + +@pytest.mark.asyncio +async def test_create_score_calibration_in_score_set_investigator_provided_set_when_creator_is_contributor( + setup_lib_db_with_score_set, session +): + extra_user = session.execute(select(User).where(User.username == EXTRA_USER["username"])).scalars().first() + + add_contributor( + session, + setup_lib_db_with_score_set.urn, + ScoreSet, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) + + MockCalibrationCreate = create_model( + "MockCalibrationCreate", + score_set_urn=(str | None, setup_lib_db_with_score_set.urn), + threshold_sources=(list, []), + classification_sources=(list, []), + method_sources=(list, []), + ) + + calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), extra_user) + assert calibration is not None + assert calibration.score_set == setup_lib_db_with_score_set + assert calibration.created_by == extra_user + assert calibration.modified_by == extra_user + assert calibration.investigator_provided is True + + +@pytest.mark.asyncio +async def test_create_score_calibration_in_score_set_investigator_provided_not_set_when_creator_not_owner( + setup_lib_db_with_score_set, session +): + MockCalibrationCreate = create_model( + "MockCalibrationCreate", + score_set_urn=(str | None, setup_lib_db_with_score_set.urn), + threshold_sources=(list, []), + classification_sources=(list, []), + method_sources=(list, []), + ) + + # invoke from a different user context + extra_user = session.execute(select(User).where(User.username == EXTRA_USER["username"])).scalars().first() + + calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), extra_user) + assert calibration is not None + assert calibration.score_set == setup_lib_db_with_score_set + assert calibration.created_by == extra_user + assert calibration.modified_by == extra_user + assert calibration.investigator_provided is False + + +### create_score_calibration + + +@pytest.mark.asyncio +async def test_create_score_calibration_raises_value_error_when_score_set_urn_is_provided( + setup_lib_db, session, mock_user +): + MockCalibrationCreate = create_model("MockCalibrationCreate", score_set_urn=(str | None, "urn:provided")) + with pytest.raises( + ValueError, + match="score_set_urn must not be provided to create a score calibration outside a score set.", + ): + await create_score_calibration(session, MockCalibrationCreate(), mock_user) + + +@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() + + MockCalibrationCreate = create_model( + "MockCalibrationCreate", + score_set_urn=(str | None, None), + threshold_sources=(list, []), + classification_sources=(list, []), + method_sources=(list, []), + ) + + calibration = await create_score_calibration(session, MockCalibrationCreate(), test_user) + assert calibration is not None + assert calibration.score_set is None + + +### Shared tests for create_score_calibration_in_score_set and create_score_calibration + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "create_function_to_call,score_set_urn", + [ + (create_score_calibration_in_score_set, VALID_SCORE_SET_URN), + (create_score_calibration, None), + ], +) +async def test_create_score_calibration_propagates_errors_from_publication_find_create( + setup_lib_db_with_score_set, session, mock_user, create_function_to_call, score_set_urn +): + MockCalibrationCreate = create_model( + "MockCalibrationCreate", + score_set_urn=(str | None, score_set_urn), + threshold_sources=( + list, + [ + create_model( + "MockPublicationCreate", db_name=(str, "PubMed"), identifier=(str, TEST_PUBMED_IDENTIFIER) + )() + ], + ), + classification_sources=(list, []), + method_sources=(list, []), + ) + with ( + pytest.raises( + ValueError, + match="Propagated error", + ), + mock.patch( + "mavedb.lib.score_calibrations.find_or_create_publication_identifier", + side_effect=ValueError("Propagated error"), + ), + ): + await create_function_to_call(session, MockCalibrationCreate(), mock_user) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "create_function_to_call,score_set_urn", + [ + (create_score_calibration_in_score_set, VALID_SCORE_SET_URN), + (create_score_calibration, None), + ], +) +@pytest.mark.parametrize( + "relation,expected_relation", + [ + ("threshold_sources", ScoreCalibrationRelation.threshold), + ("classification_sources", ScoreCalibrationRelation.classification), + ("method_sources", ScoreCalibrationRelation.method), + ], +) +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + ({"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}), + ], + indirect=["mock_publication_fetch"], +) +async def test_create_score_calibration_publication_identifier_associations_created_with_appropriate_relation( + setup_lib_db_with_score_set, + session, + mock_publication_fetch, + relation, + expected_relation, + create_function_to_call, + score_set_urn, +): + MockCalibrationCreate = create_model( + "MockCalibrationCreate", + score_set_urn=(str | None, score_set_urn), + threshold_sources=(list, []), + classification_sources=(list, []), + method_sources=(list, []), + ) + + test_user = session.execute(select(User)).scalars().first() + + mocked_calibration = MockCalibrationCreate() + setattr( + mocked_calibration, + relation, + [create_model("MockPublicationCreate", db_name=(str, "PubMed"), identifier=(str, TEST_PUBMED_IDENTIFIER))()], + ) + + calibration = await create_function_to_call(session, mocked_calibration, test_user) + 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 + assert len(calibration.publication_identifier_associations) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "create_function_to_call,score_set_urn", + [ + (create_score_calibration_in_score_set, VALID_SCORE_SET_URN), + (create_score_calibration, None), + ], +) +async def test_create_score_calibration_user_is_set_as_creator_and_modifier( + setup_lib_db_with_score_set, session, create_function_to_call, score_set_urn +): + MockCalibrationCreate = create_model( + "MockCalibrationCreate", + score_set_urn=(str | None, score_set_urn), + threshold_sources=(list, []), + classification_sources=(list, []), + method_sources=(list, []), + ) + + test_user = session.execute(select(User)).scalars().first() + + calibration = await create_function_to_call(session, MockCalibrationCreate(), test_user) + assert calibration.created_by == test_user + assert calibration.modified_by == test_user + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "create_function_to_call,score_set_urn", + [ + (create_score_calibration_in_score_set, VALID_SCORE_SET_URN), + (create_score_calibration, None), + ], +) +@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_create_score_calibration_fully_valid_calibration( + setup_lib_db_with_score_set, session, create_function_to_call, score_set_urn, mock_publication_fetch +): + calibration_create = ScoreCalibrationCreate(**TEST_BRNICH_SCORE_CALIBRATION, score_set_urn=score_set_urn) + + test_user = session.execute(select(User)).scalars().first() + + calibration = await create_function_to_call(session, calibration_create, test_user) + + for field in TEST_BRNICH_SCORE_CALIBRATION: + # Sources are tested elsewhere + # XXX: Ranges are a pain to compare between JSONB and dict input, so are assumed correct + if "sources" not in field and "functional_ranges" not in field: + assert getattr(calibration, field) == TEST_BRNICH_SCORE_CALIBRATION[field] + + +################################################################################ +# Tests for modify_score_calibration +################################################################################ + + +@pytest.mark.asyncio +async def test_modify_score_calibration_raises_value_error_when_score_set_urn_is_missing( + setup_lib_db_with_score_set, session, mock_user, mock_functional_calibration +): + MockCalibrationModify = create_model("MockCalibrationModify", score_set_urn=(str | None, None)) + with pytest.raises( + ValueError, + match="score_set_urn must be provided to modify a score calibration.", + ): + await modify_score_calibration(session, mock_functional_calibration, MockCalibrationModify(), mock_user) + + +@pytest.mark.asyncio +async def test_modify_score_calibration_raises_no_result_found_error_when_score_set_does_not_exist( + setup_lib_db, session, mock_user, mock_functional_calibration +): + MockCalibrationModify = create_model("MockCalibrationModify", score_set_urn=(str | None, "urn:invalid")) + with pytest.raises( + NoResultFound, + match="No row was found when one was required", + ): + await modify_score_calibration(session, mock_functional_calibration, MockCalibrationModify(), mock_user) + + +@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_modify_score_calibration_modifies_score_calibration_when_score_set_exists( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + + MockCalibrationModify = create_model( + "MockCalibrationModify", + score_set_urn=(str | None, setup_lib_db_with_score_set.urn), + description=(str | None, "Modified description"), + threshold_sources=(list, []), + classification_sources=(list, []), + method_sources=(list, []), + ) + + modified_calibration = await modify_score_calibration( + session, existing_calibration, MockCalibrationModify(), test_user + ) + assert modified_calibration is not None + assert modified_calibration.description == "Modified description" + assert modified_calibration.score_set == setup_lib_db_with_score_set + + +@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_modify_score_calibration_clears_existing_publication_identifier_associations( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + + MockCalibrationModify = create_model( + "MockCalibrationModify", + score_set_urn=(str | None, setup_lib_db_with_score_set.urn), + threshold_sources=(list, []), + classification_sources=(list, []), + method_sources=(list, []), + ) + + mocked_calibration = MockCalibrationModify() + + calibration = await modify_score_calibration(session, existing_calibration, mocked_calibration, test_user) + assert len(calibration.publication_identifier_associations) == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "relation,expected_relation", + [ + ("threshold_sources", ScoreCalibrationRelation.threshold), + ("classification_sources", ScoreCalibrationRelation.classification), + ("method_sources", ScoreCalibrationRelation.method), + ], +) +@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_modify_score_calibration_publication_identifier_associations_created_with_appropriate_relation( + setup_lib_db_with_score_set, + session, + mock_publication_fetch, + relation, + expected_relation, +): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + + MockCalibrationModify = create_model( + "MockCalibrationModify", + score_set_urn=(str | None, setup_lib_db_with_score_set.urn), + threshold_sources=(list, []), + classification_sources=(list, []), + method_sources=(list, []), + ) + + mocked_calibration = MockCalibrationModify() + setattr( + mocked_calibration, + relation, + [create_model("MockPublicationCreate", db_name=(str, "PubMed"), identifier=(str, TEST_PUBMED_IDENTIFIER))()], + ) + + calibration = await modify_score_calibration(session, existing_calibration, mocked_calibration, test_user) + 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 + assert len(calibration.publication_identifier_associations) == 1 + + +@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_modify_score_calibration_retains_existing_publication_relationships_when_not_modified( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + calibration_publication_relations = existing_calibration.publication_identifier_associations.copy() + + MockCalibrationModify = create_model( + "MockCalibrationModify", + score_set_urn=(str | None, setup_lib_db_with_score_set.urn), + threshold_sources=( + list, + [ + create_model( + "MockPublicationCreate", + db_name=(str, pub_dict["db_name"]), + identifier=(str, pub_dict["identifier"]), + )() + for pub_dict in TEST_BRNICH_SCORE_CALIBRATION["threshold_sources"] + ], + ), + classification_sources=( + list, + [ + create_model( + "MockPublicationCreate", + db_name=(str, pub_dict["db_name"]), + identifier=(str, pub_dict["identifier"]), + )() + for pub_dict in TEST_BRNICH_SCORE_CALIBRATION["classification_sources"] + ], + ), + method_sources=( + list, + [ + create_model( + "MockPublicationCreate", + db_name=(str, pub_dict["db_name"]), + identifier=(str, pub_dict["identifier"]), + )() + for pub_dict in TEST_BRNICH_SCORE_CALIBRATION["method_sources"] + ], + ), + ) + + modified_calibration = await modify_score_calibration( + session, existing_calibration, MockCalibrationModify(), test_user + ) + assert modified_calibration is not None + assert modified_calibration.publication_identifier_associations == calibration_publication_relations + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + {"dbName": "Crossref", "identifier": TEST_CROSSREF_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +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() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + + MockCalibrationModify = create_model( + "MockCalibrationModify", + score_set_urn=(str | None, setup_lib_db_with_score_set.urn), + threshold_sources=( + list, + [ + create_model( + "MockPublicationCreate", + db_name=(str, "Crossref"), + identifier=(str, TEST_CROSSREF_IDENTIFIER), + )() + ], + ), + classification_sources=(list, []), + method_sources=(list, []), + ) + + modified_calibration = await modify_score_calibration( + session, existing_calibration, MockCalibrationModify(), test_user + ) + assert modified_calibration is not None + assert modified_calibration.publication_identifier_associations[0].publication.db_name == "Crossref" + assert ( + modified_calibration.publication_identifier_associations[0].publication.identifier == TEST_CROSSREF_IDENTIFIER + ) + assert modified_calibration.publication_identifier_associations[0].relation == ScoreCalibrationRelation.threshold + assert len(modified_calibration.publication_identifier_associations) == 1 + + +@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_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() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + + MockCalibrationModify = create_model( + "MockCalibrationModify", + score_set_urn=(str | None, setup_lib_db_with_score_set.urn), + threshold_sources=(list, []), + classification_sources=(list, []), + method_sources=(list, []), + ) + + modify_user = session.execute(select(User).where(User.id != test_user.id)).scalars().first() + modified_calibration = await modify_score_calibration( + session, existing_calibration, MockCalibrationModify(), modify_user + ) + assert modified_calibration is not None + assert modified_calibration.modified_by == modify_user + assert modified_calibration.created_by == test_user + + +@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_modify_score_calibration_new_score_set(setup_lib_db_with_score_set, session, mock_publication_fetch): + existing_experiment = setup_lib_db_with_score_set.experiment + score_set_scaffold = TEST_SEQ_SCORESET.copy() + score_set_scaffold.pop("target_genes") + new_containing_score_set = ScoreSet( + **score_set_scaffold, + urn="urn:mavedb:00000000-B-0", + experiment_id=existing_experiment.id, + licence_id=TEST_LICENSE["id"], + ) + new_containing_score_set.created_by = setup_lib_db_with_score_set.created_by + new_containing_score_set.modified_by = setup_lib_db_with_score_set.modified_by + session.add(new_containing_score_set) + session.commit() + session.refresh(new_containing_score_set) + + test_user = session.execute(select(User)).scalars().first() + existing_calibration = await create_test_score_calibration_in_score_set( + session, new_containing_score_set.urn, test_user + ) + + MockCalibrationModify = create_model( + "MockCalibrationModify", + score_set_urn=(str | None, new_containing_score_set.urn), + threshold_sources=(list, []), + classification_sources=(list, []), + method_sources=(list, []), + ) + + modified_calibration = await modify_score_calibration( + session, existing_calibration, MockCalibrationModify(), test_user + ) + assert modified_calibration is not None + assert modified_calibration.score_set == new_containing_score_set + + +@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_modify_score_calibration_fully_valid_calibration( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + + modify_calibration = ScoreCalibrationModify( + **TEST_PATHOGENICITY_SCORE_CALIBRATION, score_set_urn=setup_lib_db_with_score_set.urn + ) + modified_calibration = await modify_score_calibration(session, existing_calibration, modify_calibration, test_user) + + for field in TEST_PATHOGENICITY_SCORE_CALIBRATION: + # Sources are tested elsewhere + # XXX: Ranges are a pain to compare between JSONB and dict input, so are assumed correct + if "sources" not in field and "functional_ranges" not in field: + assert getattr(modified_calibration, field) == TEST_PATHOGENICITY_SCORE_CALIBRATION[field] + + +################################################################################ +# Tests for publish_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_cannot_publish_already_published_calibration( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration.private = False + session.add(existing_calibration) + session.commit() + session.refresh(existing_calibration) + + with pytest.raises(ValueError, match="Calibration is already published."): + publish_score_calibration(session, existing_calibration, test_user) + + +@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_publish_score_calibration_marks_calibration_public( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + assert existing_calibration.private is True + + published_calibration = publish_score_calibration(session, existing_calibration, test_user) + assert published_calibration.private is False + + +@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_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() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + + publish_user = session.execute(select(User).where(User.id != test_user.id)).scalars().first() + published_calibration = publish_score_calibration(session, existing_calibration, publish_user) + assert published_calibration is not None + assert published_calibration.modified_by == publish_user + assert published_calibration.created_by == test_user + + +################################################################################ +# Tests for promote_score_calibration_to_primary +################################################################################ + + +@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_promote_already_primary_calibration(setup_lib_db_with_score_set, session, mock_publication_fetch): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration.primary = True + session.add(existing_calibration) + session.commit() + session.refresh(existing_calibration) + + with pytest.raises(ValueError, match="Calibration is already primary."): + promote_score_calibration_to_primary(session, existing_calibration, test_user, force=False) + + +@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_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() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration.research_use_only = True + session.add(existing_calibration) + session.commit() + session.refresh(existing_calibration) + + with pytest.raises(ValueError, match="Cannot promote a research use only calibration to primary."): + promote_score_calibration_to_primary(session, existing_calibration, test_user, force=False) + + +@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_promote_calibration_when_calibration_is_private( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration.private = True + session.add(existing_calibration) + session.commit() + session.refresh(existing_calibration) + + with pytest.raises(ValueError, match="Cannot promote a private calibration to primary."): + promote_score_calibration_to_primary(session, existing_calibration, test_user, force=False) + + +@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_promote_calibration_when_another_primary_exists( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + + existing_primary_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_primary_calibration.private = False + existing_primary_calibration.primary = True + existing_calibration.private = False + existing_calibration.primary = False + + session.add(existing_primary_calibration) + session.add(existing_calibration) + session.commit() + session.refresh(existing_primary_calibration) + session.refresh(existing_calibration) + + with pytest.raises(ValueError, match="Another primary calibration already exists for this score set."): + promote_score_calibration_to_primary(session, existing_calibration, test_user, force=False) + + +@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_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() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration.private = False + existing_calibration.primary = False + session.add(existing_calibration) + session.commit() + session.refresh(existing_calibration) + + promoted_calibration = promote_score_calibration_to_primary(session, existing_calibration, test_user, force=False) + assert promoted_calibration.primary is True + + +@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_promote_score_calibration_to_primary_demotes_existing_primary_when_forced( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + + existing_primary_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_primary_calibration.private = False + existing_primary_calibration.primary = True + existing_calibration.private = False + existing_calibration.primary = False + + session.add(existing_primary_calibration) + session.add(existing_calibration) + session.commit() + session.refresh(existing_primary_calibration) + session.refresh(existing_calibration) + + assert existing_calibration.primary is False + + promoted_calibration = promote_score_calibration_to_primary(session, existing_calibration, test_user, force=True) + session.commit() + session.refresh(existing_primary_calibration) + + assert promoted_calibration.primary is True + assert existing_primary_calibration.primary is False + + +@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_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() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration.private = False + existing_calibration.primary = False + session.add(existing_calibration) + session.commit() + session.refresh(existing_calibration) + + promote_user = session.execute(select(User).where(User.id != test_user.id)).scalars().first() + promoted_calibration = promote_score_calibration_to_primary( + session, existing_calibration, promote_user, force=False + ) + assert promoted_calibration is not None + assert promoted_calibration.modified_by == promote_user + assert promoted_calibration.created_by == test_user + + +@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_promote_score_calibration_to_primary_demoted_existing_primary_user_is_set_as_modifier( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + + existing_primary_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_primary_calibration.private = False + existing_primary_calibration.primary = True + existing_calibration.private = False + existing_calibration.primary = False + + session.add(existing_primary_calibration) + session.add(existing_calibration) + session.commit() + session.refresh(existing_primary_calibration) + session.refresh(existing_calibration) + + assert existing_calibration.primary is False + + promote_user = session.execute(select(User).where(User.id != test_user.id)).scalars().first() + promoted_calibration = promote_score_calibration_to_primary(session, existing_calibration, promote_user, force=True) + session.commit() + session.refresh(existing_primary_calibration) + + assert promoted_calibration.primary is True + assert existing_primary_calibration is not None + assert existing_primary_calibration.modified_by == promote_user + assert promoted_calibration.created_by == test_user + + +################################################################################ +# Test demote_score_calibration_from_primary +################################################################################ + + +@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_demote_non_primary_calibration(setup_lib_db_with_score_set, session, mock_publication_fetch): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration.primary = False + session.add(existing_calibration) + session.commit() + session.refresh(existing_calibration) + + with pytest.raises(ValueError, match="Calibration is not primary."): + demote_score_calibration_from_primary(session, existing_calibration, test_user) + + +@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_demote_score_calibration_from_primary_marks_calibration_non_primary( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration.primary = True + session.add(existing_calibration) + session.commit() + session.refresh(existing_calibration) + assert existing_calibration.primary is True + + demoted_calibration = demote_score_calibration_from_primary(session, existing_calibration, test_user) + assert demoted_calibration.primary is False + + +@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_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() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration.primary = True + session.add(existing_calibration) + session.commit() + session.refresh(existing_calibration) + + demote_user = session.execute(select(User).where(User.id != test_user.id)).scalars().first() + demoted_calibration = demote_score_calibration_from_primary(session, existing_calibration, demote_user) + assert demoted_calibration is not None + assert demoted_calibration.modified_by == demote_user + assert demoted_calibration.created_by == test_user + + +################################################################################ +# Test delete_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_cannot_delete_primary_calibration(setup_lib_db_with_score_set, session, mock_publication_fetch): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + existing_calibration.primary = True + session.add(existing_calibration) + session.commit() + session.refresh(existing_calibration) + + with pytest.raises(ValueError, match="Cannot delete a primary calibration."): + delete_score_calibration(session, existing_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_delete_score_calibration_deletes_calibration( + session, setup_lib_db_with_score_set, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + + existing_calibration = await create_test_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user + ) + calibration_id = existing_calibration.id + + delete_score_calibration(session, existing_calibration) + session.commit() + + with pytest.raises(NoResultFound, match="No row was found when one was required"): + session.execute(select(ScoreCalibration).where(ScoreCalibration.id == calibration_id)).scalars().one() diff --git a/tests/lib/test_score_set.py b/tests/lib/test_score_set.py index d7a45c76..a260599a 100644 --- a/tests/lib/test_score_set.py +++ b/tests/lib/test_score_set.py @@ -373,6 +373,7 @@ def test_create_variants_acc_score_set(setup_lib_db, session): def test_create_null_score_range(setup_lib_db, client, session): experiment = create_experiment(client) create_seq_score_set(client, experiment["urn"]) - score_set = session.scalar(select(ScoreSet).where(ScoreSet.score_ranges.is_(None))) + score_set = session.scalar(select(ScoreSet).where(~ScoreSet.score_calibrations.any())) + assert not score_set.score_calibrations assert score_set is not None diff --git a/tests/lib/test_seqrepo.py b/tests/lib/test_seqrepo.py index 9b3b9e8c..822d1a13 100644 --- a/tests/lib/test_seqrepo.py +++ b/tests/lib/test_seqrepo.py @@ -1,7 +1,7 @@ # ruff: noqa: E402 import pytest -pytest.importorskip("biocommons") +pytest.importorskip("biocommons.seqrepo") pytest.importorskip("bioutils") from mavedb.lib.seqrepo import get_sequence_ids, _generate_nsa_options, seqrepo_versions, sequence_generator diff --git a/tests/routers/conftest.py b/tests/routers/conftest.py index 7bdbe731..d54b18d8 100644 --- a/tests/routers/conftest.py +++ b/tests/routers/conftest.py @@ -59,150 +59,3 @@ def setup_router_db(session): def data_files(tmp_path): copytree(Path(__file__).absolute().parent / "data", tmp_path / "data") return tmp_path / "data" - - -@pytest.fixture -def mock_publication_fetch(request, requests_mock): - """ - Mocks the request that would be sent for the provided publication. - - To use this fixture for a test on which you would like to mock the creation of a publication identifier, - mark the test with: - - @pytest.mark.parametrize( - "mock_publication_fetch", - [ - { - "dbName": "", - "identifier": "" - }, - ... - ], - indirect=["mock_publication_fetch"], - ) - def test_needing_publication_identifier_mock(mock_publication_fetch, ...): - ... - - If your test requires use of the mocked publication identifier, this fixture returns it. Just assign the fixture - to a variable (or use it directly). - - def test_needing_publication_identifier_mock(mock_publication_fetch, ...): - ... - mocked_publication = mock_publication_fetch - experiment = create_experiment(client, {"primaryPublicationIdentifiers": [mocked_publication]}) - ... - """ - publication_to_mock = request.param - - if publication_to_mock["dbName"] == "PubMed": - # minimal xml to pass validation - requests_mock.post( - "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi", - text=f""" - - - - {publication_to_mock["identifier"]} -
- - test - - - 1999 - - - - - test - -
-
- - - test - - -
-
- """, - ) - - # Since 6 digit PubMed identifiers may also be valid bioRxiv identifiers, the code checks that this isn't also a valid bioxriv ID. We return nothing. - requests_mock.get( - f"https://api.biorxiv.org/details/medrxiv/10.1101/{publication_to_mock['identifier']}/na/json", - json={"collection": []}, - ) - - elif publication_to_mock["dbName"] == "bioRxiv": - requests_mock.get( - f"https://api.biorxiv.org/details/biorxiv/10.1101/{publication_to_mock['identifier']}/na/json", - json={ - "collection": [ - { - "title": "test1", - "doi": "test2", - "category": "test3", - "authors": "test4; test5", - "author_corresponding": "test6", - "author_corresponding_institution": "test7", - "date": "1999-12-31", - "version": "test8", - "type": "test9", - "license": "test10", - "jatsxml": "test11", - "abstract": "test12", - "published": "test13", - "server": "test14", - } - ] - }, - ) - elif publication_to_mock["dbName"] == "medRxiv": - requests_mock.get( - f"https://api.biorxiv.org/details/medrxiv/10.1101/{publication_to_mock['identifier']}/na/json", - json={ - "collection": [ - { - "title": "test1", - "doi": "test2", - "category": "test3", - "authors": "test4; test5", - "author_corresponding": "test6", - "author_corresponding_institution": "test7", - "date": "1999-12-31", - "version": "test8", - "type": "test9", - "license": "test10", - "jatsxml": "test11", - "abstract": "test12", - "published": "test13", - "server": "test14", - } - ] - }, - ) - elif publication_to_mock["dbName"] == "Crossref": - requests_mock.get( - f"https://api.crossref.org/works/{publication_to_mock['identifier']}", - json={ - "status": "ok", - "message-type": "work", - "message-version": "1.0.0", - "message": { - "DOI": "10.10/1.2.3", - "source": "Crossref", - "title": ["Crossref test pub title"], - "prefix": "10.10", - "author": [ - {"given": "author", "family": "one", "sequence": "first", "affiliation": []}, - {"given": "author", "family": "two", "sequence": "additional", "affiliation": []}, - ], - "container-title": ["American Heart Journal"], - "abstract": "Abstracttext test", - "URL": "http://dx.doi.org/10.10/1.2.3", - "published": {"date-parts": [[2024, 5]]}, - }, - }, - ) - - return publication_to_mock diff --git a/tests/routers/test_mapped_variants.py b/tests/routers/test_mapped_variants.py index 3b3fa888..fb18baa3 100644 --- a/tests/routers/test_mapped_variants.py +++ b/tests/routers/test_mapped_variants.py @@ -9,7 +9,6 @@ cdot = pytest.importorskip("cdot") fastapi = pytest.importorskip("fastapi") -from humps import camelize from sqlalchemy import select from sqlalchemy.orm.session import make_transient from urllib.parse import quote_plus @@ -21,25 +20,17 @@ from mavedb.models.variant import Variant from mavedb.view_models.mapped_variant import SavedMappedVariant -from tests.helpers.constants import ( - TEST_PUBMED_IDENTIFIER, - TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, - TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, -) +from tests.helpers.constants import TEST_BIORXIV_IDENTIFIER, TEST_PUBMED_IDENTIFIER, TEST_BRNICH_SCORE_CALIBRATION +from tests.helpers.util.common import deepcamelize from tests.helpers.util.experiment import create_experiment +from tests.helpers.util.score_calibration import create_publish_and_promote_score_calibration from tests.helpers.util.score_set import ( create_seq_score_set_with_mapped_variants, create_seq_score_set_with_variants, ) -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) -def test_show_mapped_variant(client, session, data_provider, data_files, setup_router_db, mock_publication_fetch): +def test_show_mapped_variant(client, session, data_provider, data_files, setup_router_db): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( client, @@ -47,10 +38,6 @@ def test_show_mapped_variant(client, session, data_provider, data_files, setup_r data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) response = client.get(f"/api/v1/mapped-variants/{quote_plus(score_set['urn'] + '#1')}") @@ -62,14 +49,7 @@ def test_show_mapped_variant(client, session, data_provider, data_files, setup_r SavedMappedVariant.model_validate_json(json.dumps(response_data)) -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) -def test_cannot_show_mapped_variant_when_multiple_exist( - client, session, data_provider, data_files, setup_router_db, mock_publication_fetch -): +def test_cannot_show_mapped_variant_when_multiple_exist(client, session, data_provider, data_files, setup_router_db): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( client, @@ -77,10 +57,6 @@ def test_cannot_show_mapped_variant_when_multiple_exist( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) item = session.scalar(select(MappedVariant).join(Variant).where(Variant.urn == f'{score_set["urn"]}#1')) @@ -99,14 +75,7 @@ def test_cannot_show_mapped_variant_when_multiple_exist( assert response_data["detail"] == f"Multiple variants with URN {score_set['urn']}#1 were found." -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) -def test_cannot_show_mapped_variant_when_none_exists( - client, session, data_provider, data_files, setup_router_db, mock_publication_fetch -): +def test_cannot_show_mapped_variant_when_none_exists(client, session, data_provider, data_files, setup_router_db): experiment = create_experiment(client) score_set = create_seq_score_set_with_variants( client, @@ -114,10 +83,6 @@ def test_cannot_show_mapped_variant_when_none_exists( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) response = client.get(f"/api/v1/mapped-variants/{quote_plus(score_set['urn'] + '#1')}") @@ -127,14 +92,7 @@ def test_cannot_show_mapped_variant_when_none_exists( assert response_data["detail"] == f"Mapped variant with URN {score_set['urn']}#1 not found" -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) -def test_show_mapped_variant_study_result( - client, session, data_provider, data_files, setup_router_db, mock_publication_fetch -): +def test_show_mapped_variant_study_result(client, session, data_provider, data_files, setup_router_db): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( client, @@ -142,10 +100,6 @@ def test_show_mapped_variant_study_result( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) response = client.get(f"/api/v1/mapped-variants/{quote_plus(score_set['urn'] + '#1')}/va/study-result") @@ -157,13 +111,8 @@ def test_show_mapped_variant_study_result( ExperimentalVariantFunctionalImpactStudyResult.model_validate_json(json.dumps(response_data)) -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) def test_cannot_show_mapped_variant_study_result_when_multiple_exist( - client, session, data_provider, data_files, setup_router_db, mock_publication_fetch + client, session, data_provider, data_files, setup_router_db ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -172,10 +121,6 @@ def test_cannot_show_mapped_variant_study_result_when_multiple_exist( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) item = session.scalar(select(MappedVariant).join(Variant).where(Variant.urn == f'{score_set["urn"]}#1')) @@ -194,13 +139,8 @@ def test_cannot_show_mapped_variant_study_result_when_multiple_exist( assert response_data["detail"] == f"Multiple variants with URN {score_set['urn']}#1 were found." -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) def test_cannot_show_mapped_variant_study_result_when_none_exists( - client, session, data_provider, data_files, setup_router_db, mock_publication_fetch + client, session, data_provider, data_files, setup_router_db ): experiment = create_experiment(client) score_set = create_seq_score_set_with_variants( @@ -209,10 +149,6 @@ def test_cannot_show_mapped_variant_study_result_when_none_exists( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) response = client.get(f"/api/v1/mapped-variants/{quote_plus(score_set['urn'] + '#1')}/va/study-result") @@ -222,13 +158,8 @@ def test_cannot_show_mapped_variant_study_result_when_none_exists( assert response_data["detail"] == f"Mapped variant with URN {score_set['urn']}#1 not found" -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) def test_cannot_show_mapped_variant_study_result_when_no_mapping_data_exists( - client, session, data_provider, data_files, setup_router_db, mock_publication_fetch + client, session, data_provider, data_files, setup_router_db ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -237,10 +168,6 @@ def test_cannot_show_mapped_variant_study_result_when_no_mapping_data_exists( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) item = session.scalar(select(MappedVariant).join(Variant).where(Variant.urn == f'{score_set["urn"]}#1')) @@ -262,7 +189,12 @@ def test_cannot_show_mapped_variant_study_result_when_no_mapping_data_exists( @pytest.mark.parametrize( "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], + [ + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], indirect=["mock_publication_fetch"], ) def test_show_mapped_variant_functional_impact_statement( @@ -275,11 +207,8 @@ def test_show_mapped_variant_functional_impact_statement( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) + create_publish_and_promote_score_calibration(client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION)) response = client.get(f"/api/v1/mapped-variants/{quote_plus(score_set['urn'] + '#1')}/va/functional-impact") response_data = response.json() @@ -290,13 +219,8 @@ def test_show_mapped_variant_functional_impact_statement( Statement.model_validate_json(json.dumps(response_data)) -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) def test_cannot_show_mapped_variant_functional_impact_statement_when_multiple_exist( - client, session, data_provider, data_files, setup_router_db, mock_publication_fetch + client, session, data_provider, data_files, setup_router_db ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -305,10 +229,6 @@ def test_cannot_show_mapped_variant_functional_impact_statement_when_multiple_ex data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) item = session.scalar(select(MappedVariant).join(Variant).where(Variant.urn == f'{score_set["urn"]}#1')) @@ -327,13 +247,8 @@ def test_cannot_show_mapped_variant_functional_impact_statement_when_multiple_ex assert response_data["detail"] == f"Multiple variants with URN {score_set['urn']}#1 were found." -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) def test_cannot_show_mapped_variant_functional_impact_statement_when_none_exists( - client, session, data_provider, data_files, setup_router_db, mock_publication_fetch + client, session, data_provider, data_files, setup_router_db ): experiment = create_experiment(client) score_set = create_seq_score_set_with_variants( @@ -342,10 +257,6 @@ def test_cannot_show_mapped_variant_functional_impact_statement_when_none_exists data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) response = client.get(f"/api/v1/mapped-variants/{quote_plus(score_set['urn'] + '#1')}/va/functional-impact") @@ -357,7 +268,12 @@ def test_cannot_show_mapped_variant_functional_impact_statement_when_none_exists @pytest.mark.parametrize( "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], + [ + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], indirect=["mock_publication_fetch"], ) def test_cannot_show_mapped_variant_functional_impact_statement_when_no_mapping_data_exists( @@ -370,11 +286,8 @@ def test_cannot_show_mapped_variant_functional_impact_statement_when_no_mapping_ data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) + create_publish_and_promote_score_calibration(client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION)) item = session.scalar(select(MappedVariant).join(Variant).where(Variant.urn == f'{score_set["urn"]}#1')) assert item is not None @@ -393,13 +306,8 @@ def test_cannot_show_mapped_variant_functional_impact_statement_when_no_mapping_ ) -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) -def test_cannot_show_mapped_variant_functional_impact_statement_when_no_score_ranges( - client, session, data_provider, data_files, setup_router_db, mock_publication_fetch +def test_cannot_show_mapped_variant_functional_impact_statement_when_insufficient_functional_evidence( + client, session, data_provider, data_files, setup_router_db ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -408,12 +316,10 @@ def test_cannot_show_mapped_variant_functional_impact_statement_when_no_score_ra data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, - }, ) + # insufficient evidence = no (primary) calibrations + response = client.get(f"/api/v1/mapped-variants/{quote_plus(score_set['urn'] + '#1')}/va/functional-impact") response_data = response.json() @@ -426,7 +332,12 @@ def test_cannot_show_mapped_variant_functional_impact_statement_when_no_score_ra @pytest.mark.parametrize( "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], + [ + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], indirect=["mock_publication_fetch"], ) def test_show_mapped_variant_clinical_evidence_line( @@ -439,11 +350,8 @@ def test_show_mapped_variant_clinical_evidence_line( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) + create_publish_and_promote_score_calibration(client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION)) response = client.get(f"/api/v1/mapped-variants/{quote_plus(score_set['urn'] + '#2')}/va/clinical-evidence") response_data = response.json() @@ -454,13 +362,8 @@ def test_show_mapped_variant_clinical_evidence_line( VariantPathogenicityEvidenceLine.model_validate_json(json.dumps(response_data)) -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) def test_cannot_show_mapped_variant_clinical_evidence_line_when_multiple_exist( - client, session, data_provider, data_files, setup_router_db, mock_publication_fetch + client, session, data_provider, data_files, setup_router_db ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -469,10 +372,6 @@ def test_cannot_show_mapped_variant_clinical_evidence_line_when_multiple_exist( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) item = session.scalar(select(MappedVariant).join(Variant).where(Variant.urn == f'{score_set["urn"]}#1')) @@ -491,13 +390,8 @@ def test_cannot_show_mapped_variant_clinical_evidence_line_when_multiple_exist( assert response_data["detail"] == f"Multiple variants with URN {score_set['urn']}#1 were found." -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) def test_cannot_show_mapped_variant_clinical_evidence_line_when_none_exists( - client, session, data_provider, data_files, setup_router_db, mock_publication_fetch + client, session, data_provider, data_files, setup_router_db ): experiment = create_experiment(client) score_set = create_seq_score_set_with_variants( @@ -506,10 +400,6 @@ def test_cannot_show_mapped_variant_clinical_evidence_line_when_none_exists( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) response = client.get(f"/api/v1/mapped-variants/{quote_plus(score_set['urn'] + '#1')}/va/clinical-evidence") @@ -521,7 +411,12 @@ def test_cannot_show_mapped_variant_clinical_evidence_line_when_none_exists( @pytest.mark.parametrize( "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], + [ + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], indirect=["mock_publication_fetch"], ) def test_cannot_show_mapped_variant_clinical_evidence_line_when_no_mapping_data_exists( @@ -534,11 +429,8 @@ def test_cannot_show_mapped_variant_clinical_evidence_line_when_no_mapping_data_ data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) + create_publish_and_promote_score_calibration(client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION)) item = session.scalar(select(MappedVariant).join(Variant).where(Variant.urn == f'{score_set["urn"]}#1')) assert item is not None @@ -557,13 +449,8 @@ def test_cannot_show_mapped_variant_clinical_evidence_line_when_no_mapping_data_ ) -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) -def test_cannot_show_mapped_variant_clinical_evidence_line_when_no_score_calibrations_exist( - client, session, data_provider, data_files, setup_router_db, mock_publication_fetch +def test_cannot_show_mapped_variant_clinical_evidence_line_when_insufficient_pathogenicity_evidence( + client, session, data_provider, data_files, setup_router_db ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -572,10 +459,6 @@ def test_cannot_show_mapped_variant_clinical_evidence_line_when_no_score_calibra data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED), - }, ) response = client.get(f"/api/v1/mapped-variants/{quote_plus(score_set['urn'] + '#1')}/va/clinical-evidence") diff --git a/tests/routers/test_permissions.py b/tests/routers/test_permissions.py index e716b46a..74405a47 100644 --- a/tests/routers/test_permissions.py +++ b/tests/routers/test_permissions.py @@ -1,22 +1,26 @@ # ruff: noqa: E402 -from unittest.mock import patch import pytest arq = pytest.importorskip("arq") cdot = pytest.importorskip("cdot") fastapi = pytest.importorskip("fastapi") +from unittest.mock import patch + from mavedb.lib.permissions import Action from mavedb.models.experiment import Experiment as ExperimentDbModel from mavedb.models.experiment_set import ExperimentSet as ExperimentSetDbModel +from mavedb.models.score_calibration import ScoreCalibration as ScoreCalibrationDbModel from mavedb.models.score_set import ScoreSet as ScoreSetDbModel - -from tests.helpers.constants import TEST_USER -from tests.helpers.util.experiment import create_experiment +from tests.helpers.constants import EXTRA_USER, TEST_MINIMAL_CALIBRATION, TEST_USER +from tests.helpers.dependency_overrider import DependencyOverrider +from tests.helpers.util.common import deepcamelize from tests.helpers.util.contributor import add_contributor -from tests.helpers.util.user import change_ownership +from tests.helpers.util.experiment import create_experiment +from tests.helpers.util.score_calibration import create_test_score_calibration_in_score_set_via_client from tests.helpers.util.score_set import create_seq_score_set, publish_score_set +from tests.helpers.util.user import change_ownership from tests.helpers.util.variant import mock_worker_variant_insertion @@ -366,6 +370,112 @@ def test_cannot_get_permission_with_non_existing_score_set(client, setup_router_ assert response_data["detail"] == "score-set with URN 'invalidUrn' not found" +# score calibrations +# non-exhaustive, see TODO#543 + + +def test_get_true_permission_from_own_score_calibration_update_check(client, setup_router_db): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + score_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_MINIMAL_CALIBRATION) + ) + response = client.get(f"/api/v1/permissions/user-is-permitted/score-calibration/{score_calibration['urn']}/update") + + assert response.status_code == 200 + assert response.json() + + +def test_get_true_permission_from_own_score_calibration_delete_check(client, setup_router_db): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + score_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_MINIMAL_CALIBRATION) + ) + response = client.get(f"/api/v1/permissions/user-is-permitted/score-calibration/{score_calibration['urn']}/delete") + + assert response.status_code == 200 + assert response.json() + + +def test_contributor_gets_true_permission_from_others_investigator_provided_score_calibration_update_check( + session, client, setup_router_db, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + score_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_MINIMAL_CALIBRATION) + ) + 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/permissions/user-is-permitted/score-calibration/{score_calibration['urn']}/update" + ) + + assert response.status_code == 200 + assert response.json() + + +def test_contributor_gets_true_permission_from_others_investigator_provided_score_calibration_delete_check( + session, client, setup_router_db, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + score_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_MINIMAL_CALIBRATION) + ) + 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/permissions/user-is-permitted/score-calibration/{score_calibration['urn']}/delete" + ) + + assert response.status_code == 200 + assert response.json() + + +def test_get_false_permission_from_others_score_calibration_update_check(session, client, setup_router_db): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + score_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_MINIMAL_CALIBRATION) + ) + change_ownership(session, score_calibration["urn"], ScoreCalibrationDbModel) + + response = client.get(f"/api/v1/permissions/user-is-permitted/score-calibration/{score_calibration['urn']}/update") + + assert response.status_code == 200 + assert not response.json() + + +def test_get_false_permission_from_others_score_calibration_delete_check(session, client, setup_router_db): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + score_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_MINIMAL_CALIBRATION) + ) + change_ownership(session, score_calibration["urn"], ScoreCalibrationDbModel) + + response = client.get(f"/api/v1/permissions/user-is-permitted/score-calibration/{score_calibration['urn']}/delete") + + assert response.status_code == 200 + assert not response.json() + + # Common invalid test def test_cannot_get_permission_with_non_existing_item(client, setup_router_db): response = client.get("/api/v1/permissions/user-is-permitted/invalidModel/invalidUrn/update") @@ -374,5 +484,5 @@ def test_cannot_get_permission_with_non_existing_item(client, setup_router_db): response_data = response.json() assert ( response_data["detail"][0]["msg"] - == "Input should be 'collection', 'experiment', 'experiment-set' or 'score-set'" + == "Input should be 'collection', 'experiment', 'experiment-set', 'score-set' or 'score-calibration'" ) diff --git a/tests/routers/test_score_calibrations.py b/tests/routers/test_score_calibrations.py new file mode 100644 index 00000000..307394ec --- /dev/null +++ b/tests/routers/test_score_calibrations.py @@ -0,0 +1,3373 @@ +# ruff: noqa: E402 + +import pytest + +arq = pytest.importorskip("arq") +cdot = pytest.importorskip("cdot") +fastapi = pytest.importorskip("fastapi") + +from unittest.mock import patch + +from arq import ArqRedis +from sqlalchemy import select + +from mavedb.models.score_calibration import ScoreCalibration as CalibrationDbModel +from mavedb.models.score_set import ScoreSet as ScoreSetDbModel +from tests.helpers.dependency_overrider import DependencyOverrider +from tests.helpers.util.common import deepcamelize +from tests.helpers.util.contributor import add_contributor +from tests.helpers.util.experiment import create_experiment +from tests.helpers.util.score_calibration import ( + create_publish_and_promote_score_calibration, + create_test_score_calibration_in_score_set_via_client, + publish_test_score_calibration_via_client, +) +from tests.helpers.util.score_set import create_seq_score_set_with_mapped_variants, publish_score_set + +from tests.helpers.constants import ( + EXTRA_USER, + TEST_BIORXIV_IDENTIFIER, + TEST_BRNICH_SCORE_CALIBRATION, + TEST_PATHOGENICITY_SCORE_CALIBRATION, + TEST_PUBMED_IDENTIFIER, + VALID_CALIBRATION_URN, +) + +########################################################### +# GET /score-calibrations/{calibration_urn} +########################################################### + + +def test_cannot_get_score_calibration_when_not_exists(client, setup_router_db): + response = client.get(f"/api/v1/score-calibrations/{VALID_CALIBRATION_URN}") + + assert response.status_code == 404 + error = response.json() + assert "The requested score calibration does not exist" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_cannot_get_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( + 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) + ) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score calibration with URN '{calibration['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_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) + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score calibration with URN '{calibration['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_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( + 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) + ) + + response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + 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_contributing_user_can_get_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) + 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) + ) + + 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/{calibration['urn']}") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + 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_contributing_user_cannot_get_score_calibration_when_private_and_not_investigator_provided( + 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): + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + 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/{calibration['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score calibration with URN '{calibration['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_admin_user_can_get_score_calibration_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) + ) + + with DependencyOverrider(admin_app_overrides): + response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + 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_anonymous_user_can_get_score_calibration_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) + ) + calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["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_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) + ) + calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["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_creating_user_can_get_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( + 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) + ) + calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["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_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) + ) + calibration = 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/{calibration['urn']}") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["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_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( + 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) + ) + calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + with DependencyOverrider(admin_app_overrides): + response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == 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", + ) + + 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", + ) + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + 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_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) + ) + + 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) + ) + + 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) + ) + + 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) + ) + + 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) + ) + + investigator_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + 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"] == 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) + ) + + 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) + ) + + # 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) + ) + + 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) + ) + + # 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) + ) + + 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) + ) + + # 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) + ) + + 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) + ) + + # 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) + ) + + 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) + ) + 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) + ) + + 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"] == 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_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) + ) + 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) + ) + + 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"] == 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_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) + ) + 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) + ) + + 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"] == 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) + ) + + 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) + ) + + 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)) + calibration2 = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + 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)}, + ) + + 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), + }, + ) + + 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_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), + }, + ) + + 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_cannot_create_score_calibration_in_public_score_set_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 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), + }, + ) + + assert response.status_code == 403 + error = response.json() + assert f"insufficient permissions for URN '{score_set['urn']}'" 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_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), + }, + ) + + assert response.status_code == 401 + error = response.json() + assert "Could not validate credentials" 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_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", + ) + + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION), + }, + ) + + 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( + "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_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( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + 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), + }, + ) + + 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( + "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_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( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + with DependencyOverrider(admin_app_overrides): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True + + +########################################################### +# PUT /score-calibrations/{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_cannot_update_score_calibration_when_score_set_not_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_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": "urn:ngs:score-set:nonexistent", + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION), + }, + ) + + 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_update_score_calibration_when_calibration_not_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", + ) + + response = client.put( + "/api/v1/score-calibrations/urn:ngs:score-calibration:nonexistent", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 404 + error = response.json() + assert "The requested score calibration does not exist" 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_update_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", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + with DependencyOverrider(anonymous_app_overrides): + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 401 + error = response.json() + assert "Could not validate credentials" 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_update_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", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION), + }, + ) + + 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_cannot_update_score_calibration_in_published_score_set_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", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + 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.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 403 + error = response.json() + assert f"insufficient permissions for URN '{score_set['urn']}'" 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_update_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_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["scoreSetUrn"] == score_set["urn"] + 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_update_published_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_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + publish_test_score_calibration_via_client(client, calibration["urn"]) + + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 403 + error = response.json() + assert f"insufficient permissions for URN '{calibration['urn']}'" 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_update_investigator_provided_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( + 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) + ) + + 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.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["scoreSetUrn"] == score_set["urn"] + 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_update_non_investigator_score_calibration_as_score_set_contributor( + 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): + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + 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.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 404 + calibration_response = response.json() + assert f"score calibration with URN '{calibration['urn']}' not found" in calibration_response["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_update_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( + 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) + ) + + with DependencyOverrider(admin_app_overrides): + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["scoreSetUrn"] == score_set["urn"] + 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_can_update_published_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( + 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) + ) + + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with DependencyOverrider(admin_app_overrides): + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["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_may_not_move_calibration_to_another_score_set( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set1 = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + score_set2 = 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_set1["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + with DependencyOverrider(anonymous_app_overrides): + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set2["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 401 + error = response.json() + assert "Could not validate credentials" 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_user_may_not_move_investigator_calibration_when_lacking_permissions_on_destination_score_set( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set1 = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + score_set2 = 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_set1["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + # Give user permissions on the first score set only + add_contributor( + session, + score_set1["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set2["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set2['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_user_may_move_investigator_calibration_when_has_permissions_on_destination_score_set( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set1 = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + score_set2 = 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_set1["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + # Give user permissions on both score sets + add_contributor( + session, + score_set1["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) + add_contributor( + session, + score_set2["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set1 = publish_score_set(client, score_set1["urn"]) + score_set2 = publish_score_set(client, score_set2["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set2["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["scoreSetUrn"] == score_set2["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_admin_user_may_move_calibration_to_another_score_set( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides +): + experiment = create_experiment(client) + score_set1 = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + score_set2 = 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_set1["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + with DependencyOverrider(admin_app_overrides): + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + json={ + "scoreSetUrn": score_set2["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION), + }, + ) + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["scoreSetUrn"] == score_set2["urn"] + + +########################################################### +# DELETE /score-calibrations/{calibration_urn} +########################################################### + + +def test_cannot_delete_score_calibration_when_not_exists(client, setup_router_db, session, data_provider, data_files): + response = client.delete("/api/v1/score-calibrations/urn:ngs:score-calibration:nonexistent") + + assert response.status_code == 404 + error = response.json() + assert "The requested score calibration does not exist" 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_delete_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", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + with DependencyOverrider(anonymous_app_overrides): + response = client.delete(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 401 + error = response.json() + assert "Could not validate credentials" 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_delete_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", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.delete(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score calibration with URN '{calibration['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_delete_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_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + response = client.delete(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 204 + + # verify it's deleted + get_response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + assert get_response.status_code == 404 + + +@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_delete_published_score_calibration_as_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_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + + response = client.delete(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 403 + error = response.json() + assert f"insufficient permissions for URN '{calibration['urn']}'" 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_delete_investigator_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( + 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) + ) + + 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.delete(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 204 + + # verify it's deleted + get_response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + assert get_response.status_code == 404 + + +@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_delete_non_investigator_calibration_as_score_set_contributor( + 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): + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + 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.delete(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 404 + + +@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_delete_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( + 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) + ) + + with DependencyOverrider(admin_app_overrides): + response = client.delete(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 204 + + # verify it's deleted + get_response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + assert get_response.status_code == 404 + + +@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_delete_published_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( + 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) + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with DependencyOverrider(admin_app_overrides): + response = client.delete(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 204 + + # verify it's deleted + get_response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + assert get_response.status_code == 404 + + +@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_delete_primary_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_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + response = client.delete(f"/api/v1/score-calibrations/{calibration['urn']}") + + assert response.status_code == 403 + error = response.json() + assert f"insufficient permissions for URN '{calibration['urn']}'" in error["detail"] + + +########################################################### +# POST /score-calibrations/{calibration_urn}/promote-to-primary +########################################################### + + +def test_cannot_promote_score_calibration_when_not_exists(client, setup_router_db, session, data_provider, data_files): + response = client.post( + "/api/v1/score-calibrations/urn:ngs:score-calibration:nonexistent/promote-to-primary", + json={"calibrationUrn": "urn:ngs:score-calibration:nonexistent"}, + ) + + assert response.status_code == 404 + error = response.json() + assert "The requested score calibration does not exist" 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_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", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.post(f"/api/v1/score-calibrations/{calibration['urn']}/promote-to-primary") + + assert response.status_code == 401 + error = response.json() + assert "Could not validate credentials" 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_score_calibration_when_score_calibration_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", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + f"/api/v1/score-calibrations/{calibration['urn']}/promote-to-primary", + ) + + assert response.status_code == 403 + error = response.json() + assert f"insufficient permissions for URN '{calibration['urn']}'" 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_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_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + response = client.post(f"/api/v1/score-calibrations/{calibration['urn']}/promote-to-primary") + + assert response.status_code == 200 + promotion_response = response.json() + assert promotion_response["urn"] == calibration["urn"] + assert promotion_response["scoreSetUrn"] == score_set["urn"] + assert promotion_response["primary"] 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_can_promote_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( + 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) + ) + 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.post(f"/api/v1/score-calibrations/{calibration['urn']}/promote-to-primary") + + assert response.status_code == 200 + promotion_response = response.json() + assert promotion_response["urn"] == calibration["urn"] + assert promotion_response["scoreSetUrn"] == score_set["urn"] + assert promotion_response["primary"] 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_can_promote_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( + 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) + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with DependencyOverrider(admin_app_overrides): + response = client.post(f"/api/v1/score-calibrations/{calibration['urn']}/promote-to-primary") + + assert response.status_code == 200 + promotion_response = response.json() + assert promotion_response["urn"] == calibration["urn"] + assert promotion_response["scoreSetUrn"] == score_set["urn"] + assert promotion_response["primary"] 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_can_promote_existing_primary_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", + ) + primary_calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + response = client.post(f"/api/v1/score-calibrations/{primary_calibration['urn']}/promote-to-primary") + + assert response.status_code == 200 + promotion_response = response.json() + assert promotion_response["urn"] == primary_calibration["urn"] + assert promotion_response["scoreSetUrn"] == score_set["urn"] + assert promotion_response["primary"] 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_promote_research_use_only_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, "researchUseOnly": True}), + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + + 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"] + + +@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, "private": True}), + ) + + 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)) + 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) + ) + 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( + "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_with_demote_existing_flag_if_user_does_not_have_change_rank_permissions_on_existing_primary( + 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", + ) + with DependencyOverrider(admin_app_overrides): + primary_calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + 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 == 403 + promotion_response = response.json() + assert "insufficient permissions for URN" in promotion_response["detail"] + + # verify the previous primary is still 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 True + + +########################################################### +# POST /score-calibrations/{calibration_urn}/demote-from-primary +########################################################### + + +def test_cannot_demote_score_calibration_when_not_exists(client, setup_router_db): + response = client.post( + "/api/v1/score-calibrations/urn:ngs:score-calibration:nonexistent/demote-from-primary", + ) + + assert response.status_code == 404 + error = response.json() + assert "The requested score calibration does not exist" 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_demote_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", + ) + calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + with DependencyOverrider(anonymous_app_overrides): + response = client.post( + f"/api/v1/score-calibrations/{calibration['urn']}/demote-from-primary", + ) + + assert response.status_code == 401 + error = response.json() + assert "Could not validate credentials" 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_demote_score_calibration_when_score_calibration_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", + ) + calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + f"/api/v1/score-calibrations/{calibration['urn']}/demote-from-primary", + ) + + assert response.status_code == 403 + error = response.json() + assert f"insufficient permissions for URN '{calibration['urn']}'" 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_demote_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( + 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) + ) + + 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( + f"/api/v1/score-calibrations/{calibration['urn']}/demote-from-primary", + ) + + assert response.status_code == 200 + demotion_response = response.json() + assert demotion_response["urn"] == calibration["urn"] + assert demotion_response["scoreSetUrn"] == score_set["urn"] + 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_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) + ) + + response = client.post( + f"/api/v1/score-calibrations/{calibration['urn']}/demote-from-primary", + ) + + assert response.status_code == 200 + demotion_response = response.json() + assert demotion_response["urn"] == calibration["urn"] + assert demotion_response["scoreSetUrn"] == score_set["urn"] + 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_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( + 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) + ) + + with DependencyOverrider(admin_app_overrides): + response = client.post( + f"/api/v1/score-calibrations/{calibration['urn']}/demote-from-primary", + ) + + assert response.status_code == 200 + demotion_response = response.json() + assert demotion_response["urn"] == calibration["urn"] + assert demotion_response["scoreSetUrn"] == score_set["urn"] + 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_non_primary_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", + ) + create_publish_and_promote_score_calibration(client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION)) + secondary_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) + ) + + response = client.post( + f"/api/v1/score-calibrations/{secondary_calibration['urn']}/demote-from-primary", + ) + + assert response.status_code == 200 + demotion_response = response.json() + assert demotion_response["urn"] == secondary_calibration["urn"] + assert demotion_response["scoreSetUrn"] == score_set["urn"] + assert demotion_response["primary"] is False + + +########################################################### +# POST /score-calibrations/{calibration_urn}/publish +########################################################### + + +@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_publish_score_calibration_when_not_exists( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + response = client.post( + "/api/v1/score-calibrations/urn:ngs:score-calibration:nonexistent/publish", + ) + + assert response.status_code == 404 + error = response.json() + assert "The requested score calibration does not exist" 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_publish_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", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + with DependencyOverrider(anonymous_app_overrides): + response = client.post( + f"/api/v1/score-calibrations/{calibration['urn']}/publish", + ) + + assert response.status_code == 401 + error = response.json() + assert "Could not validate credentials" 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_publish_score_calibration_when_score_calibration_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", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + f"/api/v1/score-calibrations/{calibration['urn']}/publish", + ) + + assert response.status_code == 404 + error = response.json() + assert f"score calibration with URN '{calibration['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_publish_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_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + response = client.post( + f"/api/v1/score-calibrations/{calibration['urn']}/publish", + ) + + assert response.status_code == 200 + publish_response = response.json() + assert publish_response["urn"] == calibration["urn"] + assert publish_response["scoreSetUrn"] == score_set["urn"] + assert publish_response["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_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( + 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) + ) + + with DependencyOverrider(admin_app_overrides): + response = client.post( + f"/api/v1/score-calibrations/{calibration['urn']}/publish", + ) + + assert response.status_code == 200 + publish_response = response.json() + assert publish_response["urn"] == calibration["urn"] + assert publish_response["scoreSetUrn"] == score_set["urn"] + assert publish_response["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_already_published_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) + ) + + # publish it first + publish_response_1 = client.post( + f"/api/v1/score-calibrations/{calibration['urn']}/publish", + ) + assert publish_response_1.status_code == 200 + published_calibration_1 = publish_response_1.json() + assert published_calibration_1["private"] is False + + # publish it again + publish_response_2 = client.post( + f"/api/v1/score-calibrations/{calibration['urn']}/publish", + ) + assert publish_response_2.status_code == 200 + published_calibration_2 = publish_response_2.json() + assert published_calibration_2["private"] is False diff --git a/tests/routers/test_score_set.py b/tests/routers/test_score_set.py index d1f960e2..97630bfd 100644 --- a/tests/routers/test_score_set.py +++ b/tests/routers/test_score_set.py @@ -34,6 +34,8 @@ SAVED_MINIMAL_DATASET_COLUMNS, SAVED_PUBMED_PUBLICATION, SAVED_SHORT_EXTRA_LICENSE, + TEST_BIORXIV_IDENTIFIER, + TEST_BRNICH_SCORE_CALIBRATION, TEST_CROSSREF_IDENTIFIER, TEST_GNOMAD_DATA_VERSION, TEST_INACTIVE_LICENSE, @@ -44,25 +46,23 @@ TEST_MINIMAL_SEQ_SCORESET, TEST_MINIMAL_SEQ_SCORESET_RESPONSE, TEST_ORCID_ID, + TEST_PATHOGENICITY_SCORE_CALIBRATION, TEST_PUBMED_IDENTIFIER, + TEST_SAVED_BRNICH_SCORE_CALIBRATION, TEST_SAVED_CLINVAR_CONTROL, TEST_SAVED_GENERIC_CLINICAL_CONTROL, TEST_SAVED_GNOMAD_VARIANT, - TEST_SAVED_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, - TEST_SAVED_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - TEST_SAVED_SCORE_SET_RANGES_ONLY_SCOTT, - TEST_SAVED_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, - TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, - TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - TEST_SCORE_SET_RANGES_ONLY_SCOTT, - TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, TEST_USER, ) from tests.helpers.dependency_overrider import DependencyOverrider -from tests.helpers.util.common import update_expected_response_for_created_resources +from tests.helpers.util.common import deepcamelize, update_expected_response_for_created_resources from tests.helpers.util.contributor import add_contributor from tests.helpers.util.experiment import create_experiment from tests.helpers.util.license import change_to_inactive_license +from tests.helpers.util.score_calibration import ( + create_publish_and_promote_score_calibration, + create_test_score_calibration_in_score_set_via_client, +) from tests.helpers.util.score_set import ( create_seq_score_set, create_seq_score_set_with_mapped_variants, @@ -96,7 +96,19 @@ def test_TEST_MINIMAL_ACC_SCORESET_is_valid(): ######################################################################################################################## -def test_create_minimal_score_set(client, setup_router_db): +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + ( + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ) + ], + indirect=["mock_publication_fetch"], +) +def test_create_minimal_score_set(client, mock_publication_fetch, setup_router_db): experiment = create_experiment(client) score_set_post_payload = deepcopy(TEST_MINIMAL_SEQ_SCORESET) score_set_post_payload["experimentUrn"] = experiment["urn"] @@ -121,7 +133,19 @@ def test_create_minimal_score_set(client, setup_router_db): assert response.status_code == 200 -def test_create_score_set_with_contributor(client, setup_router_db): +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + ( + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ) + ], + indirect=["mock_publication_fetch"], +) +def test_create_score_set_with_contributor(client, mock_publication_fetch, setup_router_db): experiment = create_experiment(client) score_set = deepcopy(TEST_MINIMAL_SEQ_SCORESET) score_set["experimentUrn"] = experiment["urn"] @@ -161,29 +185,22 @@ def test_create_score_set_with_contributor(client, setup_router_db): @pytest.mark.parametrize( - "score_ranges,saved_score_ranges", + "mock_publication_fetch", [ - (TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, TEST_SAVED_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED), - (TEST_SCORE_SET_RANGES_ONLY_SCOTT, TEST_SAVED_SCORE_SET_RANGES_ONLY_SCOTT), - (TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, TEST_SAVED_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION), - (TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, TEST_SAVED_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] ], -) -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], indirect=["mock_publication_fetch"], ) -def test_create_score_set_with_score_range( - client, mock_publication_fetch, setup_router_db, score_ranges, saved_score_ranges -): +def test_create_score_set_with_score_calibration(client, mock_publication_fetch, setup_router_db): experiment = create_experiment(client) score_set = deepcopy(TEST_MINIMAL_SEQ_SCORESET) score_set["experimentUrn"] = experiment["urn"] score_set.update( { - "score_ranges": score_ranges, - "secondary_publication_identifiers": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], + "scoreCalibrations": [deepcamelize(TEST_BRNICH_SCORE_CALIBRATION)], } ) @@ -198,8 +215,12 @@ def test_create_score_set_with_score_range( deepcopy(TEST_MINIMAL_SEQ_SCORESET_RESPONSE), experiment, response_data ) expected_response["experiment"].update({"numScoreSets": 1}) - expected_response["scoreRanges"] = saved_score_ranges - expected_response["secondaryPublicationIdentifiers"] = [SAVED_PUBMED_PUBLICATION] + expected_calibration = deepcopy(TEST_SAVED_BRNICH_SCORE_CALIBRATION) + expected_calibration["urn"] = response_data["scoreCalibrations"][0]["urn"] + expected_calibration["private"] = True + expected_calibration["primary"] = False + expected_calibration["investigatorProvided"] = True + expected_response["scoreCalibrations"] = [expected_calibration] assert sorted(expected_response.keys()) == sorted(response_data.keys()) for key in expected_response: @@ -210,32 +231,18 @@ def test_create_score_set_with_score_range( @pytest.mark.parametrize( - "score_ranges", + "mock_publication_fetch", [ - TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - TEST_SCORE_SET_RANGES_ONLY_SCOTT, - TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, - TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, + ( + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ) ], + indirect=["mock_publication_fetch"], ) -def test_cannot_create_score_set_with_score_range_and_source_when_publication_not_in_publications( - client, setup_router_db, score_ranges -): - experiment = create_experiment(client) - score_set = deepcopy(TEST_MINIMAL_SEQ_SCORESET) - score_set["experimentUrn"] = experiment["urn"] - score_set.update({"score_ranges": score_ranges}) - - response = client.post("/api/v1/score-sets/", json=score_set) - assert response.status_code == 422 - - response_data = response.json() - assert ( - "source publication at index 0 is not defined in score set publications." in response_data["detail"][0]["msg"] - ) - - -def test_cannot_create_score_set_with_nonexistent_contributor(client, setup_router_db): +def test_cannot_create_score_set_with_nonexistent_contributor(client, mock_publication_fetch, setup_router_db): experiment = create_experiment(client) score_set = deepcopy(TEST_MINIMAL_SEQ_SCORESET) score_set["experimentUrn"] = experiment["urn"] @@ -253,62 +260,18 @@ def test_cannot_create_score_set_with_nonexistent_contributor(client, setup_rout @pytest.mark.parametrize( - "score_ranges,saved_score_ranges", + "mock_publication_fetch", [ - (TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, TEST_SAVED_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED), - (TEST_SCORE_SET_RANGES_ONLY_SCOTT, TEST_SAVED_SCORE_SET_RANGES_ONLY_SCOTT), - (TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, TEST_SAVED_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION), - (TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, TEST_SAVED_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), + ( + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ) ], -) -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], indirect=["mock_publication_fetch"], ) -def test_remove_score_range_from_score_set( - client, setup_router_db, score_ranges, saved_score_ranges, mock_publication_fetch -): - experiment = create_experiment(client) - score_set = deepcopy(TEST_MINIMAL_SEQ_SCORESET) - score_set["experimentUrn"] = experiment["urn"] - score_set.update( - { - "score_ranges": score_ranges, - "secondary_publication_identifiers": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], - } - ) - - response = client.post("/api/v1/score-sets/", json=score_set) - assert response.status_code == 200 - response_data = response.json() - - jsonschema.validate(instance=response_data, schema=ScoreSet.model_json_schema()) - assert isinstance(MAVEDB_TMP_URN_RE.fullmatch(response_data["urn"]), re.Match) - - expected_response = update_expected_response_for_created_resources( - deepcopy(TEST_MINIMAL_SEQ_SCORESET_RESPONSE), experiment, response_data - ) - expected_response["experiment"].update({"numScoreSets": 1}) - expected_response["scoreRanges"] = saved_score_ranges - expected_response["secondaryPublicationIdentifiers"] = [SAVED_PUBMED_PUBLICATION] - - assert sorted(expected_response.keys()) == sorted(response_data.keys()) - for key in expected_response: - assert (key, expected_response[key]) == (key, response_data[key]) - - score_set.pop("score_ranges") - response = client.put(f"/api/v1/score-sets/{response_data['urn']}", json=score_set) - assert response.status_code == 200 - response_data = response.json() - - jsonschema.validate(instance=response_data, schema=ScoreSet.model_json_schema()) - assert isinstance(MAVEDB_TMP_URN_RE.fullmatch(response_data["urn"]), re.Match) - - assert "scoreRanges" not in response_data.keys() - - -def test_cannot_create_score_set_without_email(client, setup_router_db): +def test_cannot_create_score_set_without_email(client, mock_publication_fetch, setup_router_db): experiment = create_experiment(client) score_set_post_payload = deepcopy(TEST_MINIMAL_SEQ_SCORESET) score_set_post_payload["experimentUrn"] = experiment["urn"] @@ -319,7 +282,19 @@ def test_cannot_create_score_set_without_email(client, setup_router_db): assert response_data["detail"] in "There must be an email address associated with your account to use this feature." -def test_cannot_create_score_set_with_invalid_target_gene_category(client, setup_router_db): +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + ( + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ) + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_score_set_with_invalid_target_gene_category(client, mock_publication_fetch, setup_router_db): experiment = create_experiment(client) score_set_post_payload = deepcopy(TEST_MINIMAL_SEQ_SCORESET) score_set_post_payload["experimentUrn"] = experiment["urn"] @@ -351,7 +326,6 @@ def test_cannot_create_score_set_with_invalid_target_gene_category(client, setup ("doi_identifiers", [{"identifier": TEST_CROSSREF_IDENTIFIER}], [SAVED_DOI_IDENTIFIER]), ("license_id", EXTRA_LICENSE["id"], SAVED_SHORT_EXTRA_LICENSE), ("target_genes", TEST_MINIMAL_ACC_SCORESET["targetGenes"], TEST_MINIMAL_ACC_SCORESET_RESPONSE["targetGenes"]), - ("score_ranges", TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, TEST_SAVED_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), ], ) @pytest.mark.parametrize( @@ -380,12 +354,6 @@ def test_can_update_score_set_data_before_publication( score_set_update_payload = deepcopy(TEST_MINIMAL_SEQ_SCORESET) score_set_update_payload.update({camelize(attribute): updated_data}) - # The score ranges attribute requires a publication identifier source - if attribute == "score_ranges": - score_set_update_payload.update( - {"secondaryPublicationIdentifiers": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}]} - ) - response = client.put(f"/api/v1/score-sets/{score_set['urn']}", json=score_set_update_payload) assert response.status_code == 200 @@ -415,7 +383,6 @@ def test_can_update_score_set_data_before_publication( ("doi_identifiers", [{"identifier": TEST_CROSSREF_IDENTIFIER}], [SAVED_DOI_IDENTIFIER]), ("license_id", EXTRA_LICENSE["id"], SAVED_SHORT_EXTRA_LICENSE), ("target_genes", TEST_MINIMAL_ACC_SCORESET["targetGenes"], TEST_MINIMAL_ACC_SCORESET_RESPONSE["targetGenes"]), - ("score_ranges", TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, TEST_SAVED_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), ], ) @pytest.mark.parametrize( @@ -448,12 +415,6 @@ def test_can_patch_score_set_data_before_publication( form_value = str(updated_data) data[attribute] = form_value - # The score ranges attribute requires a publication identifier source - if attribute == "score_ranges": - data["secondary_publication_identifiers"] = json.dumps( - [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}] - ) - response = client.patch(f"/api/v1/score-sets-with-variants/{score_set['urn']}", data=data) assert response.status_code == 200 @@ -593,11 +554,6 @@ def test_can_update_score_set_supporting_data_after_publication( "attribute,updated_data,expected_response_data", [ ("target_genes", TEST_MINIMAL_ACC_SCORESET["targetGenes"], TEST_MINIMAL_SEQ_SCORESET_RESPONSE["targetGenes"]), - ( - "score_ranges", - TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, - None, - ), ("dataset_columns", {"countColumns": [], "scoreColumns": ["score"]}, SAVED_MINIMAL_DATASET_COLUMNS), ], ) @@ -654,7 +610,6 @@ def test_cannot_update_score_set_target_data_after_publication( score_set_update_payload.update( { camelize(attribute): updated_data, - "secondaryPublicationIdentifiers": [{"identifier": TEST_PUBMED_IDENTIFIER, "dbName": "PubMed"}], } ) response = client.put(f"/api/v1/score-sets/{published_urn}", json=score_set_update_payload) @@ -832,6 +787,81 @@ def test_admin_can_get_other_user_private_score_set(session, client, admin_app_o assert (key, expected_response[key]) == (key, response_data[key]) +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + ( + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ) + ], + indirect=["mock_publication_fetch"], +) +def test_extra_user_can_only_view_published_score_calibrations_in_score_set( + client, setup_router_db, extra_user_app_overrides, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + score_set = mock_worker_variant_insertion(client, session, data_provider, score_set, data_files / "scores.csv") + + with patch.object(arq.ArqRedis, "enqueue_job", return_value=None) as worker_queue: + published_score_set = publish_score_set(client, score_set["urn"]) + worker_queue.assert_called_once() + + create_test_score_calibration_in_score_set_via_client( + client, published_score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + public_calibration = create_publish_and_promote_score_calibration( + client, + published_score_set["urn"], + deepcamelize(TEST_BRNICH_SCORE_CALIBRATION), + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-sets/{published_score_set['urn']}") + + assert response.status_code == 200 + response_data = response.json() + assert len(response_data["scoreCalibrations"]) == 1 + assert response_data["scoreCalibrations"][0]["urn"] == public_calibration["urn"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + ( + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ) + ], + indirect=["mock_publication_fetch"], +) +def test_creating_user_can_view_all_score_calibrations_in_score_set(client, setup_router_db, mock_publication_fetch): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + private_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + public_calibration = create_publish_and_promote_score_calibration( + client, + score_set["urn"], + deepcamelize(TEST_BRNICH_SCORE_CALIBRATION), + ) + + response = client.get(f"/api/v1/score-sets/{score_set['urn']}") + + assert response.status_code == 200 + response_data = response.json() + assert len(response_data["scoreCalibrations"]) == 2 + urns = [calibration["urn"] for calibration in response_data["scoreCalibrations"]] + assert private_calibration["urn"] in urns + assert public_calibration["urn"] in urns + + ######################################################################################################################## # Adding scores to score set ######################################################################################################################## @@ -1292,6 +1322,40 @@ def test_publish_multiple_score_sets(session, data_provider, client, setup_route assert all([variant.urn.startswith("urn:mavedb:") for variant in score_set_3_variants]) +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_score_calibrations_remain_private_when_score_set_is_published( + session, data_provider, client, setup_router_db, data_files, mock_publication_fetch +): + experiment = create_experiment(client) + score_set = create_seq_score_set( + client, + experiment["urn"], + ) + score_set = mock_worker_variant_insertion(client, session, data_provider, score_set, data_files / "scores.csv") + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION) + ) + + with patch.object(arq.ArqRedis, "enqueue_job", return_value=None) as worker_queue: + published_score_set = publish_score_set(client, score_set["urn"]) + worker_queue.assert_called_once() + + # refresh score set to post worker state + score_set = (client.get(f"/api/v1/score-sets/{published_score_set['urn']}")).json() + + for score_calibration in score_set["scoreCalibrations"]: + assert score_calibration["private"] is True + + def test_cannot_publish_score_set_without_variants(client, setup_router_db): experiment = create_experiment(client) score_set = create_seq_score_set(client, experiment["urn"]) @@ -2578,102 +2642,6 @@ def test_show_correct_score_set_version_with_superseded_score_set_to_its_owner( assert score_set["urn"] == superseding_score_set["urn"] -######################################################################################################################## -# Score Ranges -######################################################################################################################## - - -@pytest.mark.parametrize( - "score_ranges", - [ - TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, - TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, - ], -) -def test_anonymous_user_cannot_add_score_ranges_to_score_set( - client, setup_router_db, anonymous_app_overrides, score_ranges -): - experiment = create_experiment(client) - score_set = create_seq_score_set(client, experiment["urn"]) - range_payload = deepcopy(score_ranges) - - with DependencyOverrider(anonymous_app_overrides): - response = client.post(f"/api/v1/score-sets/{score_set['urn']}/ranges/data", json=range_payload) - response_data = response.json() - - assert response.status_code == 401 - assert "score_calibrations" not in response_data - - -@pytest.mark.parametrize( - "score_ranges", - [ - TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, - TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, - ], -) -def test_user_cannot_add_score_ranges_to_own_score_set(client, setup_router_db, anonymous_app_overrides, score_ranges): - experiment = create_experiment(client) - score_set = create_seq_score_set(client, experiment["urn"]) - range_payload = deepcopy(score_ranges) - - response = client.post(f"/api/v1/score-sets/{score_set['urn']}/ranges/data", json=range_payload) - response_data = response.json() - - assert response.status_code == 401 - assert "score_calibrations" not in response_data - - -@pytest.mark.parametrize( - "score_ranges,saved_score_ranges", - [ - (TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, TEST_SAVED_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED), - (TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, TEST_SAVED_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION), - (TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, TEST_SAVED_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - ], -) -def test_admin_can_add_score_ranges_to_score_set( - client, setup_router_db, admin_app_overrides, score_ranges, saved_score_ranges -): - experiment = create_experiment(client) - score_set = create_seq_score_set(client, experiment["urn"]) - range_payload = deepcopy(score_ranges) - - with DependencyOverrider(admin_app_overrides): - response = client.post(f"/api/v1/score-sets/{score_set['urn']}/ranges/data", json=range_payload) - response_data = response.json() - - expected_response = update_expected_response_for_created_resources( - deepcopy(TEST_MINIMAL_SEQ_SCORESET_RESPONSE), experiment, score_set - ) - expected_response["scoreRanges"] = deepcopy(saved_score_ranges) - expected_response["experiment"].update({"numScoreSets": 1}) - - assert response.status_code == 200 - for key in expected_response: - assert (key, expected_response[key]) == (key, response_data[key]) - - -def test_score_set_not_found_for_non_existent_score_set_when_adding_score_calibrations( - client, setup_router_db, admin_app_overrides -): - experiment = create_experiment(client) - score_set = create_seq_score_set(client, experiment["urn"]) - range_payload = deepcopy(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT) - - with DependencyOverrider(admin_app_overrides): - response = client.post( - f"/api/v1/score-sets/{score_set['urn'] + 'xxx'}/ranges/data", - json=range_payload, - ) - response_data = response.json() - - assert response.status_code == 404 - assert "score_calibrations" not in response_data - - ######################################################################################################################## # Score set upload files ######################################################################################################################## @@ -2862,16 +2830,7 @@ def test_download_scores_and_counts_file(session, data_provider, client, setup_r download_scores_and_counts_csv = download_scores_and_counts_csv_response.text reader = csv.DictReader(StringIO(download_scores_and_counts_csv)) assert sorted(reader.fieldnames) == sorted( - [ - "accession", - "hgvs_nt", - "hgvs_pro", - "scores.score", - "scores.s_0", - "scores.s_1", - "counts.c_0", - "counts.c_1" - ] + ["accession", "hgvs_nt", "hgvs_pro", "scores.score", "scores.s_0", "scores.s_1", "counts.c_0", "counts.c_1"] ) @@ -2885,7 +2844,7 @@ def test_download_scores_and_counts_file(session, data_provider, client, setup_r ids=["without_post_mapped_vrs", "with_post_mapped_hgvs_g", "with_post_mapped_hgvs_p"], ) def test_download_scores_counts_and_post_mapped_variants_file( - session, data_provider, client, setup_router_db, data_files, mapped_variant, has_hgvs_g, has_hgvs_p + session, data_provider, client, setup_router_db, data_files, mapped_variant, has_hgvs_g, has_hgvs_p ): experiment = create_experiment(client) score_set = create_seq_score_set(client, experiment["urn"]) @@ -2916,7 +2875,7 @@ def test_download_scores_counts_and_post_mapped_variants_file( "scores.s_0", "scores.s_1", "counts.c_0", - "counts.c_1" + "counts.c_1", ] ) @@ -3103,7 +3062,12 @@ def test_cannot_get_annotated_variants_for_score_set_with_no_mapped_variants( @pytest.mark.parametrize( "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], + [ + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ], indirect=["mock_publication_fetch"], ) def test_get_annotated_pathogenicity_evidence_lines_for_score_set( @@ -3116,11 +3080,8 @@ def test_get_annotated_pathogenicity_evidence_lines_for_score_set( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION), - }, ) + create_publish_and_promote_score_calibration(client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION)) # The contents of the annotated variants objects should be tested in more detail elsewhere. response = client.get(f"/api/v1/score-sets/{score_set['urn']}/annotated-variants/pathogenicity-evidence-line") @@ -3148,14 +3109,8 @@ def test_nonetype_annotated_pathogenicity_evidence_lines_for_score_set_when_thre data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED), - }, ) - print(score_set["scoreRanges"]) - response = client.get(f"/api/v1/score-sets/{score_set['urn']}/annotated-variants/pathogenicity-evidence-line") response_data = response.json() @@ -3166,38 +3121,7 @@ def test_nonetype_annotated_pathogenicity_evidence_lines_for_score_set_when_thre assert annotated_variant is None -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) -def test_annotated_pathogenicity_evidence_lines_exists_for_score_set_when_ranges_not_present( - client, session, data_provider, data_files, setup_router_db, admin_app_overrides, 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", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION), - }, - ) - - response = client.get(f"/api/v1/score-sets/{score_set['urn']}/annotated-variants/pathogenicity-evidence-line") - response_data = response.json() - - assert response.status_code == 200 - assert len(response_data) == score_set["numVariants"] - - for variant_urn, annotated_variant in response_data.items(): - assert f"Pathogenicity evidence line {variant_urn}" in annotated_variant.get("description") - - -def test_nonetype_annotated_pathogenicity_evidence_lines_for_score_set_when_thresholds_and_ranges_not_present( +def test_nonetype_annotated_pathogenicity_evidence_lines_for_score_set_when_calibrations_not_present( client, session, data_provider, data_files, setup_router_db ): experiment = create_experiment(client) @@ -3221,7 +3145,12 @@ def test_nonetype_annotated_pathogenicity_evidence_lines_for_score_set_when_thre @pytest.mark.parametrize( "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], + [ + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ], indirect=["mock_publication_fetch"], ) def test_get_annotated_pathogenicity_evidence_lines_for_score_set_when_some_variants_were_not_mapped( @@ -3234,11 +3163,8 @@ def test_get_annotated_pathogenicity_evidence_lines_for_score_set_when_some_vari data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION), - }, ) + create_publish_and_promote_score_calibration(client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION)) first_var = clear_first_mapped_variant_post_mapped(session, score_set["urn"]) @@ -3257,7 +3183,12 @@ def test_get_annotated_pathogenicity_evidence_lines_for_score_set_when_some_vari @pytest.mark.parametrize( "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], + [ + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ], indirect=["mock_publication_fetch"], ) def test_get_annotated_functional_impact_statement_for_score_set( @@ -3270,42 +3201,8 @@ def test_get_annotated_functional_impact_statement_for_score_set( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, - ) - - response = client.get(f"/api/v1/score-sets/{score_set['urn']}/annotated-variants/functional-impact-statement") - response_data = response.json() - - assert response.status_code == 200 - assert len(response_data) == score_set["numVariants"] - - for _, annotated_variant in response_data.items(): - assert annotated_variant.get("type") == "Statement" - - -@pytest.mark.parametrize( - "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], - indirect=["mock_publication_fetch"], -) -def test_annotated_functional_impact_statement_exists_for_score_set_when_thresholds_not_present( - client, session, data_provider, data_files, setup_router_db, 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", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED), - }, ) + create_publish_and_promote_score_calibration(client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION)) response = client.get(f"/api/v1/score-sets/{score_set['urn']}/annotated-variants/functional-impact-statement") response_data = response.json() @@ -3322,7 +3219,7 @@ def test_annotated_functional_impact_statement_exists_for_score_set_when_thresho [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], indirect=["mock_publication_fetch"], ) -def test_nonetype_annotated_functional_impact_statement_for_score_set_when_ranges_not_present( +def test_nonetype_annotated_functional_impact_statement_for_score_set_when_calibrations_not_present( client, session, data_provider, data_files, setup_router_db, admin_app_overrides, mock_publication_fetch ): experiment = create_experiment(client) @@ -3334,7 +3231,7 @@ def test_nonetype_annotated_functional_impact_statement_for_score_set_when_range data_files / "scores.csv", update={ "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION), + "scoreRanges": camelize([TEST_BRNICH_SCORE_CALIBRATION, TEST_PATHOGENICITY_SCORE_CALIBRATION]), }, ) @@ -3372,7 +3269,12 @@ def test_nonetype_annotated_functional_impact_statement_for_score_set_when_thres @pytest.mark.parametrize( "mock_publication_fetch", - [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"})], + [ + [ + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, + ] + ], indirect=["mock_publication_fetch"], ) def test_get_annotated_functional_impact_statement_for_score_set_when_some_variants_were_not_mapped( @@ -3385,11 +3287,8 @@ def test_get_annotated_functional_impact_statement_for_score_set_when_some_varia data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) + create_publish_and_promote_score_calibration(client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION)) first_var = clear_first_mapped_variant_post_mapped(session, score_set["urn"]) @@ -3421,10 +3320,6 @@ def test_get_annotated_functional_study_result_for_score_set( data_provider, experiment["urn"], data_files / "scores.csv", - update={ - "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), - }, ) response = client.get(f"/api/v1/score-sets/{score_set['urn']}/annotated-variants/functional-study-result") @@ -3454,7 +3349,7 @@ def test_annotated_functional_study_result_exists_for_score_set_when_thresholds_ data_files / "scores.csv", update={ "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED), + "scoreRanges": camelize([TEST_BRNICH_SCORE_CALIBRATION, TEST_PATHOGENICITY_SCORE_CALIBRATION]), }, ) @@ -3485,7 +3380,7 @@ def test_annotated_functional_study_result_exists_for_score_set_when_ranges_not_ data_files / "scores.csv", update={ "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION), + "scoreRanges": camelize([TEST_BRNICH_SCORE_CALIBRATION, TEST_PATHOGENICITY_SCORE_CALIBRATION]), }, ) @@ -3538,7 +3433,7 @@ def test_annotated_functional_study_result_exists_for_score_set_when_some_varian data_files / "scores.csv", update={ "secondaryPublicationIdentifiers": [{"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}], - "scoreRanges": camelize(TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION), + "scoreRanges": camelize([TEST_BRNICH_SCORE_CALIBRATION, TEST_PATHOGENICITY_SCORE_CALIBRATION]), }, ) diff --git a/tests/view_models/test_acmg_classification.py b/tests/view_models/test_acmg_classification.py new file mode 100644 index 00000000..f7b68149 --- /dev/null +++ b/tests/view_models/test_acmg_classification.py @@ -0,0 +1,105 @@ +import pytest +from copy import deepcopy + +from mavedb.lib.exceptions import ValidationError +from mavedb.view_models.acmg_classification import ACMGClassificationCreate, ACMGClassification + +from tests.helpers.constants import ( + TEST_ACMG_BS3_STRONG_CLASSIFICATION, + TEST_ACMG_PS3_STRONG_CLASSIFICATION, + TEST_ACMG_BS3_STRONG_CLASSIFICATION_WITH_POINTS, + TEST_ACMG_PS3_STRONG_CLASSIFICATION_WITH_POINTS, + TEST_SAVED_ACMG_BS3_STRONG_CLASSIFICATION, + TEST_SAVED_ACMG_PS3_STRONG_CLASSIFICATION, + TEST_SAVED_ACMG_BS3_STRONG_CLASSIFICATION_WITH_POINTS, + TEST_SAVED_ACMG_PS3_STRONG_CLASSIFICATION_WITH_POINTS, +) + + +### ACMG Classification Creation Tests ### + + +@pytest.mark.parametrize( + "valid_acmg_classification", + [ + TEST_ACMG_BS3_STRONG_CLASSIFICATION, + TEST_ACMG_PS3_STRONG_CLASSIFICATION, + TEST_ACMG_BS3_STRONG_CLASSIFICATION_WITH_POINTS, + TEST_ACMG_PS3_STRONG_CLASSIFICATION_WITH_POINTS, + ], +) +def test_can_create_acmg_classification(valid_acmg_classification): + """Test that valid ACMG classifications can be created.""" + acmg = ACMGClassificationCreate(**valid_acmg_classification) + + assert isinstance(acmg, ACMGClassificationCreate) + assert acmg.criterion == valid_acmg_classification.get("criterion") + assert acmg.evidence_strength == valid_acmg_classification.get("evidence_strength") + assert acmg.points == valid_acmg_classification.get("points") + + +def test_cannot_create_acmg_classification_with_mismatched_points(): + """Test that an ACMG classification cannot be created with mismatched points.""" + invalid_acmg_classification = deepcopy(TEST_ACMG_BS3_STRONG_CLASSIFICATION) + invalid_acmg_classification["points"] = 2 # BS3 Strong should be -4 points + + with pytest.raises(ValidationError) as exc: + ACMGClassificationCreate(**invalid_acmg_classification) + + assert "The provided points value does not agree with the provided criterion and evidence_strength" in str( + exc.value + ) + + +def test_cannot_create_acmg_classification_with_only_criterion(): + """Test that an ACMG classification cannot be created with only criterion.""" + invalid_acmg_classification = deepcopy(TEST_ACMG_BS3_STRONG_CLASSIFICATION) + invalid_acmg_classification.pop("evidence_strength") + + with pytest.raises(ValidationError) as exc: + ACMGClassificationCreate(**invalid_acmg_classification) + + assert "Both a criterion and evidence_strength must be provided together" in str(exc.value) + + +def test_cannot_create_acmg_classification_with_only_evidence_strength(): + """Test that an ACMG classification cannot be created with only evidence_strength.""" + invalid_acmg_classification = deepcopy(TEST_ACMG_BS3_STRONG_CLASSIFICATION) + invalid_acmg_classification.pop("criterion") + + with pytest.raises(ValidationError) as exc: + ACMGClassificationCreate(**invalid_acmg_classification) + + assert "Both a criterion and evidence_strength must be provided together" in str(exc.value) + + +def test_can_create_acmg_classification_from_points(): + """Test that an ACMG classification can be created from points alone.""" + acmg = ACMGClassificationCreate(points=-4) # BS3 Strong + + assert isinstance(acmg, ACMGClassificationCreate) + assert acmg.criterion == "BS3" + assert acmg.evidence_strength == "strong" + assert acmg.points == -4 + + +### ACMG Classification Saved Data Tests ### + + +@pytest.mark.parametrize( + "valid_saved_classification", + [ + TEST_SAVED_ACMG_BS3_STRONG_CLASSIFICATION, + TEST_SAVED_ACMG_PS3_STRONG_CLASSIFICATION, + TEST_SAVED_ACMG_BS3_STRONG_CLASSIFICATION_WITH_POINTS, + TEST_SAVED_ACMG_PS3_STRONG_CLASSIFICATION_WITH_POINTS, + ], +) +def test_can_create_acmg_classification_from_saved_data(valid_saved_classification): + """Test that an ACMG classification can be created from saved data.""" + acmg = ACMGClassification(**valid_saved_classification) + + assert isinstance(acmg, ACMGClassification) + assert acmg.criterion == valid_saved_classification.get("criterion") + assert acmg.evidence_strength == valid_saved_classification.get("evidenceStrength") + assert acmg.points == valid_saved_classification.get("points") diff --git a/tests/view_models/test_odds_path.py b/tests/view_models/test_odds_path.py deleted file mode 100644 index 93585bef..00000000 --- a/tests/view_models/test_odds_path.py +++ /dev/null @@ -1,45 +0,0 @@ -import pytest -from pydantic import ValidationError - -from mavedb.view_models.odds_path import OddsPathBase, OddsPathModify, OddsPathCreate - -from tests.helpers.constants import TEST_BS3_ODDS_PATH, TEST_PS3_ODDS_PATH - - -@pytest.mark.parametrize("valid_data", [TEST_BS3_ODDS_PATH, TEST_PS3_ODDS_PATH]) -def test_odds_path_base_valid_data(valid_data): - odds_path = OddsPathBase(**valid_data) - assert odds_path.ratio == valid_data["ratio"] - assert odds_path.evidence == valid_data["evidence"] - - -def test_odds_path_base_no_evidence(): - odds_with_no_evidence = TEST_BS3_ODDS_PATH.copy() - odds_with_no_evidence["evidence"] = None - - odds_path = OddsPathBase(**odds_with_no_evidence) - assert odds_path.ratio == odds_with_no_evidence["ratio"] - assert odds_path.evidence is None - - -@pytest.mark.parametrize("valid_data", [TEST_BS3_ODDS_PATH, TEST_PS3_ODDS_PATH]) -def test_odds_path_base_invalid_data(valid_data): - odds_path = OddsPathModify(**valid_data) - assert odds_path.ratio == valid_data["ratio"] - assert odds_path.evidence == valid_data["evidence"] - - -def test_odds_path_modify_invalid_ratio(): - invalid_data = { - "ratio": -1.0, - "evidence": "BS3_STRONG", - } - with pytest.raises(ValidationError, match="OddsPath value must be greater than or equal to 0"): - OddsPathModify(**invalid_data) - - -@pytest.mark.parametrize("valid_data", [TEST_BS3_ODDS_PATH, TEST_PS3_ODDS_PATH]) -def test_odds_path_create_valid(valid_data): - odds_path = OddsPathCreate(**valid_data) - assert odds_path.ratio == valid_data["ratio"] - assert odds_path.evidence == valid_data["evidence"] diff --git a/tests/view_models/test_score_calibration.py b/tests/view_models/test_score_calibration.py new file mode 100644 index 00000000..bf89aec4 --- /dev/null +++ b/tests/view_models/test_score_calibration.py @@ -0,0 +1,497 @@ +from copy import deepcopy + +import pytest +from pydantic import ValidationError + +from mavedb.lib.acmg import ACMGCriterion +from mavedb.models.enums.score_calibration_relation import ScoreCalibrationRelation +from mavedb.view_models.score_calibration import ( + FunctionalRangeCreate, + ScoreCalibration, + ScoreCalibrationCreate, + ScoreCalibrationWithScoreSetUrn, +) +from tests.helpers.constants import ( + TEST_BRNICH_SCORE_CALIBRATION, + TEST_FUNCTIONAL_RANGE_ABNORMAL, + TEST_FUNCTIONAL_RANGE_INCLUDING_NEGATIVE_INFINITY, + TEST_FUNCTIONAL_RANGE_INCLUDING_POSITIVE_INFINITY, + TEST_FUNCTIONAL_RANGE_NORMAL, + TEST_FUNCTIONAL_RANGE_NOT_SPECIFIED, + TEST_PATHOGENICITY_SCORE_CALIBRATION, + TEST_SAVED_BRNICH_SCORE_CALIBRATION, + TEST_SAVED_PATHOGENICITY_SCORE_CALIBRATION, +) +from tests.helpers.util.common import dummy_attributed_object_from_dict + +############################################################################## +# Tests for FunctionalRange view models +############################################################################## + + +## Tests on models generated from dicts (e.g. request bodies) + + +@pytest.mark.parametrize( + "functional_range", + [ + TEST_FUNCTIONAL_RANGE_NORMAL, + TEST_FUNCTIONAL_RANGE_ABNORMAL, + TEST_FUNCTIONAL_RANGE_NOT_SPECIFIED, + TEST_FUNCTIONAL_RANGE_INCLUDING_POSITIVE_INFINITY, + TEST_FUNCTIONAL_RANGE_INCLUDING_NEGATIVE_INFINITY, + ], +) +def test_can_create_valid_functional_range(functional_range): + fr = FunctionalRangeCreate.model_validate(functional_range) + + assert fr.label == functional_range["label"] + assert fr.description == functional_range.get("description") + assert fr.classification == functional_range["classification"] + assert fr.range == tuple(functional_range["range"]) + assert fr.inclusive_lower_bound == functional_range.get("inclusive_lower_bound", True) + assert fr.inclusive_upper_bound == functional_range.get("inclusive_upper_bound", False) + + +def test_cannot_create_functional_range_with_reversed_range(): + invalid_data = deepcopy(TEST_FUNCTIONAL_RANGE_NORMAL) + invalid_data["range"] = (2, 1) + with pytest.raises(ValidationError, match="The lower bound cannot exceed the upper bound."): + FunctionalRangeCreate.model_validate(invalid_data) + + +def test_cannot_create_functional_range_with_equal_bounds(): + invalid_data = deepcopy(TEST_FUNCTIONAL_RANGE_NORMAL) + invalid_data["range"] = (1, 1) + with pytest.raises(ValidationError, match="The lower and upper bounds cannot be identical."): + FunctionalRangeCreate.model_validate(invalid_data) + + +def test_can_create_range_with_infinity_bounds(): + valid_data = deepcopy(TEST_FUNCTIONAL_RANGE_NORMAL) + valid_data["inclusive_lower_bound"] = False + valid_data["inclusive_upper_bound"] = False + valid_data["range"] = (None, None) + + fr = FunctionalRangeCreate.model_validate(valid_data) + assert fr.range == (None, None) + + +@pytest.mark.parametrize("ratio_property", ["oddspaths_ratio", "positive_likelihood_ratio"]) +def test_cannot_create_functional_range_with_negative_ratios(ratio_property): + invalid_data = deepcopy(TEST_FUNCTIONAL_RANGE_NORMAL) + invalid_data[ratio_property] = -1.0 + with pytest.raises(ValidationError, match="The ratio must be greater than or equal to 0."): + FunctionalRangeCreate.model_validate(invalid_data) + + +def test_cannot_create_functional_range_with_inclusive_bounds_at_infinity(): + invalid_data = deepcopy(TEST_FUNCTIONAL_RANGE_INCLUDING_POSITIVE_INFINITY) + invalid_data["inclusive_upper_bound"] = True + with pytest.raises(ValidationError, match="An inclusive upper bound may not include positive infinity."): + FunctionalRangeCreate.model_validate(invalid_data) + + invalid_data = deepcopy(TEST_FUNCTIONAL_RANGE_INCLUDING_NEGATIVE_INFINITY) + invalid_data["inclusive_lower_bound"] = True + with pytest.raises(ValidationError, match="An inclusive lower bound may not include negative infinity."): + FunctionalRangeCreate.model_validate(invalid_data) + + +@pytest.mark.parametrize( + "functional_range, opposite_criterion", + [(TEST_FUNCTIONAL_RANGE_NORMAL, ACMGCriterion.PS3), (TEST_FUNCTIONAL_RANGE_ABNORMAL, ACMGCriterion.BS3)], +) +def test_cannot_create_functional_range_when_classification_disagrees_with_acmg_criterion( + functional_range, opposite_criterion +): + invalid_data = deepcopy(functional_range) + invalid_data["acmg_classification"]["criterion"] = opposite_criterion.value + with pytest.raises(ValidationError, match="must agree with the functional range classification"): + FunctionalRangeCreate.model_validate(invalid_data) + + +def test_none_type_classification_and_evidence_strength_count_as_agreement(): + valid_data = deepcopy(TEST_FUNCTIONAL_RANGE_NORMAL) + valid_data["acmg_classification"] = {"criterion": None, "evidence_strength": None} + + fr = FunctionalRangeCreate.model_validate(valid_data) + assert fr.acmg_classification.criterion is None + assert fr.acmg_classification.evidence_strength is None + + +def test_cannot_create_functional_range_when_oddspaths_evidence_disagrees_with_classification(): + invalid_data = deepcopy(TEST_FUNCTIONAL_RANGE_NORMAL) + # Abnormal evidence strength for a normal range + invalid_data["oddspaths_ratio"] = 350 + with pytest.raises(ValidationError, match="implies criterion"): + FunctionalRangeCreate.model_validate(invalid_data) + + invalid_data = deepcopy(TEST_FUNCTIONAL_RANGE_ABNORMAL) + # Normal evidence strength for an abnormal range + invalid_data["oddspaths_ratio"] = 0.1 + with pytest.raises(ValidationError, match="implies criterion"): + FunctionalRangeCreate.model_validate(invalid_data) + + +def test_is_contained_by_range(): + fr = FunctionalRangeCreate.model_validate( + { + "label": "test range", + "classification": "abnormal", + "range": (0.0, 1.0), + "inclusive_lower_bound": True, + "inclusive_upper_bound": True, + } + ) + + assert fr.is_contained_by_range(1.0), "1.0 (inclusive upper bound) should be contained in the range" + assert fr.is_contained_by_range(0.0), "0.0 (inclusive lower bound) should be contained in the range" + assert not fr.is_contained_by_range(-0.1), "values below lower bound should not be contained in the range" + assert not fr.is_contained_by_range(5.0), "values above upper bound should not be contained in the range" + + fr.inclusive_lower_bound = False + fr.inclusive_upper_bound = False + + assert not fr.is_contained_by_range(1.0), "1.0 (exclusive upper bound) should not be contained in the range" + assert not fr.is_contained_by_range(0.0), "0.0 (exclusive lower bound) should not be contained in the range" + + +############################################################################## +# Tests for ScoreCalibration view models +############################################################################## + +# Tests on models generated from dicts (e.g. request bodies) + + +@pytest.mark.parametrize( + "valid_calibration", + [TEST_BRNICH_SCORE_CALIBRATION, TEST_PATHOGENICITY_SCORE_CALIBRATION], +) +def test_can_create_valid_score_calibration(valid_calibration): + sc = ScoreCalibrationCreate.model_validate(valid_calibration) + + assert sc.title == valid_calibration["title"] + assert sc.research_use_only == valid_calibration.get("research_use_only", False) + assert sc.baseline_score == valid_calibration.get("baseline_score") + assert sc.baseline_score_description == valid_calibration.get("baseline_score_description") + + if valid_calibration.get("functional_ranges") is not None: + assert len(sc.functional_ranges) == len(valid_calibration["functional_ranges"]) + # functional range validation is presumed to be well tested separately. + else: + assert sc.functional_ranges is None + + if valid_calibration.get("threshold_sources") is not None: + assert len(sc.threshold_sources) == len(valid_calibration["threshold_sources"]) + for pub in valid_calibration["threshold_sources"]: + assert pub["identifier"] in [rs.identifier for rs in sc.threshold_sources] + else: + assert sc.threshold_sources is None + + if valid_calibration.get("classification_sources") is not None: + assert len(sc.classification_sources) == len(valid_calibration["classification_sources"]) + for pub in valid_calibration["classification_sources"]: + assert pub["identifier"] in [rs.identifier for rs in sc.classification_sources] + else: + assert sc.classification_sources is None + + if valid_calibration.get("method_sources") is not None: + assert len(sc.method_sources) == len(valid_calibration["method_sources"]) + for pub in valid_calibration["method_sources"]: + assert pub["identifier"] in [rs.identifier for rs in sc.method_sources] + else: + assert sc.method_sources is None + + if valid_calibration.get("calibration_metadata") is not None: + assert sc.calibration_metadata == valid_calibration["calibration_metadata"] + else: + assert sc.calibration_metadata is None + + +# Making an exception to usually not testing the ability to create models without optional fields, +# because of the large number of model validators that need to play nice with this case. +@pytest.mark.parametrize( + "valid_calibration", + [TEST_BRNICH_SCORE_CALIBRATION, TEST_PATHOGENICITY_SCORE_CALIBRATION], +) +def test_can_create_valid_score_calibration_without_functional_ranges(valid_calibration): + valid_calibration = deepcopy(valid_calibration) + valid_calibration["functional_ranges"] = None + + sc = ScoreCalibrationCreate.model_validate(valid_calibration) + + assert sc.title == valid_calibration["title"] + assert sc.research_use_only == valid_calibration.get("research_use_only", False) + assert sc.baseline_score == valid_calibration.get("baseline_score") + assert sc.baseline_score_description == valid_calibration.get("baseline_score_description") + + if valid_calibration.get("functional_ranges") is not None: + assert len(sc.functional_ranges) == len(valid_calibration["functional_ranges"]) + # functional range validation is presumed to be well tested separately. + else: + assert sc.functional_ranges is None + + if valid_calibration.get("threshold_sources") is not None: + assert len(sc.threshold_sources) == len(valid_calibration["threshold_sources"]) + for pub in valid_calibration["threshold_sources"]: + assert pub["identifier"] in [rs.identifier for rs in sc.threshold_sources] + else: + assert sc.threshold_sources is None + + if valid_calibration.get("classification_sources") is not None: + assert len(sc.classification_sources) == len(valid_calibration["classification_sources"]) + for pub in valid_calibration["classification_sources"]: + assert pub["identifier"] in [rs.identifier for rs in sc.classification_sources] + else: + assert sc.classification_sources is None + + if valid_calibration.get("method_sources") is not None: + assert len(sc.method_sources) == len(valid_calibration["method_sources"]) + for pub in valid_calibration["method_sources"]: + assert pub["identifier"] in [rs.identifier for rs in sc.method_sources] + else: + assert sc.method_sources is None + + if valid_calibration.get("calibration_metadata") is not None: + assert sc.calibration_metadata == valid_calibration["calibration_metadata"] + else: + assert sc.calibration_metadata is None + + +def test_cannot_create_score_calibration_when_classification_ranges_overlap(): + invalid_data = deepcopy(TEST_BRNICH_SCORE_CALIBRATION) + # Make the first two ranges overlap + invalid_data["functional_ranges"][0]["range"] = [1.0, 3.0] + invalid_data["functional_ranges"][1]["range"] = [2.0, 4.0] + with pytest.raises(ValidationError, match="Classified score ranges may not overlap; `"): + ScoreCalibrationCreate.model_validate(invalid_data) + + +def test_can_create_score_calibration_when_unclassified_ranges_overlap_with_classified_ranges(): + valid_data = deepcopy(TEST_BRNICH_SCORE_CALIBRATION) + # Make the first two ranges overlap, one being 'not_specified' + valid_data["functional_ranges"][0]["range"] = [1.5, 3.0] + valid_data["functional_ranges"][1]["range"] = [2.0, 4.0] + valid_data["functional_ranges"][0]["classification"] = "not_specified" + sc = ScoreCalibrationCreate.model_validate(valid_data) + assert len(sc.functional_ranges) == len(valid_data["functional_ranges"]) + + +def test_can_create_score_calibration_when_unclassified_ranges_overlap_with_each_other(): + valid_data = deepcopy(TEST_BRNICH_SCORE_CALIBRATION) + # Make the first two ranges overlap, both being 'not_specified' + valid_data["functional_ranges"][0]["range"] = [1.5, 3.0] + valid_data["functional_ranges"][1]["range"] = [2.0, 4.0] + valid_data["functional_ranges"][0]["classification"] = "not_specified" + valid_data["functional_ranges"][1]["classification"] = "not_specified" + sc = ScoreCalibrationCreate.model_validate(valid_data) + assert len(sc.functional_ranges) == len(valid_data["functional_ranges"]) + + +def test_cannot_create_score_calibration_when_ranges_touch_with_inclusive_ranges(): + invalid_data = deepcopy(TEST_BRNICH_SCORE_CALIBRATION) + # Make the first two ranges touch + invalid_data["functional_ranges"][0]["range"] = [1.0, 2.0] + invalid_data["functional_ranges"][1]["range"] = [2.0, 4.0] + invalid_data["functional_ranges"][0]["inclusive_upper_bound"] = True + with pytest.raises(ValidationError, match="Classified score ranges may not overlap; `"): + ScoreCalibrationCreate.model_validate(invalid_data) + + +def test_cannot_create_score_calibration_with_duplicate_range_labels(): + invalid_data = deepcopy(TEST_BRNICH_SCORE_CALIBRATION) + # Make the first two ranges have the same label + invalid_data["functional_ranges"][0]["label"] = "duplicate label" + invalid_data["functional_ranges"][1]["label"] = "duplicate label" + with pytest.raises(ValidationError, match="Functional range labels must be unique"): + ScoreCalibrationCreate.model_validate(invalid_data) + + +# Making an exception to usually not testing the ability to create models without optional fields, +# since model validators sometimes rely on their absence. +def test_can_create_score_calibration_without_baseline_score(): + valid_data = deepcopy(TEST_BRNICH_SCORE_CALIBRATION) + valid_data["baseline_score"] = None + + sc = ScoreCalibrationCreate.model_validate(valid_data) + assert sc.baseline_score is None + + +def test_can_create_score_calibration_with_baseline_score_when_outside_all_ranges(): + valid_data = deepcopy(TEST_BRNICH_SCORE_CALIBRATION) + valid_data["baseline_score"] = 10.0 + + sc = ScoreCalibrationCreate.model_validate(valid_data) + assert sc.baseline_score == 10.0 + + +def test_can_create_score_calibration_with_baseline_score_when_inside_normal_range(): + valid_data = deepcopy(TEST_BRNICH_SCORE_CALIBRATION) + valid_data["baseline_score"] = 3.0 + + sc = ScoreCalibrationCreate.model_validate(valid_data) + assert sc.baseline_score == 3.0 + + +def test_cannot_create_score_calibration_with_baseline_score_when_inside_non_normal_range(): + invalid_data = deepcopy(TEST_BRNICH_SCORE_CALIBRATION) + invalid_data["baseline_score"] = -3.0 + with pytest.raises(ValueError, match="Baseline scores may not fall within non-normal ranges"): + ScoreCalibrationCreate.model_validate(invalid_data) + + +# Tests on models generated from attributed objects (e.g. ORM models) + + +@pytest.mark.parametrize( + "valid_calibration", + [TEST_SAVED_BRNICH_SCORE_CALIBRATION, TEST_SAVED_PATHOGENICITY_SCORE_CALIBRATION], +) +def test_can_create_valid_score_calibration_from_attributed_object(valid_calibration): + sc = ScoreCalibration.model_validate(dummy_attributed_object_from_dict(valid_calibration)) + + assert sc.title == valid_calibration["title"] + assert sc.research_use_only == valid_calibration.get("researchUseOnly", False) + assert sc.primary == valid_calibration.get("primary", True) + assert sc.investigator_provided == valid_calibration.get("investigatorProvided", False) + assert sc.baseline_score == valid_calibration.get("baselineScore") + assert sc.baseline_score_description == valid_calibration.get("baselineScoreDescription") + + if valid_calibration.get("functionalRanges") is not None: + assert len(sc.functional_ranges) == len(valid_calibration["functionalRanges"]) + # functional range validation is presumed to be well tested separately. + else: + assert sc.functional_ranges is None + + if valid_calibration.get("thresholdSources") is not None: + assert len(sc.threshold_sources) == len(valid_calibration["thresholdSources"]) + for pub in valid_calibration["thresholdSources"]: + assert pub["identifier"] in [rs.identifier for rs in sc.threshold_sources] + else: + assert sc.threshold_sources is None + + if valid_calibration.get("classificationSources") is not None: + assert len(sc.classification_sources) == len(valid_calibration["classificationSources"]) + for pub in valid_calibration["classificationSources"]: + assert pub["identifier"] in [rs.identifier for rs in sc.classification_sources] + else: + assert sc.classification_sources is None + + if valid_calibration.get("methodSources") is not None: + assert len(sc.method_sources) == len(valid_calibration["methodSources"]) + for pub in valid_calibration["methodSources"]: + assert pub["identifier"] in [rs.identifier for rs in sc.method_sources] + else: + assert sc.method_sources is None + + if valid_calibration.get("calibrationMetadata") is not None: + assert sc.calibration_metadata == valid_calibration["calibrationMetadata"] + else: + assert sc.calibration_metadata is None + + +def test_cannot_create_score_calibration_when_publication_information_is_missing(): + invalid_data = deepcopy(TEST_SAVED_BRNICH_SCORE_CALIBRATION) + # Add publication identifiers with missing information + invalid_data.pop("thresholdSources", None) + invalid_data.pop("classificationSources", None) + invalid_data.pop("methodSources", None) + with pytest.raises(ValidationError, match="Unable to create ScoreCalibration without attribute"): + ScoreCalibration.model_validate(dummy_attributed_object_from_dict(invalid_data)) + + +def test_can_create_score_calibration_from_association_style_publication_identifiers_against_attributed_object(): + orig_data = TEST_SAVED_BRNICH_SCORE_CALIBRATION + data = deepcopy(orig_data) + + threshold_sources = [ + dummy_attributed_object_from_dict({"publication": pub, "relation": ScoreCalibrationRelation.threshold}) + for pub in data.pop("thresholdSources", []) + ] + classification_sources = [ + dummy_attributed_object_from_dict({"publication": pub, "relation": ScoreCalibrationRelation.classification}) + for pub in data.pop("classificationSources", []) + ] + method_sources = [ + dummy_attributed_object_from_dict({"publication": pub, "relation": ScoreCalibrationRelation.method}) + for pub in data.pop("methodSources", []) + ] + + # Simulate ORM model by adding required fields that would originate from the DB + data["publication_identifier_associations"] = threshold_sources + classification_sources + method_sources + data["id"] = 1 + data["score_set_id"] = 1 + + sc = ScoreCalibration.model_validate(dummy_attributed_object_from_dict(data)) + + assert sc.title == orig_data["title"] + assert sc.research_use_only == orig_data.get("researchUseOnly", False) + assert sc.primary == orig_data.get("primary", False) + assert sc.investigator_provided == orig_data.get("investigatorProvided", False) + assert sc.baseline_score == orig_data.get("baselineScore") + assert sc.baseline_score_description == orig_data.get("baselineScoreDescription") + + if orig_data.get("functionalRanges") is not None: + assert len(sc.functional_ranges) == len(orig_data["functionalRanges"]) + # functional range validation is presumed to be well tested separately. + else: + assert sc.functional_ranges is None + + if orig_data.get("thresholdSources") is not None: + assert len(sc.threshold_sources) == len(orig_data["thresholdSources"]) + for pub in orig_data["thresholdSources"]: + assert pub["identifier"] in [rs.identifier for rs in sc.threshold_sources] + else: + assert sc.threshold_sources is None + + if orig_data.get("classificationSources") is not None: + assert len(sc.classification_sources) == len(orig_data["classificationSources"]) + for pub in orig_data["classificationSources"]: + assert pub["identifier"] in [rs.identifier for rs in sc.classification_sources] + else: + assert sc.classification_sources is None + + if orig_data.get("methodSources") is not None: + assert len(sc.method_sources) == len(orig_data["methodSources"]) + for pub in orig_data["methodSources"]: + assert pub["identifier"] in [rs.identifier for rs in sc.method_sources] + else: + assert sc.method_sources is None + + if orig_data.get("calibrationMetadata") is not None: + assert sc.calibration_metadata == orig_data["calibrationMetadata"] + else: + assert sc.calibration_metadata is None + + +def test_primary_score_calibration_cannot_be_research_use_only(): + invalid_data = deepcopy(TEST_SAVED_BRNICH_SCORE_CALIBRATION) + invalid_data["primary"] = True + invalid_data["researchUseOnly"] = True + with pytest.raises(ValidationError, match="Primary score calibrations may not be marked as research use only"): + ScoreCalibration.model_validate(dummy_attributed_object_from_dict(invalid_data)) + + +def test_primary_score_calibration_cannot_be_private(): + invalid_data = deepcopy(TEST_SAVED_BRNICH_SCORE_CALIBRATION) + invalid_data["primary"] = True + invalid_data["private"] = True + with pytest.raises(ValidationError, match="Primary score calibrations may not be marked as private"): + ScoreCalibration.model_validate(dummy_attributed_object_from_dict(invalid_data)) + + +def test_score_calibration_with_score_set_urn_can_be_created_from_attributed_object(): + data = deepcopy(TEST_SAVED_BRNICH_SCORE_CALIBRATION) + data["score_set"] = dummy_attributed_object_from_dict({"urn": "urn:mavedb:00000000-0000-0000-0000-000000000001"}) + + sc = ScoreCalibrationWithScoreSetUrn.model_validate(dummy_attributed_object_from_dict(data)) + + assert sc.title == data["title"] + assert sc.score_set_urn == data["score_set"].urn + + +def test_score_calibration_with_score_set_urn_cannot_be_created_without_score_set_urn(): + invalid_data = deepcopy(TEST_SAVED_BRNICH_SCORE_CALIBRATION) + invalid_data["score_set"] = dummy_attributed_object_from_dict({}) + with pytest.raises(ValidationError, match="Unable to create ScoreCalibrationWithScoreSetUrn without attribute"): + ScoreCalibrationWithScoreSetUrn.model_validate(dummy_attributed_object_from_dict(invalid_data)) diff --git a/tests/view_models/test_score_range.py b/tests/view_models/test_score_range.py deleted file mode 100644 index 704e26b1..00000000 --- a/tests/view_models/test_score_range.py +++ /dev/null @@ -1,796 +0,0 @@ -from copy import deepcopy -import pytest -from pydantic import ValidationError - -from mavedb.view_models.score_range import ( - ScoreRangeModify, - ScoreRangeCreate, - ScoreRange, - ScoreRangesCreate, - ScoreRangesModify, - ScoreRanges, - BrnichScoreRangeCreate, - BrnichScoreRangeModify, - BrnichScoreRange, - BrnichScoreRangesCreate, - BrnichScoreRangesModify, - BrnichScoreRanges, - InvestigatorScoreRangesCreate, - InvestigatorScoreRangesModify, - InvestigatorScoreRanges, - ScottScoreRangesCreate, - ScottScoreRangesModify, - ScottScoreRanges, - ZeibergCalibrationScoreRangeCreate, - ZeibergCalibrationScoreRangeModify, - ZeibergCalibrationScoreRange, - ZeibergCalibrationScoreRangesCreate, - ZeibergCalibrationScoreRangesModify, - ZeibergCalibrationScoreRanges, - ScoreSetRangesModify, - ScoreSetRangesCreate, - ScoreSetRanges, -) - -from tests.helpers.constants import ( - TEST_SCORE_SET_NORMAL_RANGE, - TEST_SCORE_SET_ABNORMAL_RANGE, - TEST_SCORE_SET_NOT_SPECIFIED_RANGE, - TEST_BRNICH_SCORE_SET_NORMAL_RANGE, - TEST_BRNICH_SCORE_SET_ABNORMAL_RANGE, - TEST_ZEIBERG_CALIBRATION_SCORE_SET_BS3_STRONG_RANGE, - TEST_ZEIBERG_CALIBRATION_SCORE_SET_PS3_STRONG_RANGE, - TEST_BRNICH_SCORE_SET_RANGE, - TEST_BRNICH_SCORE_SET_RANGE_WITH_SOURCE, - TEST_ZEIBERG_CALIBRATION_SCORE_SET_RANGE, - TEST_ZEIBERG_CALIBRATION_SCORE_SET_RANGE_WITH_SOURCE, - TEST_SCORE_SET_RANGE, - TEST_SCORE_SET_RANGE_WITH_SOURCE, - TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - TEST_SCORE_SET_RANGES_ONLY_SCOTT, - TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, - TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, - TEST_SCORE_SET_NEGATIVE_INFINITY_RANGE, - TEST_SCORE_SET_POSITIVE_INFINITY_RANGE, - TEST_BASELINE_SCORE, -) - - -### ScoreRange Tests ### - - -@pytest.mark.parametrize( - "score_range_data", - [ - TEST_SCORE_SET_NORMAL_RANGE, - TEST_SCORE_SET_ABNORMAL_RANGE, - TEST_SCORE_SET_NOT_SPECIFIED_RANGE, - TEST_SCORE_SET_POSITIVE_INFINITY_RANGE, - TEST_SCORE_SET_NEGATIVE_INFINITY_RANGE, - ], -) -@pytest.mark.parametrize("ScoreRangeModel", [ScoreRange, ScoreRangeModify, ScoreRangeCreate]) -def test_score_range_base_valid_range(ScoreRangeModel, score_range_data): - score_range = ScoreRangeModel(**score_range_data) - assert score_range.label == score_range_data["label"], "Label should match" - assert score_range.classification == score_range_data["classification"], "Classification should match" - assert score_range.range[0] == score_range_data["range"][0], "Range should match" - assert score_range.range[1] == score_range_data["range"][1], "Range should match" - assert score_range.description == score_range_data.get("description", None), "Description should match" - assert score_range.inclusive_lower_bound == score_range_data.get( - "inclusive_lower_bound" - ), "Inclusive lower bound should match" - assert score_range.inclusive_upper_bound == score_range_data.get( - "inclusive_upper_bound" - ), "Inclusive upper bound should match" - - -@pytest.mark.parametrize( - "score_range_data", - [TEST_BRNICH_SCORE_SET_NORMAL_RANGE, TEST_BRNICH_SCORE_SET_ABNORMAL_RANGE], -) -@pytest.mark.parametrize("ScoreRangeModel", [BrnichScoreRange, BrnichScoreRangeCreate, BrnichScoreRangeModify]) -def test_score_range_brnich_valid_range(ScoreRangeModel, score_range_data): - score_range = ScoreRangeModel(**score_range_data) - assert score_range.label == score_range_data["label"], "Label should match" - assert score_range.classification == score_range_data["classification"], "Classification should match" - assert score_range.range[0] == score_range_data["range"][0], "Range should match" - assert score_range.range[1] == score_range_data["range"][1], "Range should match" - assert score_range.description == score_range_data.get("description", None), "Description should match" - assert score_range.odds_path.ratio == score_range_data.get("odds_path", {}).get( - "ratio", None - ), "Odds path should match" - assert score_range.odds_path.evidence == score_range_data.get("odds_path", {}).get( - "evidence", None - ), "Odds path should match" - - -@pytest.mark.parametrize( - "score_range_data", - [TEST_ZEIBERG_CALIBRATION_SCORE_SET_BS3_STRONG_RANGE, TEST_ZEIBERG_CALIBRATION_SCORE_SET_PS3_STRONG_RANGE], -) -@pytest.mark.parametrize( - "ScoreRangeModel", - [ZeibergCalibrationScoreRange, ZeibergCalibrationScoreRangeCreate, ZeibergCalibrationScoreRangeModify], -) -def test_score_range_zeiberg_calibration_valid_range(ScoreRangeModel, score_range_data): - score_range = ScoreRangeModel(**score_range_data) - assert score_range.label == score_range_data["label"], "Label should match" - assert score_range.classification == score_range_data["classification"], "Classification should match" - assert score_range.range[0] == score_range_data["range"][0], "Range should match" - assert score_range.range[1] == score_range_data["range"][1], "Range should match" - assert score_range.description == score_range_data.get("description", None), "Description should match" - assert score_range.positive_likelihood_ratio == score_range_data.get( - "positive_likelihood_ratio", None - ), "Odds path should match" - - -@pytest.mark.parametrize( - "ScoreRangeModel", - [ - ScoreRange, - ScoreRangeModify, - ScoreRangeCreate, - BrnichScoreRange, - BrnichScoreRangeCreate, - BrnichScoreRangeModify, - ], -) -def test_score_range_invalid_range_length(ScoreRangeModel): - invalid_data = { - "label": "Test Range", - "classification": "normal", - "range": [0.0], - } - with pytest.raises( - ValidationError, - match=r".*1 validation error for {}\nrange.1\n Field required.*".format(ScoreRangeModel.__name__), - ): - ScoreRangeModel(**invalid_data) - - -@pytest.mark.parametrize( - "ScoreRangeModel", - [ - ZeibergCalibrationScoreRange, - ZeibergCalibrationScoreRangeCreate, - ZeibergCalibrationScoreRangeModify, - ], -) -def test_zeiberg_calibration_score_range_invalid_range_length(ScoreRangeModel): - invalid_data = { - "label": "Test Range", - "classification": "normal", - "range": [0.0], - "evidence_strength": 1, - } - with pytest.raises( - ValidationError, - match=r".*1 validation error for {}\nrange.1\n Field required.*".format(ScoreRangeModel.__name__), - ): - ScoreRangeModel(**invalid_data) - - -@pytest.mark.parametrize( - "ScoreRangeModel", - [ - ScoreRange, - ScoreRangeModify, - ScoreRangeCreate, - BrnichScoreRange, - BrnichScoreRangeCreate, - BrnichScoreRangeModify, - ZeibergCalibrationScoreRange, - ZeibergCalibrationScoreRangeCreate, - ZeibergCalibrationScoreRangeModify, - ], -) -def test_score_range_base_invalid_range_order(ScoreRangeModel): - invalid_data = { - "label": "Test Range", - "classification": "normal", - "range": [1.0, 0.0], - } - with pytest.raises( - ValidationError, - match=r".*The lower bound of the score range may not be larger than the upper bound\..*", - ): - ScoreRangeModel(**invalid_data) - - -@pytest.mark.parametrize( - "ScoreRangeModel", - [ - ScoreRange, - ScoreRangeModify, - ScoreRangeCreate, - BrnichScoreRange, - BrnichScoreRangeCreate, - BrnichScoreRangeModify, - ZeibergCalibrationScoreRange, - ZeibergCalibrationScoreRangeCreate, - ZeibergCalibrationScoreRangeModify, - ], -) -def test_score_range_base_equal_bounds(ScoreRangeModel): - invalid_data = { - "label": "Test Range", - "classification": "normal", - "range": [1.0, 1.0], - } - with pytest.raises( - ValidationError, - match=r".*The lower and upper bound of the score range may not be the same\..*", - ): - ScoreRangeModel(**invalid_data) - - -@pytest.mark.parametrize( - "ScoreRangeModel", - [ - ScoreRange, - ScoreRangeModify, - ScoreRangeCreate, - BrnichScoreRange, - BrnichScoreRangeCreate, - BrnichScoreRangeModify, - ], -) -@pytest.mark.parametrize( - "range_value", - [ - [None, 1.0], - [1.0, None], - ], -) -def test_score_range_may_not_include_infinity(ScoreRangeModel, range_value): - invalid_data = { - "label": "Test Range", - "classification": "normal", - "range": range_value, - "inclusive_lower_bound": True, - "inclusive_upper_bound": True, - } - with pytest.raises( - ValidationError, - match=r".*An inclusive lower bound may not include negative infinity\..*|An inclusive upper bound may not include positive infinity\..*", - ): - ScoreRangeModel(**invalid_data) - - -@pytest.mark.parametrize( - "ScoreRangeModel", - [ - ZeibergCalibrationScoreRange, - ZeibergCalibrationScoreRangeCreate, - ZeibergCalibrationScoreRangeModify, - ], -) -@pytest.mark.parametrize( - "range_value", - [ - [None, 1.0], - [1.0, None], - ], -) -def test_zeiberg_calibration_score_range_may_not_include_infinity(ScoreRangeModel, range_value): - invalid_data = { - "label": "Test Range", - "classification": "normal", - "range": range_value, - "inclusive_lower_bound": True, - "inclusive_upper_bound": True, - "evidence_strength": 1, - } - with pytest.raises( - ValidationError, - match=r".*An inclusive lower bound may not include negative infinity\..*|An inclusive upper bound may not include positive infinity\..*", - ): - ScoreRangeModel(**invalid_data) - - -@pytest.mark.parametrize( - "classification,evidence_strength,should_raise", - [ - ("normal", 1, True), # Should raise: normal with positive evidence_strength - ("normal", 0, True), # Should not raise: normal with zero evidence_strength - ("normal", -1, False), # Should not raise: normal with negative evidence_strength - ("abnormal", -1, True), # Should raise: abnormal with negative evidence_strength - ("abnormal", 0, True), # Should not raise: abnormal with zero evidence_strength - ("abnormal", 1, False), # Should not raise: abnormal with positive evidence_strength - ("not_specified", 1, False), # Should not raise: not_specified with positive evidence_strength - ("not_specified", -1, False), # Should not raise: not_specified with negative evidence_strength - ], -) -@pytest.mark.parametrize( - "ScoreRangeModel", - [ZeibergCalibrationScoreRange, ZeibergCalibrationScoreRangeCreate, ZeibergCalibrationScoreRangeModify], -) -def test_zeiberg_calibration_evidence_strength_cardinality_must_agree_with_classification( - classification, evidence_strength, should_raise, ScoreRangeModel -): - invalid_data = deepcopy(TEST_ZEIBERG_CALIBRATION_SCORE_SET_BS3_STRONG_RANGE) - invalid_data["classification"] = classification - invalid_data["evidence_strength"] = evidence_strength - if should_raise: - with pytest.raises(ValidationError) as excinfo: - ScoreRangeModel(**invalid_data) - if classification == "normal": - assert "The evidence strength for a normal range must be negative." in str(excinfo.value) - elif classification == "abnormal": - assert "The evidence strength for an abnormal range must be positive." in str(excinfo.value) - else: - obj = ScoreRangeModel(**invalid_data) - assert obj.evidence_strength == evidence_strength - - -### ScoreRanges Tests ### - - -@pytest.mark.parametrize( - "score_ranges_data", - [TEST_SCORE_SET_RANGE, TEST_SCORE_SET_RANGE_WITH_SOURCE], -) -@pytest.mark.parametrize("ScoreRangesModel", [ScoreRanges, ScoreRangesCreate, ScoreRangesModify]) -def test_score_ranges_base_valid_range(ScoreRangesModel, score_ranges_data): - score_ranges = ScoreRangesModel(**score_ranges_data) - - matched_source = ( - None - if score_ranges_data.get("source", None) is None - else [source.model_dump() for source in score_ranges.source] - ) - assert score_ranges.ranges is not None, "Ranges should not be None" - assert matched_source == score_ranges_data.get("source", None), "Source should match" - - -@pytest.mark.parametrize( - "score_ranges_data", - [TEST_BRNICH_SCORE_SET_RANGE, TEST_BRNICH_SCORE_SET_RANGE_WITH_SOURCE], -) -@pytest.mark.parametrize( - "ScoreRangesModel", - [ - BrnichScoreRanges, - BrnichScoreRangesCreate, - BrnichScoreRangesModify, - InvestigatorScoreRanges, - InvestigatorScoreRangesCreate, - InvestigatorScoreRangesModify, - ScottScoreRanges, - ScottScoreRangesCreate, - ScottScoreRangesModify, - ], -) -def test_score_ranges_brnich_valid_range(ScoreRangesModel, score_ranges_data): - score_ranges = ScoreRangesModel(**score_ranges_data) - matched_source = ( - None - if score_ranges_data.get("source", None) is None - else [source.model_dump() for source in score_ranges.source] - ) - matched_odds_source = ( - None - if score_ranges_data.get("odds_path_source", None) is None - else [odds.model_dump() for odds in score_ranges.odds_path_source] - ) - assert score_ranges.ranges is not None, "Ranges should not be None" - assert score_ranges.baseline_score == TEST_BASELINE_SCORE, "Baseline score should match" - assert score_ranges.research_use_only is False, "Research use only should be False for invesitigator provided" - assert score_ranges.title == score_ranges_data.get("title", None), "Title should match" - assert matched_odds_source == score_ranges_data.get("odds_path_source", None), "Odds path source should match" - assert matched_source == score_ranges_data.get("source", None), "Source should match" - - -@pytest.mark.parametrize( - "score_ranges_data", - [TEST_ZEIBERG_CALIBRATION_SCORE_SET_RANGE, TEST_ZEIBERG_CALIBRATION_SCORE_SET_RANGE_WITH_SOURCE], -) -@pytest.mark.parametrize( - "ScoreRangesModel", - [ZeibergCalibrationScoreRanges, ZeibergCalibrationScoreRangesCreate, ZeibergCalibrationScoreRangesModify], -) -def test_score_ranges_zeiberg_calibration_valid_range(ScoreRangesModel, score_ranges_data): - score_ranges = ScoreRangesModel(**score_ranges_data) - matched_source = ( - None - if score_ranges_data.get("source", None) is None - else [source.model_dump() for source in score_ranges.source] - ) - assert score_ranges.ranges is not None, "Ranges should not be None" - assert score_ranges.prior_probability_pathogenicity == score_ranges_data.get( - "prior_probability_pathogenicity", None - ), "Prior probability pathogenicity should match" - assert score_ranges.parameter_sets is not None, "Parameter sets should not be None" - assert score_ranges.research_use_only is True, "Research use only should be True for zeiberg calibration" - assert score_ranges.title == score_ranges_data.get("title", None), "Title should match" - assert matched_source == score_ranges_data.get("source", None), "Source should match" - - -@pytest.mark.parametrize( - "ScoreRangesModel, ScoreRangeModel", - [ - (ScoreRanges, ScoreRange), - (ScoreRangesCreate, ScoreRangeCreate), - (ScoreRangesModify, ScoreRangeModify), - (BrnichScoreRanges, BrnichScoreRange), - (BrnichScoreRangesCreate, BrnichScoreRangeCreate), - (BrnichScoreRangesModify, BrnichScoreRangeModify), - (InvestigatorScoreRanges, BrnichScoreRange), - (InvestigatorScoreRangesCreate, BrnichScoreRangeCreate), - (InvestigatorScoreRangesModify, BrnichScoreRangeModify), - (ScottScoreRanges, BrnichScoreRange), - (ScottScoreRangesCreate, BrnichScoreRangeCreate), - (ScottScoreRangesModify, BrnichScoreRangeModify), - ], -) -def test_score_ranges_ranges_may_not_overlap(ScoreRangesModel, ScoreRangeModel): - range_test = ScoreRangeModel(label="Range 1", classification="abnormal", range=[0.0, 2.0]) - range_check = ScoreRangeModel(label="Range 2", classification="abnormal", range=[1.0, 3.0]) - invalid_data = { - "ranges": [ - range_test, - range_check, - ] - } - with pytest.raises( - ValidationError, - match=rf".*Score ranges may not overlap; `{range_test.label}` \(\({range_test.range[0]}, {range_test.range[1]}\)\) overlaps with `{range_check.label}` \(\({range_check.range[0]}, {range_check.range[1]}\)\).*", - ): - ScoreRangesModel(**invalid_data) - - -@pytest.mark.parametrize( - "ScoreRangesModel, ScoreRangeModel", - [ - (ScoreRanges, ScoreRange), - (ScoreRangesCreate, ScoreRangeCreate), - (ScoreRangesModify, ScoreRangeModify), - (BrnichScoreRanges, BrnichScoreRange), - (BrnichScoreRangesCreate, BrnichScoreRangeCreate), - (BrnichScoreRangesModify, BrnichScoreRangeModify), - (InvestigatorScoreRanges, BrnichScoreRange), - (InvestigatorScoreRangesCreate, BrnichScoreRangeCreate), - (InvestigatorScoreRangesModify, BrnichScoreRangeModify), - (ScottScoreRanges, BrnichScoreRange), - (ScottScoreRangesCreate, BrnichScoreRangeCreate), - (ScottScoreRangesModify, BrnichScoreRangeModify), - ], -) -def test_score_ranges_ranges_may_not_overlap_via_inclusive_bounds(ScoreRangesModel, ScoreRangeModel): - range_test = ScoreRangeModel( - label="Range 1", - classification="abnormal", - range=[0.0, 2.0], - inclusive_lower_bound=True, - inclusive_upper_bound=True, - ) - range_check = ScoreRangeModel( - label="Range 2", - classification="abnormal", - range=[2.0, 3.0], - inclusive_lower_bound=True, - inclusive_upper_bound=True, - ) - invalid_data = { - "ranges": [ - range_test, - range_check, - ] - } - with pytest.raises( - ValidationError, - match=rf".*Score ranges may not overlap; `{range_test.label}` \(\({range_test.range[0]}, {range_test.range[1]}\)\) overlaps with `{range_check.label}` \(\({range_check.range[0]}, {range_check.range[1]}\)\).*", - ): - ScoreRangesModel(**invalid_data) - - -@pytest.mark.parametrize( - "ScoreRangesModel, ScoreRangeModel", - [ - (ScoreRanges, ScoreRange), - (ScoreRangesCreate, ScoreRangeCreate), - (ScoreRangesModify, ScoreRangeModify), - (BrnichScoreRanges, BrnichScoreRange), - (BrnichScoreRangesCreate, BrnichScoreRangeCreate), - (BrnichScoreRangesModify, BrnichScoreRangeModify), - (InvestigatorScoreRanges, BrnichScoreRange), - (InvestigatorScoreRangesCreate, BrnichScoreRangeCreate), - (InvestigatorScoreRangesModify, BrnichScoreRangeModify), - (ScottScoreRanges, BrnichScoreRange), - (ScottScoreRangesCreate, BrnichScoreRangeCreate), - (ScottScoreRangesModify, BrnichScoreRangeModify), - ], -) -@pytest.mark.parametrize( - "range_value1, range_value2, orientation", - [ - ([0.0, 2.0], [2.0, 3.0], True), - ([0.0, 2.0], [2.0, 3.0], False), - ], -) -def test_score_ranges_ranges_boundaries_may_be_adjacent( - ScoreRangesModel, ScoreRangeModel, range_value1, range_value2, orientation -): - range_test = ScoreRangeModel( - label="Range 1", - classification="abnormal", - range=range_value1, - inclusive_lower_bound=orientation, - inclusive_upper_bound=not orientation, - ) - range_check = ScoreRangeModel( - label="Range 2", - classification="abnormal", - range=range_value2, - inclusive_lower_bound=orientation, - inclusive_upper_bound=not orientation, - ) - valid_data = { - "title": "Test Ranges", - "research_use_only": False, - "ranges": [ - range_test, - range_check, - ], - } - - ScoreRangesModel(**valid_data) - - -@pytest.mark.parametrize( - "ScoreRangesModel, ScoreRangeModel", - [ - (ZeibergCalibrationScoreRanges, ZeibergCalibrationScoreRange), - (ZeibergCalibrationScoreRangesCreate, ZeibergCalibrationScoreRangeCreate), - (ZeibergCalibrationScoreRangesModify, ZeibergCalibrationScoreRangeModify), - ], -) -def test_score_ranges_zeiberg_calibration_ranges_may_not_overlap(ScoreRangesModel, ScoreRangeModel): - range_test = ScoreRangeModel(label="Range 1", classification="abnormal", range=[0.0, 2.0], evidence_strength=2) - range_check = ScoreRangeModel(label="Range 2", classification="abnormal", range=[1.0, 3.0], evidence_strength=3) - invalid_data = { - "ranges": [ - range_test, - range_check, - ] - } - with pytest.raises( - ValidationError, - match=rf".*Score ranges may not overlap; `{range_test.label}` \(\({range_test.range[0]}, {range_test.range[1]}\)\) overlaps with `{range_check.label}` \(\({range_check.range[0]}, {range_check.range[1]}\)\).*", - ): - ScoreRangesModel(**invalid_data) - - -@pytest.mark.parametrize( - "ScoreRangesModel, ScoreRangeModel", - [ - (ZeibergCalibrationScoreRanges, ZeibergCalibrationScoreRange), - (ZeibergCalibrationScoreRangesCreate, ZeibergCalibrationScoreRangeCreate), - (ZeibergCalibrationScoreRangesModify, ZeibergCalibrationScoreRangeModify), - ], -) -def test_score_ranges_zeiberg_calibration_ranges_may_not_overlap_via_inclusive_bounds( - ScoreRangesModel, ScoreRangeModel -): - range_test = ScoreRangeModel( - label="Range 1", - classification="abnormal", - range=[0.0, 2.0], - evidence_strength=2, - inclusive_lower_bound=True, - inclusive_upper_bound=True, - ) - range_check = ScoreRangeModel( - label="Range 2", - classification="abnormal", - range=[2.0, 3.0], - evidence_strength=3, - inclusive_lower_bound=True, - inclusive_upper_bound=True, - ) - invalid_data = { - "ranges": [ - range_test, - range_check, - ] - } - with pytest.raises( - ValidationError, - match=rf".*Score ranges may not overlap; `{range_test.label}` \(\({range_test.range[0]}, {range_test.range[1]}\)\) overlaps with `{range_check.label}` \(\({range_check.range[0]}, {range_check.range[1]}\)\).*", - ): - ScoreRangesModel(**invalid_data) - - -@pytest.mark.parametrize( - "ScoreRangesModel, ScoreRangeModel", - [ - (ZeibergCalibrationScoreRanges, ZeibergCalibrationScoreRange), - (ZeibergCalibrationScoreRangesCreate, ZeibergCalibrationScoreRangeCreate), - (ZeibergCalibrationScoreRangesModify, ZeibergCalibrationScoreRangeModify), - ], -) -@pytest.mark.parametrize( - "range_value1, range_value2, orientation", - [ - ([0.0, 2.0], [2.0, 3.0], True), - ([0.0, 2.0], [2.0, 3.0], False), - ], -) -def test_score_ranges_zeiberg_calibration_ranges_boundaries_may_be_adjacent( - ScoreRangesModel, ScoreRangeModel, range_value1, range_value2, orientation -): - range_test = ScoreRangeModel( - label="Range 1", - classification="abnormal", - range=range_value1, - evidence_strength=2, - inclusive_lower_bound=orientation, - inclusive_upper_bound=not orientation, - ) - range_check = ScoreRangeModel( - label="Range 2", - classification="abnormal", - range=range_value2, - evidence_strength=3, - inclusive_lower_bound=orientation, - inclusive_upper_bound=not orientation, - ) - valid_data = { - "ranges": [ - range_test, - range_check, - ] - } - - ScoreRangesModel(**valid_data) - - -@pytest.mark.skip("Not applicable currently. Baseline score may be provided on its own.") -@pytest.mark.parametrize( - "ScoreRangesModel", - [ - BrnichScoreRanges, - BrnichScoreRangesCreate, - BrnichScoreRangesModify, - InvestigatorScoreRanges, - InvestigatorScoreRangesCreate, - InvestigatorScoreRangesModify, - ScottScoreRanges, - ScottScoreRangesCreate, - ScottScoreRangesModify, - ], -) -def test_score_ranges_brnich_normal_classification_exists_if_baseline_score_provided(ScoreRangesModel): - invalid_data = deepcopy(TEST_BRNICH_SCORE_SET_RANGE) - invalid_data["ranges"].remove(TEST_BRNICH_SCORE_SET_NORMAL_RANGE) - with pytest.raises( - ValidationError, - match=r".*A baseline score has been provided, but no normal classification range exists.*", - ): - ScoreRangesModel(**invalid_data) - - -@pytest.mark.parametrize( - "ScoreRangesModel", - [ - BrnichScoreRanges, - BrnichScoreRangesCreate, - BrnichScoreRangesModify, - InvestigatorScoreRanges, - InvestigatorScoreRangesCreate, - InvestigatorScoreRangesModify, - ScottScoreRanges, - ScottScoreRangesCreate, - ScottScoreRangesModify, - ], -) -def test_score_ranges_brnich_baseline_score_within_normal_range(ScoreRangesModel): - baseline_score = 50.0 - invalid_data = deepcopy(TEST_BRNICH_SCORE_SET_RANGE) - invalid_data["baselineScore"] = baseline_score - with pytest.raises( - ValidationError, - match=r".*The provided baseline score of {} is not within any of the provided normal ranges\. This score should be within a normal range\..*".format( - baseline_score - ), - ): - ScoreRangesModel(**invalid_data) - - -@pytest.mark.skip("Not applicable currently. Baseline score is not required if a normal range exists.") -@pytest.mark.parametrize( - "ScoreRangesModel", - [ - BrnichScoreRanges, - BrnichScoreRangesCreate, - BrnichScoreRangesModify, - InvestigatorScoreRanges, - InvestigatorScoreRangesCreate, - InvestigatorScoreRangesModify, - ScottScoreRanges, - ScottScoreRangesCreate, - ScottScoreRangesModify, - ], -) -def test_score_ranges_brnich_baseline_type_score_provided_if_normal_range_exists(ScoreRangesModel): - invalid_data = deepcopy(TEST_BRNICH_SCORE_SET_RANGE) - invalid_data["baselineScore"] = None - with pytest.raises( - ValidationError, - match=r".*A normal range has been provided, but no baseline type score has been provided.*", - ): - ScoreRangesModel(**invalid_data) - - -### ScoreSetRanges Tests ### - - -@pytest.mark.parametrize( - "score_set_ranges_data", - [ - TEST_SCORE_SET_RANGES_ONLY_SCOTT, - TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, - TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, - ], -) -@pytest.mark.parametrize("ScoreSetRangesModel", [ScoreSetRanges, ScoreSetRangesCreate, ScoreSetRangesModify]) -def test_score_set_ranges_valid_range(ScoreSetRangesModel, score_set_ranges_data): - score_set_ranges = ScoreSetRangesModel(**score_set_ranges_data) - assert isinstance(score_set_ranges, ScoreSetRangesModel), "ScoreSetRangesModel instantiation failed" - # Ensure a ranges property exists. Data values are checked elsewhere in more detail. - for attr_name in score_set_ranges.model_fields_set: - if attr_name == "record_type": - continue - range_definition = getattr(score_set_ranges, attr_name) - # Only check for .ranges if the attribute has that property - assert range_definition.ranges - - -@pytest.mark.parametrize( - "ScoreSetRangesModel", - [ - ScoreSetRanges, - ScoreSetRangesCreate, - ScoreSetRangesModify, - ], -) -@pytest.mark.parametrize( - "score_set_ranges_data", - [ - TEST_SCORE_SET_RANGES_ONLY_SCOTT, - TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, - ], -) -def test_score_set_ranges_may_not_include_duplicate_labels(ScoreSetRangesModel, score_set_ranges_data): - # Add a duplicate label to the ranges - score_set_ranges_data = deepcopy(score_set_ranges_data) - range_values = score_set_ranges_data[list(score_set_ranges_data.keys())[0]]["ranges"] - for range_value in range_values: - range_value["label"] = "duplicated_label" - - with pytest.raises( - ValidationError, - match=r".*Detected repeated label\(s\): duplicated_label\. Range labels must be unique\..*", - ): - ScoreSetRangesModel(**score_set_ranges_data) - - -@pytest.mark.parametrize( - "ScoreSetRangesModel", - [ - ScoreSetRanges, - ScoreSetRangesCreate, - ScoreSetRangesModify, - ], -) -def test_score_set_ranges_may_include_duplicate_labels_in_different_range_definitions(ScoreSetRangesModel): - # Add a duplicate label across all schemas - score_set_ranges_data = deepcopy(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT) - for key in score_set_ranges_data: - range_schema = score_set_ranges_data[key] - range_schema["ranges"][0]["label"] = "duplicated_label" - - ScoreSetRangesModel(**score_set_ranges_data) diff --git a/tests/view_models/test_score_set.py b/tests/view_models/test_score_set.py index a74e4d79..754b8657 100644 --- a/tests/view_models/test_score_set.py +++ b/tests/view_models/test_score_set.py @@ -10,14 +10,13 @@ EXTRA_USER, SAVED_PUBMED_PUBLICATION, TEST_BIORXIV_IDENTIFIER, + TEST_BRNICH_SCORE_CALIBRATION, TEST_CROSSREF_IDENTIFIER, TEST_MINIMAL_ACC_SCORESET, TEST_MINIMAL_SEQ_SCORESET, TEST_MINIMAL_SEQ_SCORESET_RESPONSE, + TEST_PATHOGENICITY_SCORE_CALIBRATION, TEST_PUBMED_IDENTIFIER, - TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT, - TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED, - TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION, VALID_EXPERIMENT_URN, VALID_SCORE_SET_URN, VALID_TMP_URN, @@ -231,65 +230,31 @@ def test_cannot_create_score_set_with_an_empty_method(): assert "methodText" in str(exc_info.value) -@pytest.mark.parametrize("publication_key", ["primary_publication_identifiers", "secondary_publication_identifiers"]) -def test_can_create_score_set_with_investigator_provided_score_range(publication_key): - score_set_test = TEST_MINIMAL_SEQ_SCORESET.copy() - score_set_test["score_ranges"] = deepcopy(TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED) - score_set_test[publication_key] = [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}] - - ScoreSetModify(**score_set_test) - - -def test_cannot_create_score_set_with_investigator_provided_score_range_if_odds_path_source_not_in_score_set_publications(): - score_set_test = TEST_MINIMAL_SEQ_SCORESET.copy() - score_set_test["score_ranges"] = deepcopy(TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED) - - with pytest.raises( - ValueError, - match=r".*Odds path source publication at index {} is not defined in score set publications.*".format(0), - ): - ScoreSetModify(**score_set_test) - - -def test_cannot_create_score_set_with_investigator_provided_score_range_if_source_not_in_score_set_publications(): +@pytest.mark.parametrize( + "calibration", [deepcopy(TEST_BRNICH_SCORE_CALIBRATION), deepcopy(TEST_PATHOGENICITY_SCORE_CALIBRATION)] +) +def test_can_create_score_set_with_complete_and_valid_provided_calibrations(calibration): score_set_test = TEST_MINIMAL_SEQ_SCORESET.copy() - score_set_test["score_ranges"] = deepcopy(TEST_SCORE_SET_RANGES_ONLY_INVESTIGATOR_PROVIDED) - score_set_test["score_ranges"]["investigator_provided"]["odds_path_source"] = None - - with pytest.raises( - ValueError, - match=r".*Score range source publication at index {} is not defined in score set publications.*".format(0), - ): - ScoreSetModify(**score_set_test) - + score_set_test["experiment_urn"] = VALID_EXPERIMENT_URN + score_set_test["score_calibrations"] = [calibration] -@pytest.mark.parametrize("publication_key", ["primary_publication_identifiers", "secondary_publication_identifiers"]) -def test_can_create_score_set_with_zeiberg_calibration_score_range(publication_key): - score_set_test = TEST_MINIMAL_SEQ_SCORESET.copy() - score_set_test["score_ranges"] = deepcopy(TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION) - score_set_test[publication_key] = [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}] + score_set = ScoreSetCreate.model_validate(score_set_test) - ScoreSetModify(**score_set_test) + assert len(score_set.score_calibrations) == 1 -def test_cannot_create_score_set_with_zeiberg_calibration_score_range_if_source_not_in_score_set_publications(): +def test_can_create_score_set_with_multiple_valid_calibrations(): score_set_test = TEST_MINIMAL_SEQ_SCORESET.copy() - score_set_test["score_ranges"] = deepcopy(TEST_SCORE_SET_RANGES_ONLY_ZEIBERG_CALIBRATION) - - with pytest.raises( - ValueError, - match=r".*Score range source publication at index {} is not defined in score set publications.*".format(0), - ): - ScoreSetModify(**score_set_test) - + score_set_test["experiment_urn"] = VALID_EXPERIMENT_URN + score_set_test["score_calibrations"] = [ + deepcopy(TEST_BRNICH_SCORE_CALIBRATION), + deepcopy(TEST_BRNICH_SCORE_CALIBRATION), + deepcopy(TEST_PATHOGENICITY_SCORE_CALIBRATION), + ] -@pytest.mark.parametrize("publication_key", ["primary_publication_identifiers", "secondary_publication_identifiers"]) -def test_can_create_score_set_with_ranges_and_calibrations(publication_key): - score_set_test = TEST_MINIMAL_SEQ_SCORESET.copy() - score_set_test["score_ranges"] = deepcopy(TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT) - score_set_test[publication_key] = [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}] + score_set = ScoreSetCreate.model_validate(score_set_test) - ScoreSetModify(**score_set_test) + assert len(score_set.score_calibrations) == 3 def test_cannot_create_score_set_with_inconsistent_base_editor_flags(): @@ -400,7 +365,6 @@ def test_saved_score_set_synthetic_properties(): ("doi_identifiers", [{"identifier": TEST_CROSSREF_IDENTIFIER}]), ("license_id", EXTRA_LICENSE["id"]), ("target_genes", TEST_MINIMAL_SEQ_SCORESET["targetGenes"]), - ("score_ranges", TEST_SCORE_SET_RANGES_ALL_SCHEMAS_PRESENT), ], ) def test_score_set_update_all_optional(attribute, updated_data):