diff --git a/alembic/versions/adb481b7c60b_add_calibration_superseded_column.py b/alembic/versions/adb481b7c60b_add_calibration_superseded_column.py new file mode 100644 index 00000000..4aa552ce --- /dev/null +++ b/alembic/versions/adb481b7c60b_add_calibration_superseded_column.py @@ -0,0 +1,31 @@ +"""add_calibration_superseded_column + +Revision ID: adb481b7c60b +Revises: 398067c53257 +Create Date: 2026-06-01 16:45:35.507837 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'adb481b7c60b' +down_revision = 'a7f3c2e9b104' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('score_calibrations', sa.Column('replaces_id', sa.Integer(), nullable=True)) + op.create_index(op.f('ix_score_calibrations_replaces_id'), 'score_calibrations', ['replaces_id'], unique=False) + op.create_foreign_key(None, 'score_calibrations', 'score_calibrations', ['replaces_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'score_calibrations', type_='foreignkey') + op.drop_index(op.f('ix_score_calibrations_replaces_id'), table_name='score_calibrations') + op.drop_column('score_calibrations', 'replaces_id') + # ### end Alembic commands ### diff --git a/src/mavedb/lib/permissions/actions.py b/src/mavedb/lib/permissions/actions.py index f2402479..9c66bd6f 100644 --- a/src/mavedb/lib/permissions/actions.py +++ b/src/mavedb/lib/permissions/actions.py @@ -14,3 +14,4 @@ class Action(Enum): ADD_BADGE = "add_badge" CHANGE_RANK = "change_rank" ADD_CALIBRATION = "add_calibration" + SUPERSEDE_CALIBRATION = "supersede_calibration" diff --git a/src/mavedb/lib/permissions/score_calibration.py b/src/mavedb/lib/permissions/score_calibration.py index 86b404f5..d3951fa0 100644 --- a/src/mavedb/lib/permissions/score_calibration.py +++ b/src/mavedb/lib/permissions/score_calibration.py @@ -61,6 +61,7 @@ def has_permission(user_data: Optional[UserData], entity: ScoreCalibration, acti Action.DELETE: _handle_delete_action, Action.PUBLISH: _handle_publish_action, Action.CHANGE_RANK: _handle_change_rank_action, + Action.SUPERSEDE_CALIBRATION: _handle_supersede_action } if action not in handlers: @@ -293,3 +294,50 @@ def _handle_change_rank_action( user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) return deny_action_for_entity(entity, private, user_data, user_may_view_private, "score calibration") + + +def _handle_supersede_action( + user_data: Optional[UserData], + entity: ScoreCalibration, + user_is_owner: bool, + user_is_contributor_to_score_set: bool, + private: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle SUPERSEDE action permission check for ScoreCalibration entities. + + Only public score calibrations are allowed to be superseded. + Only superseding the calibration from the same score set. + Admin, owner, or investigator-provided plus a contributor to the score set can be the users who have permissions to + supersede a calibration. + + Args: + user_data: The user's authentication data. + entity: The ScoreCalibration entity being accessed. + user_is_owner: Whether the user created the ScoreCalibration. + user_is_contributor_to_score_set: Whether the user is a contributor to the associated ScoreSet. + private: Whether the ScoreCalibration is private. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow read access under the following conditions: + # Only public score calibrations are allowed to be superseded. + if private: + return PermissionResponse(False) + # Owners of the ScoreCalibration may supersede it. + if user_is_owner: + return PermissionResponse(True) + # System admins may supersede any ScoreCalibration. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # If the calibration is investigator provided, contributors to the ScoreCalibration may supersede it. + if entity.investigator_provided and user_is_contributor_to_score_set: + return PermissionResponse(True) + # Only non superseded calibration can be superseded. + if entity.superseding_calibration: + return PermissionResponse(False) + + return deny_action_for_entity(entity, private, user_data, False, "score calibration") \ No newline at end of file diff --git a/src/mavedb/lib/score_calibrations.py b/src/mavedb/lib/score_calibrations.py index 11e1b2e8..d4a037db 100644 --- a/src/mavedb/lib/score_calibrations.py +++ b/src/mavedb/lib/score_calibrations.py @@ -1,14 +1,18 @@ """Utilities for building and mutating score calibration ORM objects.""" +import logging import math from typing import Optional, Union import pandas as pd -from sqlalchemy import Float, and_, select -from sqlalchemy.orm import Session +from sqlalchemy import and_, Float, func, or_, select +from sqlalchemy.orm import contains_eager, joinedload, Query, selectinload, Session from mavedb.lib.acmg import find_or_create_acmg_classification from mavedb.lib.identifiers import find_or_create_publication_identifier +from mavedb.lib.logging.context import logging_context, save_to_logging_context +from mavedb.lib.permissions import Action, has_permission +from mavedb.lib.types.authentication import UserData from mavedb.lib.types.score_calibrations import ClassificationDict from mavedb.lib.validation.constants.general import ( calibration_class_column_name, @@ -17,14 +21,91 @@ hgvs_pro_column, ) from mavedb.lib.validation.utilities import inf_or_float +from mavedb.models.contributor import Contributor from mavedb.models.enums.score_calibration_relation import ScoreCalibrationRelation +from mavedb.models.publication_identifier import PublicationIdentifier from mavedb.models.score_calibration import ScoreCalibration from mavedb.models.score_calibration_functional_classification import ScoreCalibrationFunctionalClassification from mavedb.models.score_calibration_publication_identifier import ScoreCalibrationPublicationIdentifierAssociation from mavedb.models.score_set import ScoreSet +from mavedb.models.score_set_publication_identifier import ScoreSetPublicationIdentifierAssociation from mavedb.models.user import User from mavedb.models.variant import Variant from mavedb.view_models import score_calibration +from mavedb.view_models.score_calibration import ScoreCalibrationCreate +from mavedb.view_models.search import ScoreCalibrationsSearch + +logger = logging.getLogger(__name__) + +def build_search_score_calibrations_query_filter( + db: Session, query: Query[ScoreCalibration], owner_or_contributor: Optional[User], search: ScoreCalibrationsSearch +): + # Exclude score calibrations that have been publicly superseded (i.e., have at least one + # published superseding version). Uses NOT EXISTS instead of LEFT OUTER JOIN to + # avoid row multiplication when multiple superseding versions point to the same + # original via replaces_id (which has no uniqueness constraint). A LEFT JOIN would + # produce N rows per original, all counted against the LIMIT, causing paginated + # searches to return fewer unique score calibrations than requested. + query = query.filter(~ScoreCalibration.superseding_calibration.has(ScoreCalibration.private.is_(False))) + + if owner_or_contributor is not None: + query = query.filter( + or_( + ScoreCalibration.created_by_id == owner_or_contributor.id, + ScoreCalibration.score_set.has(ScoreSet.contributors.any(Contributor.orcid_id == owner_or_contributor.username)), + ) + ) + + if search.primary is not None: + query = query.filter(ScoreCalibration.primary == search.primary) + + if search.private is not None: + query = query.filter(ScoreCalibration.private == search.private) + + if search.text: + lower_search_text = search.text.lower().strip() + query = query.filter( + or_( + ScoreCalibration.urn.icontains(lower_search_text), + ScoreCalibration.title.icontains(lower_search_text), + ScoreCalibration.publication_identifiers.any( + func.lower(PublicationIdentifier.identifier).icontains(lower_search_text) + ), + ScoreCalibration.publication_identifiers.any( + func.lower(PublicationIdentifier.doi).icontains(lower_search_text) + ), + ScoreCalibration.publication_identifiers.any( + func.lower(PublicationIdentifier.abstract).icontains(lower_search_text) + ), + ScoreCalibration.publication_identifiers.any( + func.lower(PublicationIdentifier.title).icontains(lower_search_text) + ), + ScoreCalibration.publication_identifiers.any( + func.lower(PublicationIdentifier.publication_journal).icontains(lower_search_text) + ), + ScoreCalibration.publication_identifiers.any( + func.jsonb_path_exists( + PublicationIdentifier.authors, + f"""$[*].name ? (@ like_regex "{lower_search_text}" flag "i")""", + ) + ), + ) + ) + + if search.authors: + query = query.filter( + ScoreCalibration.publication_identifiers.any( + func.jsonb_path_query_array(PublicationIdentifier.authors, "$.name").op("?|")(search.authors) + ) + ) + + if search.research_use_only is not None: + if search.primary: + query = query.filter(ScoreCalibration.research_use_only.isnot(None)) + else: + query = query.filter(ScoreCalibration.research_use_only.is_(None)) + + return query def create_functional_classification( @@ -97,7 +178,7 @@ def create_functional_classification( async def _create_score_calibration( db: Session, calibration_create: score_calibration.ScoreCalibrationCreate, - user: User, + user_data: UserData, variant_classes: Optional[ClassificationDict] = None, containing_score_set: Optional[ScoreSet] = None, ) -> ScoreCalibration: @@ -176,6 +257,11 @@ async def _create_score_calibration( db.add(pub) db.flush() + if calibration_create.superseded_calibration_urn: + superseded_calibration = validate_superseded_score_calibration(db, calibration_create, containing_score_set, user_data) + else: + superseded_calibration = None + calibration = ScoreCalibration( **calibration_create.model_dump( by_alias=False, @@ -185,12 +271,14 @@ async def _create_score_calibration( "evidence_sources", "method_sources", "score_set_urn", + "superseded_calibration_urn" }, ), publication_identifier_associations=calibration_pub_assocs, functional_classifications=[], - created_by=user, - modified_by=user, + superseded_calibration=superseded_calibration, + created_by=user_data.user, + modified_by=user_data.user, ) # type: ignore[call-arg] if containing_score_set: @@ -210,7 +298,7 @@ async def _create_score_calibration( async def create_score_calibration_in_score_set( db: Session, calibration_create: score_calibration.ScoreCalibrationCreate, - user: User, + user_data: UserData, variant_classes: Optional[ClassificationDict] = None, ) -> ScoreCalibration: """ @@ -251,9 +339,9 @@ async def create_score_calibration_in_score_set( raise ValueError("score_set_urn must be provided to create a score calibration within a score set.") containing_score_set = db.query(ScoreSet).where(ScoreSet.urn == calibration_create.score_set_urn).one() - calibration = await _create_score_calibration(db, calibration_create, user, variant_classes, containing_score_set) + calibration = await _create_score_calibration(db, calibration_create, user_data, variant_classes, containing_score_set) - if user.username in [contributor.orcid_id for contributor in containing_score_set.contributors] + [ + if user_data.user.username in [contributor.orcid_id for contributor in containing_score_set.contributors] + [ containing_score_set.created_by.username, containing_score_set.modified_by.username, ]: @@ -268,7 +356,7 @@ async def create_score_calibration_in_score_set( async def create_score_calibration( db: Session, calibration_create: score_calibration.ScoreCalibrationCreate, - user: User, + user_data: UserData, variant_classes: Optional[ClassificationDict] = None, ) -> ScoreCalibration: """ @@ -321,13 +409,40 @@ async def create_score_calibration( raise ValueError("score_set_urn must not be provided to create a score calibration outside a score set.") created_calibration = await _create_score_calibration( - db, calibration_create, user, variant_classes, containing_score_set=None + db, calibration_create, user_data, variant_classes, containing_score_set=None ) db.add(created_calibration) return created_calibration +def find_superseded_score_calibration_tail( + score_calibration: ScoreCalibration, action: Optional["Action"] = None, user_data: Optional["UserData"] = None +) -> Optional[ScoreCalibration]: + while score_calibration.superseding_calibration is not None: + next_score_calibration_in_chain = score_calibration.superseding_calibration + + # If we were given a permission to check and the next score calibration in the chain does not have that permission, + # pretend like we have reached the end of the chain. Otherwise, continue to the next score calibration. + if action is not None and not has_permission(user_data, next_score_calibration_in_chain, action).permitted: + break + + score_calibration = next_score_calibration_in_chain + + # Handle unpublished superseding score calibration case. + # The score calibration has a published superseded score calibration but has not superseding score calibration. + if action is not None and not has_permission(user_data, score_calibration, action).permitted: + while score_calibration.superseded_calibration is not None: + next_score_calibration_in_chain = score_calibration.superseded_calibration + if has_permission(user_data, next_score_calibration_in_chain, action).permitted: + return next_score_calibration_in_chain + else: + score_calibration = next_score_calibration_in_chain + return None + + return score_calibration + + async def modify_score_calibration( db: Session, calibration: ScoreCalibration, @@ -486,6 +601,56 @@ async def modify_score_calibration( return calibration +def search_score_calibrations(db: Session, owner_or_contributor: Optional[User], search: ScoreCalibrationsSearch): + save_to_logging_context({"score_calibration_search_criteria": search.model_dump()}) + + query = db.query(ScoreCalibration) + query = build_search_score_calibrations_query_filter(db, query, owner_or_contributor, search) + + score_calibrations: list[ScoreCalibration] = ( + query.join(ScoreCalibration.score_set) + .options( + # Use selectinload for ALL relationships loaded via the main query. The presence of + # contains_eager disables SQLAlchemy's subquery-wrapping logic for the ENTIRE query, + # not just the relationships nested inside it. This means any joinedload that adds a + # LEFT OUTER JOIN to the main SQL query — even for many-to-one relationships — can + # corrupt the LIMIT clause by applying it to joined rows rather than unique score sets, + # causing fewer results than expected and suppressing the count query fallback. + # The only JOINs that should remain in the main query are the explicit experiment + # INNER JOIN (required by contains_eager) and the superseding score set LEFT OUTER JOIN + # added by the filter builder. + contains_eager(ScoreCalibration.score_set).options( + selectinload(ScoreSet.created_by), + selectinload(ScoreSet.modified_by), + selectinload(ScoreSet.doi_identifiers), + selectinload(ScoreSet.publication_identifier_associations).joinedload( + ScoreSetPublicationIdentifierAssociation.publication + ), + selectinload(ScoreSet.score_calibrations).options( + joinedload(ScoreCalibration.publication_identifier_associations).joinedload( + ScoreCalibrationPublicationIdentifierAssociation.publication + ), + ), + ), + selectinload(ScoreCalibration.created_by), + selectinload(ScoreCalibration.modified_by), + selectinload(ScoreCalibration.publication_identifier_associations).joinedload( + ScoreCalibrationPublicationIdentifierAssociation.publication + ), + ) + .order_by(ScoreSet.title) + .all() + ) + if not score_calibrations: + score_calibrations = [] + + num_score_calibrations = len(score_calibrations) + save_to_logging_context({"matching_resources": num_score_calibrations}) + logger.debug(msg=f"Score calibrations search yielded {len(score_calibrations)} matching resources.", extra=logging_context()) + + return {"score_calibrations": score_calibrations, "num_score_calibrations": num_score_calibrations} + + def publish_score_calibration(db: Session, calibration: ScoreCalibration, user: User) -> ScoreCalibration: """Publish a private ScoreCalibration, marking it as publicly accessible. @@ -513,13 +678,21 @@ def publish_score_calibration(db: Session, calibration: ScoreCalibration, user: This function adds the modified calibration to the session but does not commit; the caller is responsible for committing the transaction. """ - if not calibration.private: + if calibration.private is False: # It's possible None value raise ValueError("Calibration is already published.") calibration.private = False calibration.modified_by = user db.add(calibration) + + if ( + calibration.superseded_calibration + and calibration.superseded_calibration.primary + and not calibration.research_use_only + ): + promote_score_calibration_to_primary(db, calibration, user, force=True) + return calibration @@ -552,6 +725,7 @@ def promote_score_calibration_to_primary( - If the calibration is already primary. - If the calibration is research-use-only. - If the calibration is private. + - If the calibration is superseded. - If another primary calibration exists for the score set and force is False. Side Effects: @@ -571,6 +745,9 @@ def promote_score_calibration_to_primary( if calibration.private: raise ValueError("Cannot promote a private calibration to primary.") + if calibration.superseding_calibration: + raise ValueError("Cannot promote a superseded calibration to primary.") + existing_primary_calibrations = ( db.query(ScoreCalibration) .filter( @@ -652,6 +829,31 @@ def delete_score_calibration(db: Session, calibration: ScoreCalibration) -> None return None +def validate_superseded_score_calibration( + db: Session, + calibration_create: ScoreCalibrationCreate, + containing_score_set: ScoreSet, + user_data: Optional[UserData], +) -> Optional[ScoreCalibration]: + if not calibration_create.superseded_calibration_urn: + return None + + superseded_calibration = ( + db.query(ScoreCalibration) + .where(ScoreCalibration.urn == calibration_create.superseded_calibration_urn) + .one_or_none() + ) + + if superseded_calibration is None: + raise ValueError("Superseded calibration does not exist.") + if superseded_calibration.score_set_id != containing_score_set.id: + raise ValueError("Superseded score calibration is not from the same score set.") + if not has_permission(user_data, superseded_calibration, Action.SUPERSEDE_CALIBRATION).permitted: + raise ValueError("No access right to supersede this calibration.") + + return superseded_calibration + + def variants_for_functional_classification( db: Session, functional_classification: ScoreCalibrationFunctionalClassification, diff --git a/src/mavedb/lib/score_sets.py b/src/mavedb/lib/score_sets.py index 698bc515..91f7097d 100644 --- a/src/mavedb/lib/score_sets.py +++ b/src/mavedb/lib/score_sets.py @@ -6,8 +6,7 @@ import numpy as np import pandas as pd from pandas.testing import assert_index_equal -from sqlalchemy import and_, func, or_, select -from sqlalchemy.exc import IntegrityError +from sqlalchemy import Integer, and_, cast, func, or_, select from sqlalchemy.orm import Query, Session, aliased, contains_eager, joinedload, selectinload from mavedb.lib.exceptions import ValidationError @@ -21,6 +20,7 @@ ) from mavedb.lib.mave.utils import is_csv_null from mavedb.lib.permissions import Action, has_permission +from mavedb.lib.score_calibrations import find_superseded_score_calibration_tail from mavedb.lib.types.authentication import UserData from mavedb.lib.validation.constants.general import null_values_list from mavedb.models.contributor import Contributor @@ -48,6 +48,7 @@ from mavedb.models.uniprot_offset import UniprotOffset from mavedb.models.user import User from mavedb.models.variant import Variant +from mavedb.view_models import score_set from mavedb.view_models.search import ControlledKeywordFilterOption, ScoreSetsSearch if TYPE_CHECKING: @@ -308,6 +309,33 @@ def score_set_search_filter_options_from_counter(counter: Counter): return [{"value": value, "count": count} for value, count in counter.items()] +def enrich_score_set_with_num_score_calibrations( + item_update: ScoreSet, user_data: Optional[UserData] +) -> score_set.ScoreSet: + """ + Validate and update the number of score calibration in score set. The superseded score calibration is excluded. + Data structure: score_set{score_calibration_urns, num_score_calibrations} + """ + filter_superseded_score_calibration_tails = [ + find_superseded_score_calibration_tail(score_calibration, Action.READ, user_data) for score_calibration in item_update.score_calibrations + ] + filtered_score_calibration_urns = sorted( + { + score_calibration.urn + for score_calibration in filter_superseded_score_calibration_tails + if score_calibration is not None and score_calibration.urn is not None + } + ) + + updated_score_set = score_set.ScoreSet.model_validate(item_update).copy( + update={ + "num_score_calibrations": len(filtered_score_calibration_urns), + "score_calibration_urns": filtered_score_calibration_urns, + } + ) + return updated_score_set + + def fetch_score_set_search_filter_options( db: Session, requester: Optional[UserData], owner_or_contributor: Optional[User], search: ScoreSetsSearch ): @@ -338,13 +366,13 @@ def fetch_score_set_search_filter_options( # - Use parallelization (e.g., multiprocessing or concurrent.futures) for large datasets # - Pre-fetch or denormalize target/publication data in the DB query # - Profile and refactor nested attribute lookups to minimize Python overhead - for score_set in score_sets: + for ss in score_sets: # Check read permission for each score set, skip if no permission - if not has_permission(requester, score_set, Action.READ).permitted: + if not has_permission(requester, ss, Action.READ).permitted: continue # Target related options - for target in getattr(score_set, "target_genes", []): + for target in getattr(ss, "target_genes", []): category = getattr(target, "category", None) if category: target_category_counter[category] += 1 @@ -367,7 +395,7 @@ def fetch_score_set_search_filter_options( target_accession_counter[accession] += 1 # Publication related options - for publication_association in getattr(score_set, "publication_identifier_associations", []): + for publication_association in getattr(ss, "publication_identifier_associations", []): publication = getattr(publication_association, "publication", None) authors = getattr(publication, "authors", []) @@ -385,7 +413,7 @@ def fetch_score_set_search_filter_options( publication_journal_counter[journal] += 1 # Controlled keywords related options - for controlled_keyword in getattr(score_set.experiment, "keyword_objs", []): + for controlled_keyword in getattr(ss.experiment, "keyword_objs", []): keyword = getattr(controlled_keyword, "controlled_keyword", []) if not keyword: continue @@ -574,17 +602,453 @@ def get_current_mapped_variants_for_annotation(db: Session, score_set: ScoreSet) ) -def is_replaces_id_unique_violation(exc: IntegrityError) -> bool: +def get_score_set_variants_as_csv( + db: Session, + score_set: ScoreSet, + namespaces: List[str], + namespaced: Optional[bool] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + drop_na_columns: Optional[bool] = None, + include_custom_columns: Optional[bool] = True, + include_post_mapped_hgvs: Optional[bool] = False, +) -> str: + """ + Get the variant data from a score set as a CSV string. + + Parameters + __________ + db : Session + The database session to use. + score_set : ScoreSet + The score set to get the variants from. + namespaces : List[str] + The namespaces for data: "scores", "counts", "vep", "gnomad", "clingen", and/or + ClinVar-versioned namespaces of the form "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01" + for January 2024, which joins on db_name="ClinVar" and db_version="01_2024"). + namespaced: Optional[bool] = None + Whether namespace the columns or not. + start : int, optional + The index to start from. If None, starts from the beginning. + limit : int, optional + The maximum number of variants to return. If None, returns all variants. + drop_na_columns : bool, optional + Whether to drop columns that contain only NA values. Defaults to False. + include_custom_columns : bool, optional + Whether to include custom columns defined in the score set. Defaults to True. + include_post_mapped_hgvs : bool, optional + Whether to include post-mapped HGVS notations and VEP functional consequence in the output. Defaults to False. If True, the output will include + columns for post-mapped HGVS genomic (g.) and protein (p.) notations, and VEP functional consequence. + + Returns + _______ + str + The CSV string containing the variant data. + """ + assert type(score_set.dataset_columns) is dict + namespaced_score_set_columns: dict[str, list[str]] = { + "core": ["accession", "hgvs_nt", "hgvs_splice", "hgvs_pro"], + "mavedb": [], + } + if include_post_mapped_hgvs: + namespaced_score_set_columns["mavedb"].append("post_mapped_hgvs_g") + namespaced_score_set_columns["mavedb"].append("post_mapped_hgvs_p") + namespaced_score_set_columns["mavedb"].append("post_mapped_hgvs_c") + namespaced_score_set_columns["mavedb"].append("post_mapped_hgvs_at_assay_level") + namespaced_score_set_columns["mavedb"].append("post_mapped_vrs_digest") + for namespace in namespaces: + namespaced_score_set_columns[namespace] = [] + + if include_custom_columns: + if "scores" in namespaced_score_set_columns: + namespaced_score_set_columns["scores"] = [ + col for col in [str(x) for x in list(score_set.dataset_columns.get("score_columns", []))] + ] + if "counts" in namespaced_score_set_columns: + namespaced_score_set_columns["counts"] = [ + col for col in [str(x) for x in list(score_set.dataset_columns.get("count_columns", []))] + ] + elif "scores" in namespaced_score_set_columns: + namespaced_score_set_columns["scores"].append(REQUIRED_SCORE_COLUMN) + if "vep" in namespaced_score_set_columns: + namespaced_score_set_columns["vep"].append("vep_functional_consequence") + if "gnomad" in namespaced_score_set_columns: + namespaced_score_set_columns["gnomad"].append("gnomad_af") + if "clingen" in namespaced_score_set_columns: + namespaced_score_set_columns["clingen"].append("clingen_allele_id") + + # Parse ClinVar-versioned namespaces of the form "clinvar.YEAR_MONTH". + # The corresponding db_version stored in clinical_controls is "MONTH_YEAR". + clinvar_namespaces: dict[str, str] = {} # namespace -> db_version (MONTH_YEAR) + for ns in namespaces: + m = CLINVAR_NS_PATTERN.match(ns) + if m: + year, month = m.group(1), m.group(2) + db_version = f"{month}_{year}" + clinvar_namespaces[ns] = db_version + namespaced_score_set_columns[ns] = ["clinical_significance", "clinical_review_status"] + + need_mappings = ( + include_post_mapped_hgvs + or "clingen" in namespaces + or "vep" in namespaces + or "gnomad" in namespaces + or bool(clinvar_namespaces) + ) + need_gnomad = "gnomad" in namespaces + + variants: list[Variant] = [] + mappings: Optional[list[Optional[MappedVariant]]] = [] if need_mappings else None + gnomad_data: Optional[list[Optional[GnomADVariant]]] = [] if need_gnomad else None + + select_columns: list[Any] = [Variant] + if need_mappings: + select_columns.append(MappedVariant) + if need_gnomad: + select_columns.append(GnomADVariant) + + query = ( + select(*select_columns) + .where(Variant.score_set_id == score_set.id) + .order_by(cast(func.split_part(Variant.urn, "#", 2), Integer)) + ) + + if need_mappings: + query = query.join( + MappedVariant, + and_(Variant.id == MappedVariant.variant_id, MappedVariant.current.is_(True)), + isouter=True, + ) + + if need_gnomad: + query = query.join( + MappedVariant.gnomad_variants.of_type(GnomADVariant), + isouter=True, + ).where( + or_( + and_(GnomADVariant.db_name == "gnomAD", GnomADVariant.db_version == "v4.1"), + GnomADVariant.id.is_(None), + ) + ) + + if start: + query = query.offset(start) + if limit: + query = query.limit(limit) + + result = db.execute(query).all() + + for row in result: + variant = row[0] + variants.append(variant) + + if need_mappings and mappings is not None: + mappings.append(row[1]) + + if need_gnomad and gnomad_data is not None: + idx = 2 if need_mappings else 1 + gnomad_data.append(row[idx]) + + # For each ClinVar namespace, fetch a mapping from mapped_variant_id to ClinicalControl. + clinvar_data_map: dict[str, dict[int, Optional[ClinicalControl]]] = {} + if clinvar_namespaces and mappings is not None: + mv_ids = [m.id for m in mappings if m is not None] + for ns, db_version in clinvar_namespaces.items(): + mv_to_cc: dict[int, Optional[ClinicalControl]] = {} + if mv_ids: + aliased_cc = aliased(ClinicalControl) + cc_query = ( + select( + mapped_variants_clinical_controls_association_table.c.mapped_variant_id, + aliased_cc, + ) + .join( + aliased_cc, + mapped_variants_clinical_controls_association_table.c.clinical_control_id == aliased_cc.id, + ) + .where( + and_( + mapped_variants_clinical_controls_association_table.c.mapped_variant_id.in_(mv_ids), + aliased_cc.db_name == "ClinVar", + aliased_cc.db_version == db_version, + ) + ) + ) + for mv_id, cc in db.execute(cc_query).all(): + mv_to_cc[mv_id] = cc + clinvar_data_map[ns] = mv_to_cc + + # Build per-variant ClinVar lookup (list indexed in parallel with variants). + clinvar_per_variant: Optional[list[Optional[dict[str, Optional[ClinicalControl]]]]] = None + if clinvar_namespaces and mappings is not None: + clinvar_per_variant = [] + for mapping in mappings: + row_clinvar: dict[str, Optional[ClinicalControl]] = {} + for ns, mv_to_cc in clinvar_data_map.items(): + if mapping is not None and mapping.id is not None: + row_clinvar[ns] = mv_to_cc.get(mapping.id) + else: + row_clinvar[ns] = None + clinvar_per_variant.append(row_clinvar) + + rows_data = variants_to_csv_rows( + variants, + columns=namespaced_score_set_columns, + namespaced=namespaced, + mappings=mappings, + gnomad_data=gnomad_data, + clinvar_data_by_ns=clinvar_per_variant, + ) # type: ignore + + rows_columns = [] + for namespace, cols in namespaced_score_set_columns.items(): + for col in cols: + if CLINVAR_NS_PATTERN.match(namespace): + # ClinVar versioned namespaces always include the full namespace prefix + # to avoid column-name collisions when multiple versions are requested. + rows_columns.append(f"{namespace}.{col}") + elif namespaced and namespace not in ["core", "mavedb"]: + rows_columns.append(f"{namespace}.{col}") + elif namespaced and namespace == "mavedb": + rows_columns.append(f"mavedb.{col}") + else: + rows_columns.append(col) + + if drop_na_columns: + rows_data, rows_columns = drop_na_columns_from_csv_file_rows(rows_data, rows_columns) + + stream = io.StringIO() + writer = csv.DictWriter(stream, fieldnames=rows_columns, quoting=csv.QUOTE_MINIMAL) + writer.writeheader() + writer.writerows(rows_data) + return stream.getvalue() + + +def drop_na_columns_from_csv_file_rows( + rows_data: Iterable[dict[str, Any]], columns: list[str] +) -> tuple[list[dict[str, Any]], list[str]]: + """Process rows_data for downloadable CSV by removing empty columns.""" + # Convert map to list. + rows_data = list(rows_data) + columns_to_check = ["hgvs_nt", "hgvs_splice", "hgvs_pro"] + columns_to_remove = [] + + # Check if all values in a column are None or "NA" + for col in columns_to_check: + if all(validate_is_null(row[col]) for row in rows_data): + columns_to_remove.append(col) + for row in rows_data: + row.pop(col, None) # Remove column from each row + + # Remove these columns from the header list + columns = [col for col in columns if col not in columns_to_remove] + return rows_data, columns + + +null_values_re = re.compile(r"\s+|none|nan|na|undefined|n/a|null|nil", flags=re.IGNORECASE) + + +def is_null(value): + """Return True if a string represents a null value.""" + value = str(value).strip().lower() + return null_values_re.fullmatch(value) or not value + + +def variant_to_csv_row( + variant: Variant, + columns: dict[str, list[str]], + mapping: Optional[MappedVariant] = None, + gnomad_data: Optional[GnomADVariant] = None, + clinvar_data_by_ns: Optional[dict[str, Optional[ClinicalControl]]] = None, + namespaced: Optional[bool] = None, + na_rep="NA", +) -> dict[str, Any]: + """ + Format a variant into a containing the keys specified in `columns`. + + Parameters + ---------- + variant : variant.models.Variant + List of variants. + columns : list[str] + Columns to serialize. + namespaced: Optional[bool] = None + Namespace the columns or not. + mapping : variant.models.MappedVariant, optional + Mapped variant corresponding to the variant. + gnomad_data : variant.models.GnomADVariant, optional + gnomAD variant data corresponding to the variant. + clinvar_data_by_ns : dict[str, Optional[ClinicalControl]], optional + Per-variant ClinVar data keyed by namespace (e.g. "clinvar.2024_01"). + na_rep : str + String to represent null values. + + Returns + ------- + dict[str, Any] """ - Return True if the IntegrityError was caused by the unique constraint on score_set.replaces_id. + row: dict[str, Any] = {} + # Handle each column key explicitly as part of its namespace. + for column_key in columns.get("core", []): + if column_key == "hgvs_nt": + value = str(variant.hgvs_nt) + elif column_key == "hgvs_pro": + value = str(variant.hgvs_pro) + elif column_key == "hgvs_splice": + value = str(variant.hgvs_splice) + elif column_key == "accession": + value = str(variant.urn) + if is_null(value): + value = na_rep + + # export columns in the `core` namespace without a namespace + row[column_key] = value + for column_key in columns.get("mavedb", []): + if column_key == "post_mapped_hgvs_g": + value = str(mapping.hgvs_g) if mapping and mapping.hgvs_g else na_rep + if value == na_rep: + fallback_hgvs = ( + get_hgvs_from_post_mapped(mapping.post_mapped) if mapping and mapping.post_mapped else None + ) + if fallback_hgvs is not None and is_hgvs_g(fallback_hgvs): + value = fallback_hgvs + else: + value = na_rep + + elif column_key == "post_mapped_hgvs_p": + value = str(mapping.hgvs_p) if mapping and mapping.hgvs_p else na_rep + if value == na_rep: + fallback_hgvs = ( + get_hgvs_from_post_mapped(mapping.post_mapped) if mapping and mapping.post_mapped else None + ) + if fallback_hgvs is not None and is_hgvs_p(fallback_hgvs): + value = fallback_hgvs + else: + value = na_rep + + elif column_key == "post_mapped_hgvs_c": + value = str(mapping.hgvs_c) if mapping and mapping.hgvs_c else na_rep + elif column_key == "post_mapped_hgvs_at_assay_level": + value = str(mapping.hgvs_assay_level) if mapping and mapping.hgvs_assay_level else na_rep + elif column_key == "post_mapped_vrs_digest": + digest = get_digest_from_post_mapped(mapping.post_mapped) if mapping and mapping.post_mapped else None + value = digest if digest is not None else na_rep + if is_null(value): + value = na_rep + key = f"mavedb.{column_key}" if namespaced else column_key + row[key] = value + for column_key in columns.get("vep", []): + if column_key == "vep_functional_consequence": + vep_functional_consequence = mapping.vep_functional_consequence if mapping else None + if vep_functional_consequence is not None: + value = vep_functional_consequence + else: + value = na_rep + key = f"vep.{column_key}" if namespaced else column_key + row[key] = value + for column_key in columns.get("scores", []): + parent = variant.data.get("score_data") if variant.data else None + value = str(parent.get(column_key)) if parent else na_rep + if is_null(value): + value = na_rep + key = f"scores.{column_key}" if namespaced else column_key + row[key] = value + for column_key in columns.get("counts", []): + parent = variant.data.get("count_data") if variant.data else None + value = str(parent.get(column_key)) if parent else na_rep + if is_null(value): + value = na_rep + key = f"counts.{column_key}" if namespaced else column_key + row[key] = value + for column_key in columns.get("gnomad", []): + if column_key == "gnomad_af": + gnomad_af = gnomad_data.allele_frequency if gnomad_data else None + if gnomad_af is not None: + value = str(gnomad_af) + else: + value = na_rep + key = f"gnomad.{column_key}" if namespaced else column_key + row[key] = value + for column_key in columns.get("clingen", []): + if column_key == "clingen_allele_id": + clingen_allele_id = mapping.clingen_allele_id if mapping else None + if clingen_allele_id is not None: + value = str(clingen_allele_id) + else: + value = na_rep + key = f"clingen.{column_key}" if namespaced else column_key + row[key] = value + # Handle ClinVar-versioned namespaces (e.g. "clinvar.2024_01"). + # These always use the full "namespace.column" key regardless of the namespaced flag + # to avoid collisions when multiple versions are requested. + for namespace_key, namespace_cols in columns.items(): + if not CLINVAR_NS_PATTERN.match(namespace_key): + continue + clinvar_entry = (clinvar_data_by_ns or {}).get(namespace_key) + for column_key in namespace_cols: + if column_key == "clinical_significance": + value = str(clinvar_entry.clinical_significance) if clinvar_entry else na_rep + elif column_key == "clinical_review_status": + value = str(clinvar_entry.clinical_review_status) if clinvar_entry else na_rep + else: + value = na_rep + if is_null(value): + value = na_rep + row[f"{namespace_key}.{column_key}"] = value + return row + + +def variants_to_csv_rows( + variants: Sequence[Variant], + columns: dict[str, list[str]], + mappings: Optional[Sequence[Optional[MappedVariant]]] = None, + gnomad_data: Optional[Sequence[Optional[GnomADVariant]]] = None, + clinvar_data_by_ns: Optional[Sequence[Optional[dict[str, Optional[ClinicalControl]]]]] = None, + namespaced: Optional[bool] = None, + na_rep="NA", +) -> Iterable[dict[str, Any]]: """ - orig = getattr(exc, "orig", None) - if orig is None: - return False + Format each variant into a dictionary row containing the keys specified in `columns`. - diag = getattr(orig, "diag", None) - detail = getattr(diag, "detail", "") or "" - return "replaces_id" in detail + Parameters + ---------- + variants : list[variant.models.Variant] + List of variants. + columns : list[str] + Columns to serialize. + namespaced: Optional[bool] = None + Namespace the columns or not. + mappings : list[Optional[variant.models.MappedVariant]], optional + List of mapped variants corresponding to the variants. + gnomad_data : list[Optional[variant.models.GnomADVariant]], optional + List of gnomAD variant data corresponding to the variants. + clinvar_data_by_ns : list[Optional[dict[str, Optional[ClinicalControl]]]], optional + Per-variant ClinVar data keyed by namespace (e.g. "clinvar.2024_01"). + na_rep : str + String to represent null values. + + Returns + ------- + list[dict[str, Any]] + """ + n = len(variants) + _mappings: Sequence[Optional[MappedVariant]] = mappings if mappings is not None else [None] * n + _gnomad: Sequence[Optional[GnomADVariant]] = gnomad_data if gnomad_data is not None else [None] * n + _clinvar: Sequence[Optional[dict[str, Optional[ClinicalControl]]]] = ( + clinvar_data_by_ns if clinvar_data_by_ns is not None else [None] * n + ) + return map( + lambda t: variant_to_csv_row( + t[0], + columns, + mapping=t[1], + gnomad_data=t[2], + clinvar_data_by_ns=t[3], + namespaced=namespaced, + na_rep=na_rep, + ), + zip(variants, _mappings, _gnomad, _clinvar), + ) def find_meta_analyses_for_score_sets(db: Session, urns: list[str]) -> list[ScoreSet]: diff --git a/src/mavedb/lib/validation/urn_re.py b/src/mavedb/lib/validation/urn_re.py index dddc9d14..07ede00c 100644 --- a/src/mavedb/lib/validation/urn_re.py +++ b/src/mavedb/lib/validation/urn_re.py @@ -32,6 +32,11 @@ MAVEDB_COLLECTION_URN_PATTERN = r"urn:mavedb:collection-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" MAVEDB_COLLECTION_URN_RE = re.compile(MAVEDB_COLLECTION_URN_PATTERN) + +# Temp score calibration URN +MAVEDB_TMP_CALIBRATION_URN_PATTERN = r"tmp:mavedb.calibration-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" +MAVEDB_TMP_CALIBRATION_URN_RE = re.compile(MAVEDB_TMP_CALIBRATION_URN_PATTERN) + # Score calibration URN MAVEDB_CALIBRATION_URN_PATTERN = r"urn:mavedb:calibration-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" MAVEDB_CALIBRATION_URN_RE = re.compile(MAVEDB_CALIBRATION_URN_PATTERN) diff --git a/src/mavedb/lib/validation/utilities.py b/src/mavedb/lib/validation/utilities.py index 2712c886..ff615659 100644 --- a/src/mavedb/lib/validation/utilities.py +++ b/src/mavedb/lib/validation/utilities.py @@ -1,5 +1,6 @@ import math from random import choice +from sqlalchemy.exc import IntegrityError from typing import Optional, SupportsIndex, Union from mavehgvs.variant import Variant @@ -33,6 +34,19 @@ def is_null(value): # return null_values_re.fullmatch(value) or not value +def is_replaces_id_unique_violation(exc: IntegrityError) -> bool: + """ + Return True if the IntegrityError was caused by the unique constraint on score_set.replaces_id or + score_calibration.replaces_id. + """ + orig = getattr(exc, "orig", None) + if orig is None: + return False + + diag = getattr(orig, "diag", None) + return getattr(diag, "column_name", None) == "replaces_id" + + def generate_hgvs(prefix: str = "c") -> str: """ Generates a random hgvs string from a small sample. diff --git a/src/mavedb/models/score_calibration.py b/src/mavedb/models/score_calibration.py index 38ce1f28..bf8f717e 100644 --- a/src/mavedb/models/score_calibration.py +++ b/src/mavedb/models/score_calibration.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import date -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from sqlalchemy import Boolean, Column, Date, Float, ForeignKey, Integer, String from sqlalchemy.dialects.postgresql import JSONB @@ -60,6 +60,17 @@ class ScoreCalibration(Base): calibration_metadata = Column(JSONB(none_as_null=True), nullable=True) + superseded_calibration_id = Column("replaces_id", Integer, ForeignKey("score_calibrations.id"), index=True, nullable=True, unique=True) + superseded_calibration: Mapped[Optional["ScoreCalibration"]] = relationship( + "ScoreCalibration", + uselist=False, + foreign_keys="ScoreCalibration.superseded_calibration_id", + remote_side=[id], + ) + superseding_calibration: Mapped[Optional["ScoreCalibration"]] = relationship( + "ScoreCalibration", uselist=False, back_populates="superseded_calibration" + ) + created_by_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=False) created_by: Mapped["User"] = relationship("User", foreign_keys="ScoreCalibration.created_by_id") modified_by_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=False) diff --git a/src/mavedb/routers/score_calibrations.py b/src/mavedb/routers/score_calibrations.py index e8b31478..0eb47709 100644 --- a/src/mavedb/routers/score_calibrations.py +++ b/src/mavedb/routers/score_calibrations.py @@ -1,7 +1,8 @@ import logging -from typing import Optional +from typing import Any, Optional from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, selectinload from mavedb import deps @@ -21,18 +22,21 @@ modify_score_calibration, promote_score_calibration_to_primary, publish_score_calibration, + search_score_calibrations as _search_score_calibrations, variant_classification_df_to_dict, ) -from mavedb.lib.score_sets import csv_data_to_df +from mavedb.lib.score_sets import csv_data_to_df, enrich_score_set_with_num_score_calibrations from mavedb.lib.types.authentication import UserData from mavedb.lib.validation.constants.general import calibration_class_column_name, calibration_variant_column_name from mavedb.lib.validation.dataframe.calibration import validate_and_standardize_calibration_classes_dataframe from mavedb.lib.validation.exceptions import ValidationError +from mavedb.lib.validation.utilities import is_replaces_id_unique_violation from mavedb.models.score_calibration import ScoreCalibration from mavedb.models.score_calibration_functional_classification import ScoreCalibrationFunctionalClassification from mavedb.models.score_set import ScoreSet from mavedb.routers.shared import ACCESS_CONTROL_ERROR_RESPONSES, PUBLIC_ERROR_RESPONSES from mavedb.view_models import score_calibration +from mavedb.view_models.search import ScoreCalibrationsSearch, ScoreCalibrationsSearchResponse logger = logging.getLogger(__name__) @@ -132,13 +136,25 @@ async def get_score_calibrations_for_score_set( calibrations = ( db.query(ScoreCalibration) .filter(ScoreCalibration.score_set_id == score_set.id) + .filter(~ScoreCalibration.superseding_calibration.has(ScoreCalibration.private.is_(False))) .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) .all() ) - permitted_calibrations = [ + visible_calibrations = [ calibration for calibration in calibrations if has_permission(user_data, calibration, Action.READ).permitted ] + + superseded_ids = [sc.superseded_calibration_id for sc in visible_calibrations if + sc.superseded_calibration_id is not None] + + permitted_calibrations = [sc for sc in visible_calibrations if sc.id not in superseded_ids] + + # Solve Pydantic model validation error + for sc in permitted_calibrations: + sc.superseded_calibration = None + sc.superseding_calibration = None + if not permitted_calibrations: logger.debug("No score calibrations found for the requested score set", extra=logging_context()) raise HTTPException(status_code=404, detail="No score calibrations found for the requested score set") @@ -338,11 +354,22 @@ async def create_score_calibration_route( detail=[{"loc": [e.custom_loc or "classesFile"], "msg": str(e), "type": "value_error"}], ) - created_calibration = await create_score_calibration_in_score_set( - db, calibration, user_data.user, variant_classes if classes_file else None - ) + try: + created_calibration = await create_score_calibration_in_score_set( + db, calibration, user_data, variant_classes if classes_file else None + ) + db.commit() + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + except IntegrityError as e: + db.rollback() + if is_replaces_id_unique_violation(e): + raise HTTPException( + status_code=409, + detail="The requested score calibration has already been superseded.", + ) + raise - db.commit() db.refresh(created_calibration) return created_calibration @@ -598,6 +625,10 @@ async def promote_score_calibration_to_primary_route( logger.debug("Private score calibrations cannot be promoted to primary", extra=logging_context()) raise HTTPException(status_code=400, detail="Private score calibrations cannot be promoted to primary") + if item.superseding_calibration: + logger.debug("Superseded score calibrations cannot be promoted to primary", extra=logging_context()) + raise HTTPException(status_code=400, detail="Superseded score calibrations cannot be promoted to primary") + # We've already checked whether the item matching the calibration URN is primary, so this # will necessarily be a different calibration, if it exists. existing_primary_calibration = next((c for c in item.score_set.score_calibrations if c.primary), None) @@ -708,6 +739,31 @@ def publish_score_calibration_route( return item +@router.post( + "/me/search", + status_code=200, + summary="Search my calibrations", + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + response_model=ScoreCalibrationsSearchResponse, +) +def search_my_score_calibrations( + search: ScoreCalibrationsSearch, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user), +) -> Any: + """ + Search calibrations created by the current user. + """ + score_calibrations, num_score_calibrations = _search_score_calibrations(db, user_data.user, search).values() + enriched_score_calibrations = [] + for sc in score_calibrations: + enriched_score_calibration = enrich_score_set_with_num_score_calibrations(sc.score_set, user_data) + response_item = score_calibration.ScoreCalibration.model_validate(sc).copy(update={"score_calibration": enriched_score_calibration}) + enriched_score_calibrations.append(response_item) + + return {"score_calibrations": enriched_score_calibrations, "num_score_calibrations": num_score_calibrations} + + @router.get( "/{urn}/functional-classifications/{classification_id}/variants", response_model=score_calibration.FunctionalClassificationVariants, diff --git a/src/mavedb/routers/score_sets.py b/src/mavedb/routers/score_sets.py index aff71342..01812d32 100644 --- a/src/mavedb/routers/score_sets.py +++ b/src/mavedb/routers/score_sets.py @@ -67,7 +67,7 @@ fetch_score_set_search_filter_options, find_meta_analyses_for_experiment_sets, get_current_mapped_variants_for_annotation, - is_replaces_id_unique_violation, + get_score_set_variants_as_csv, refresh_variant_urns, ) from mavedb.lib.score_sets import ( @@ -82,6 +82,7 @@ generate_experiment_urn, generate_score_set_urn, ) +from mavedb.lib.validation.utilities import is_replaces_id_unique_violation from mavedb.lib.workflow.pipeline_factory import PipelineFactory from mavedb.models.clinical_control import ClinicalControl from mavedb.models.contributor import Contributor @@ -105,7 +106,7 @@ PUBLIC_ERROR_RESPONSES, ROUTER_BASE_PREFIX, ) -from mavedb.view_models import clinical_control, gnomad_variant, mapped_variant, score_set +from mavedb.view_models import clinical_control, gnomad_variant, mapped_variant, score_calibration, score_set from mavedb.view_models.contributor import ContributorCreate from mavedb.view_models.csv_namespace import AvailableCsvNamespace from mavedb.view_models.doi_identifier import DoiIdentifierCreate @@ -617,6 +618,13 @@ async def fetch_score_set_by_urn( # callers -- supersession lookup, publication -- receive the score set as it actually is. return item + visible_calibrations = [sc for sc in item.score_calibrations if has_permission(user, sc, Action.READ).permitted] + + superseded_ids = [sc.superseded_calibration_id for sc in visible_calibrations if sc.superseded_calibration_id is not None] + + available_calibrations = [sc for sc in visible_calibrations if sc.id not in superseded_ids] + + item.score_calibrations = available_calibrations def _score_set_response(item: ScoreSet, principal: Principal) -> score_set.ScoreSet: """ @@ -1712,6 +1720,57 @@ def get_score_set_annotated_variants_functional_study_result( ) +@router.get( + "/score-sets/{urn}/score-calibrations", + status_code=200, + response_model=list[score_calibration.ScoreCalibration], + responses={**ACCESS_CONTROL_ERROR_RESPONSES}, + summary="Get score calibrations from score set by URN", +) +def get_score_set_calibrations( + *, + urn: str, + can_supersede_only: Optional[bool] = None, + db: Session = Depends(deps.get_db), + user_data: Optional[UserData] = Depends(get_current_user), +) -> list[ScoreCalibration]: + """ + Return score calibrations from a score set, identified by URN. + """ + save_to_logging_context({"requested_resource": urn, "resource_property": "score-calibrations"}) + + score_set = db.query(ScoreSet).filter(ScoreSet.urn == urn).first() + if not score_set: + logger.info( + msg="Could not fetch the requested score calibrations; No such score set exist.", extra=logging_context() + ) + raise HTTPException(status_code=404, detail=f"score set with URN {urn} not found") + + assert_permission(user_data, score_set, Action.READ) + + score_calibrations_query = ( + db.query(ScoreCalibration) + .filter(ScoreSet.urn == urn) + .filter(ScoreSet.id == ScoreCalibration.score_set_id) + .filter(~ScoreCalibration.superseding_calibration.has()) + ) + + if can_supersede_only: + score_calibrations_query = score_calibrations_query.filter( + ScoreCalibration.private.is_(False) + ) + score_calibrations: list[ScoreCalibration] = score_calibrations_query.all() + + if not score_calibrations: + logger.info(msg="No score calibration is associated with the requested score set.", extra=logging_context()) + raise HTTPException( + status_code=404, + detail=f"No score calibration associated with score set URN {urn} was found", + ) + + return score_calibrations + + @router.post( "/score-sets/", response_model=score_set.ScoreSet, @@ -1920,7 +1979,7 @@ async def create_score_set( ) created_calibration_item = await create_score_calibration( - db, calibration_create, user_data.user, variant_classes=None + db, calibration_create, user_data, variant_classes=None ) created_calibration_item.investigator_provided = True # necessarily true on score set creation score_calibrations.append(created_calibration_item) diff --git a/src/mavedb/scripts/load_calibration_csv.py b/src/mavedb/scripts/load_calibration_csv.py index 066509e3..2a3c7b48 100644 --- a/src/mavedb/scripts/load_calibration_csv.py +++ b/src/mavedb/scripts/load_calibration_csv.py @@ -100,6 +100,7 @@ from mavedb.lib.acmg import ACMGCriterion, StrengthOfEvidenceProvided from mavedb.lib.oddspaths import oddspaths_evidence_strength_equivalent from mavedb.lib.score_calibrations import create_score_calibration_in_score_set +from mavedb.lib.types.authentication import UserData from mavedb.models import score_calibration from mavedb.models.enums.functional_classification import FunctionalClassification as FunctionalClassifcationOptions from mavedb.models.score_set import ScoreSet @@ -414,8 +415,9 @@ def main(db: Session, csv_path: str, delimiter: str, overwrite: bool, purge_publ system_user = db.query(User).filter(User.id == 1).one() calibration_user = score_set.created_by if calibration_is_investigator_provided else system_user + calibration_user_data = UserData(calibration_user, calibration_user.roles) new_calibration_object = asyncio.run( - create_score_calibration_in_score_set(db, created_score_calibration, calibration_user) + create_score_calibration_in_score_set(db, created_score_calibration, calibration_user_data) ) new_calibration_object.primary = primary new_calibration_object.private = False diff --git a/src/mavedb/scripts/load_excalibr_calibrations.py b/src/mavedb/scripts/load_excalibr_calibrations.py index 72f7ed2c..20875480 100644 --- a/src/mavedb/scripts/load_excalibr_calibrations.py +++ b/src/mavedb/scripts/load_excalibr_calibrations.py @@ -49,6 +49,7 @@ from sqlalchemy.orm import Session from mavedb.lib.score_calibrations import create_score_calibration_in_score_set +from mavedb.lib.types.authentication import UserData from mavedb.models.enums.functional_classification import FunctionalClassification as FunctionalClassificationOptions from mavedb.models.score_calibration import ScoreCalibration from mavedb.models.score_set import ScoreSet @@ -238,8 +239,9 @@ def main(db: Session, csv_path: str, dataset_map: str, overwrite: bool, remove: method_sources=[EXCALIBR_CALIBRATION_CITATION], ) + system_user_data = UserData(system_user, system_user.roles) new_calibration_object = asyncio.run( - create_score_calibration_in_score_set(db, score_calibration_create, system_user) + create_score_calibration_in_score_set(db, score_calibration_create, system_user_data) ) new_calibration_object.primary = False new_calibration_object.private = False diff --git a/src/mavedb/view_models/score_calibration.py b/src/mavedb/view_models/score_calibration.py index 857d4e49..860f0f18 100644 --- a/src/mavedb/view_models/score_calibration.py +++ b/src/mavedb/view_models/score_calibration.py @@ -10,6 +10,7 @@ from pydantic import Field, field_validator, model_validator from mavedb.lib.oddspaths import oddspaths_evidence_strength_equivalent +from mavedb.lib.validation import urn_re from mavedb.lib.validation.exceptions import ValidationError from mavedb.lib.validation.transform import ( transform_score_calibration_publication_identifiers, @@ -436,6 +437,7 @@ class ScoreCalibrationModify(ScoreCalibrationBase): evidence_sources: Sequence[PublicationIdentifierCreate] method_sources: Sequence[PublicationIdentifierCreate] + # TODO#668: Move this validator to ScoreCalibrationBase once legacy calibrations have been # backfilled with publication associations. Currently on the write model only so that existing # calibrations without publications can still be serialized for API read responses. @@ -477,11 +479,37 @@ def functional_classifications_require_publication_sources( class ScoreCalibrationCreate(ScoreCalibrationModify): """Model used to create a new score calibration.""" + superseded_calibration_urn: Optional[str] = None functional_classifications: Optional[Sequence[FunctionalClassificationCreate]] = None threshold_sources: Sequence[PublicationIdentifierCreate] evidence_sources: Sequence[PublicationIdentifierCreate] method_sources: Sequence[PublicationIdentifierCreate] + @field_validator("superseded_calibration_urn") + def validate_superseded_calibration_urn(cls, v: Optional[str]) -> Optional[str]: + if not v: + return None + + if urn_re.MAVEDB_CALIBRATION_URN_RE.fullmatch(v) is None: + if urn_re.MAVEDB_TMP_CALIBRATION_URN_RE.fullmatch(v) is None: + raise ValueError(f"'{v}' is not a valid calibration URN") + else: + raise ValueError("cannot supersede a private calibration - please edit it instead") + + return v + + +class ShorterScoreCalibration(BaseModel): + urn: str + title: str + record_type: str = None # type: ignore + + _record_type_factory = record_type_validator()(set_record_type) + + class Config: + from_attributes = True + arbitrary_types_allowed = True + class SavedScoreCalibration(ScoreCalibrationBase): """Persisted score calibration model (includes identifiers and source lists).""" @@ -501,6 +529,8 @@ class SavedScoreCalibration(ScoreCalibrationBase): threshold_sources: Sequence[SavedPublicationIdentifier] evidence_sources: Sequence[SavedPublicationIdentifier] method_sources: Sequence[SavedPublicationIdentifier] + superseded_calibration: Optional[ShorterScoreCalibration] = None + superseding_calibration: Optional[ShorterScoreCalibration] = None created_by: Optional[SavedUser] = None modified_by: Optional[SavedUser] = None diff --git a/src/mavedb/view_models/search.py b/src/mavedb/view_models/search.py index 22959599..df3e94f2 100644 --- a/src/mavedb/view_models/search.py +++ b/src/mavedb/view_models/search.py @@ -2,6 +2,7 @@ from mavedb.view_models.base.base import BaseModel from mavedb.view_models.score_set import ShortScoreSet +from mavedb.view_models.score_calibration import ScoreCalibration class ControlledKeywordSearch(BaseModel): @@ -20,6 +21,23 @@ class ExperimentsSearch(BaseModel): meta_analysis: Optional[bool] = None +class ScoreCalibrationsSearch(BaseModel): + authors: Optional[list[str]] = None + primary: Optional[bool] = None + private: Optional[bool] = None + publication_identifiers: Optional[list[str]] = None + research_use_only: Optional[bool] = None + text: Optional[str] = None + + +class ScoreCalibrationsSearchResponse(BaseModel): + score_calibrations: list[ScoreCalibration] + num_score_calibrations: int + + class Config: + from_attributes = True + + class ScoreSetsSearch(BaseModel): published: Optional[bool] = None targets: Optional[list[str]] = None diff --git a/tests/helpers/util/score_calibration.py b/tests/helpers/util/score_calibration.py index a535096c..900e3822 100644 --- a/tests/helpers/util/score_calibration.py +++ b/tests/helpers/util/score_calibration.py @@ -3,8 +3,8 @@ import jsonschema from mavedb.lib.score_calibrations import create_score_calibration_in_score_set +from mavedb.lib.types.authentication import UserData from mavedb.models.score_calibration import ScoreCalibration -from mavedb.models.user import User from mavedb.view_models.score_calibration import ScoreCalibrationCreate, ScoreCalibrationWithScoreSetUrn from tests.helpers.constants import TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED @@ -14,12 +14,12 @@ async def create_test_range_based_score_calibration_in_score_set( - db: "Session", score_set_urn: str, user: User + db: "Session", score_set_urn: str, user_data: UserData ) -> ScoreCalibration: calibration_create = ScoreCalibrationCreate( **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, score_set_urn=score_set_urn ) - created_score_calibration = await create_score_calibration_in_score_set(db, calibration_create, user) + created_score_calibration = await create_score_calibration_in_score_set(db, calibration_create, user_data) assert created_score_calibration is not None db.commit() diff --git a/tests/lib/test_score_calibrations.py b/tests/lib/test_score_calibrations.py index 9a5ba43c..4a927e64 100644 --- a/tests/lib/test_score_calibrations.py +++ b/tests/lib/test_score_calibrations.py @@ -23,6 +23,7 @@ variant_classification_df_to_dict, variants_for_functional_classification, ) +from mavedb.lib.types.authentication import UserData from mavedb.lib.validation.constants.general import ( calibration_class_column_name, calibration_variant_column_name, @@ -341,6 +342,7 @@ async def test_create_score_calibration_in_score_set_creates_score_calibration_w setup_lib_db_with_score_set, session ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) MockCalibrationCreate = create_model( "MockCalibrationCreate", @@ -349,9 +351,10 @@ async def test_create_score_calibration_in_score_set_creates_score_calibration_w evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) - calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), test_user) + calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), test_user_data) assert calibration is not None assert calibration.score_set == setup_lib_db_with_score_set @@ -361,6 +364,7 @@ async def test_create_score_calibration_in_score_set_investigator_provided_set_w setup_lib_db_with_score_set, session, mock_user ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) MockCalibrationCreate = create_model( "MockCalibrationCreate", @@ -369,9 +373,10 @@ async def test_create_score_calibration_in_score_set_investigator_provided_set_w evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) - calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), test_user) + calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), test_user_data) assert calibration is not None assert calibration.score_set == setup_lib_db_with_score_set assert calibration.created_by == test_user @@ -384,6 +389,7 @@ async def test_create_score_calibration_in_score_set_investigator_provided_set_w setup_lib_db_with_score_set, session ): extra_user = session.execute(select(User).where(User.username == EXTRA_USER["username"])).scalars().first() + extra_user_data = UserData(user=extra_user, active_roles=[]) add_contributor( session, @@ -401,9 +407,10 @@ async def test_create_score_calibration_in_score_set_investigator_provided_set_w evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) - calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), extra_user) + calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), extra_user_data) assert calibration is not None assert calibration.score_set == setup_lib_db_with_score_set assert calibration.created_by == extra_user @@ -422,12 +429,14 @@ async def test_create_score_calibration_in_score_set_investigator_provided_not_s evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) # invoke from a different user context extra_user = session.execute(select(User).where(User.username == EXTRA_USER["username"])).scalars().first() + extra_user_data = UserData(user=extra_user, active_roles=[]) - calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), extra_user) + calibration = await create_score_calibration_in_score_set(session, MockCalibrationCreate(), extra_user_data) assert calibration is not None assert calibration.score_set == setup_lib_db_with_score_set assert calibration.created_by == extra_user @@ -453,6 +462,7 @@ async def test_create_score_calibration_raises_value_error_when_score_set_urn_is @pytest.mark.asyncio async def test_create_score_calibration_creates_score_calibration_when_score_set_urn_is_absent(setup_lib_db, session): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) MockCalibrationCreate = create_model( "MockCalibrationCreate", @@ -461,9 +471,10 @@ async def test_create_score_calibration_creates_score_calibration_when_score_set evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) - calibration = await create_score_calibration(session, MockCalibrationCreate(), test_user) + calibration = await create_score_calibration(session, MockCalibrationCreate(), test_user_data) assert calibration is not None assert calibration.score_set is None @@ -496,6 +507,7 @@ async def test_create_score_calibration_propagates_errors_from_publication_find_ evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) with ( pytest.raises( @@ -549,9 +561,11 @@ async def test_create_score_calibration_publication_identifier_associations_crea evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) mocked_calibration = MockCalibrationCreate() setattr( @@ -560,7 +574,7 @@ async def test_create_score_calibration_publication_identifier_associations_crea [create_model("MockPublicationCreate", db_name=(str, "PubMed"), identifier=(str, TEST_PUBMED_IDENTIFIER))()], ) - calibration = await create_function_to_call(session, mocked_calibration, test_user) + calibration = await create_function_to_call(session, mocked_calibration, test_user_data) assert calibration.publication_identifier_associations[0].publication.db_name == "PubMed" assert calibration.publication_identifier_associations[0].publication.identifier == TEST_PUBMED_IDENTIFIER assert calibration.publication_identifier_associations[0].relation == expected_relation @@ -585,11 +599,13 @@ async def test_create_score_calibration_user_is_set_as_creator_and_modifier( evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) - calibration = await create_function_to_call(session, MockCalibrationCreate(), test_user) + calibration = await create_function_to_call(session, MockCalibrationCreate(), test_user_data) assert calibration.created_by == test_user assert calibration.modified_by == test_user @@ -630,8 +646,9 @@ async def test_create_score_calibration_fully_valid_calibration( calibration_create = ScoreCalibrationCreate(**valid_score_calibration_data, score_set_urn=score_set_urn) test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) - calibration = await create_function_to_call(session, calibration_create, test_user) + calibration = await create_function_to_call(session, calibration_create, test_user_data) for field in valid_score_calibration_data: # Sources are tested elsewhere. @@ -671,9 +688,10 @@ async def test_create_score_calibration_does_not_commit_transaction( **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, score_set_urn=score_set_urn ) test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) with mock.patch.object(session, "commit") as mock_commit: - await create_function_to_call(session, calibration_create, test_user) + await create_function_to_call(session, calibration_create, test_user_data) mock_commit.assert_not_called() @@ -721,9 +739,10 @@ async def test_modify_score_calibration_modifies_score_calibration_when_score_se setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -734,6 +753,7 @@ async def test_modify_score_calibration_modifies_score_calibration_when_score_se evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) modified_calibration = await modify_score_calibration( @@ -759,9 +779,10 @@ async def test_modify_score_calibration_clears_existing_publication_identifier_a setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -771,6 +792,7 @@ async def test_modify_score_calibration_clears_existing_publication_identifier_a evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) mocked_calibration = MockCalibrationModify() @@ -806,9 +828,10 @@ async def test_modify_score_calibration_publication_identifier_associations_crea expected_relation, ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -818,6 +841,7 @@ async def test_modify_score_calibration_publication_identifier_associations_crea evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) mocked_calibration = MockCalibrationModify() @@ -849,9 +873,10 @@ async def test_modify_score_calibration_retains_existing_publication_relationshi setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) calibration_publication_relations = existing_calibration.publication_identifier_associations.copy() @@ -917,9 +942,10 @@ async def test_modify_score_calibration_adds_new_publication_association( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -938,6 +964,7 @@ async def test_modify_score_calibration_adds_new_publication_association( evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) modified_calibration = await modify_score_calibration( @@ -967,9 +994,10 @@ async def test_modify_score_calibration_user_is_set_as_modifier( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -979,6 +1007,7 @@ async def test_modify_score_calibration_user_is_set_as_modifier( evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) modify_user = session.execute(select(User).where(User.id != test_user.id)).scalars().first() @@ -1018,8 +1047,9 @@ async def test_modify_score_calibration_new_score_set(setup_lib_db_with_score_se session.refresh(new_containing_score_set) test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, new_containing_score_set.urn, test_user + session, new_containing_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -1029,6 +1059,7 @@ async def test_modify_score_calibration_new_score_set(setup_lib_db_with_score_se evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) modified_calibration = await modify_score_calibration( @@ -1053,9 +1084,10 @@ async def test_modify_score_calibration_clears_functional_classifications( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) MockCalibrationModify = create_model( @@ -1065,6 +1097,7 @@ async def test_modify_score_calibration_clears_functional_classifications( evidence_sources=(list, []), method_sources=(list, []), functional_classifications=(list, []), + superseded_calibration_urn=(str | None, None), ) modified_calibration = await modify_score_calibration( @@ -1089,9 +1122,10 @@ async def test_modify_score_calibration_fully_valid_calibration( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) modify_calibration = ScoreCalibrationModify( @@ -1127,9 +1161,10 @@ async def test_modify_score_calibration_does_not_commit_transaction( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) modify_calibration = ScoreCalibrationModify( @@ -1161,9 +1196,10 @@ async def test_cannot_publish_already_published_calibration( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.private = False session.add(existing_calibration) @@ -1189,9 +1225,10 @@ async def test_publish_score_calibration_marks_calibration_public( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) assert existing_calibration.private is True @@ -1214,9 +1251,10 @@ async def test_publish_score_calibration_user_is_set_as_modifier( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) publish_user = session.execute(select(User).where(User.id != test_user.id)).scalars().first() @@ -1226,6 +1264,282 @@ async def test_publish_score_calibration_user_is_set_as_modifier( assert published_calibration.created_by == test_user +################################################################################ +# Tests for validate_superseded_score_calibration +################################################################################ + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +async def test_private_calibration_supersedes_a_published_calibration( + client, setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) + + existing_calibration = await create_test_range_based_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user_data + ) + assert existing_calibration.private is True + + published_calibration = publish_score_calibration(session, existing_calibration, test_user) + assert published_calibration.private is False + + superseding_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=published_calibration.score_set.urn, + superseded_calibration_urn=published_calibration.urn, + ) + + superseding_calibration = await create_score_calibration_in_score_set( + session, superseding_calibration_create, test_user_data + ) + + session.commit() + session.refresh(superseding_calibration) + + assert superseding_calibration.private is True + assert published_calibration.superseding_calibration.urn == superseding_calibration.urn + + score_set_db = session.execute(select(ScoreSet).where(ScoreSet.urn == superseding_calibration.score_set.urn)).scalars().first() + score_set_response = client.get(f"/api/v1/score-sets/{score_set_db.urn}") + score_set = score_set_response.json() + assert score_set_response.status_code == 200 + assert len(score_set["scoreCalibrations"]) == 1 + assert score_set["urn"] == superseding_calibration.score_set.urn + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +async def test_public_calibration_supersedes_a_published_calibration( + client, setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) + + existing_calibration = await create_test_range_based_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user_data + ) + assert existing_calibration.private is True + + published_first_calibration = publish_score_calibration(session, existing_calibration, test_user) + assert published_first_calibration.private is False + + superseding_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=published_first_calibration.score_set.urn, + superseded_calibration_urn=published_first_calibration.urn, + ) + + superseding_calibration = await create_score_calibration_in_score_set( + session, superseding_calibration_create, test_user_data + ) + published_second_calibration = publish_score_calibration(session, superseding_calibration, test_user) + assert published_second_calibration.private is False + session.commit() + session.refresh(published_second_calibration) + + assert published_first_calibration.superseding_calibration.urn == published_second_calibration.urn + + score_set_db = session.execute( + select(ScoreSet).where(ScoreSet.urn == published_second_calibration.score_set.urn)).scalars().first() + score_set_response = client.get(f"/api/v1/score-sets/{score_set_db.urn}") + score_set = score_set_response.json() + assert score_set_response.status_code == 200 + assert len(score_set["scoreCalibrations"]) == 1 + assert score_set["urn"] == published_second_calibration.score_set.urn + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +async def test_supersede_a_calibration_that_its_score_set_has_multiple_calibrations( + client, setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) + + existing_calibration_1 = await create_test_range_based_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user_data + ) + assert existing_calibration_1.private is True + existing_calibration_2 = await create_test_range_based_score_calibration_in_score_set( + session, existing_calibration_1.score_set.urn, test_user_data + ) + assert existing_calibration_2.private is True + + published_first_calibration = publish_score_calibration(session, existing_calibration_1, test_user) + assert published_first_calibration.private is False + + superseding_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=published_first_calibration.score_set.urn, + superseded_calibration_urn=published_first_calibration.urn, + ) + + superseding_calibration = await create_score_calibration_in_score_set( + session, superseding_calibration_create, test_user_data + ) + session.commit() + session.refresh(superseding_calibration) + + assert published_first_calibration.superseding_calibration.urn == superseding_calibration.urn + + score_set_db = session.execute( + select(ScoreSet).where(ScoreSet.urn == superseding_calibration.score_set.urn)).scalars().first() + score_set_response = client.get(f"/api/v1/score-sets/{score_set_db.urn}") + score_set = score_set_response.json() + assert score_set_response.status_code == 200 + assert len(score_set["scoreCalibrations"]) == 2 + cal_urns = {cal["urn"] for cal in score_set["scoreCalibrations"]} + + assert superseding_calibration.urn in cal_urns + assert existing_calibration_2.urn in cal_urns + assert published_first_calibration.urn not in cal_urns + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +async def test_cannot_supersede_none_exist_calibration( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) + + invalid_urn = "urn:mavedb:calibration-00000000-0000-0000-0000-000000000000" + + superseding_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=setup_lib_db_with_score_set.urn, + superseded_calibration_urn=invalid_urn, + ) + + with pytest.raises(ValueError, match="Superseded calibration does not exist."): + await create_score_calibration_in_score_set( + session, superseding_calibration_create, test_user_data + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +async def test_private_calibration_cannot_supersedes_a_private_calibration( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) + + existing_calibration = await create_test_range_based_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user_data + ) + assert existing_calibration.private is True + + superseding_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=existing_calibration.score_set.urn, + superseded_calibration_urn=existing_calibration.urn, + ) + + with pytest.raises(ValueError, match="Cannot supersede a private calibration. Please edit it instead."): + await create_score_calibration_in_score_set( + session, superseding_calibration_create, test_user_data + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ], + ], + indirect=["mock_publication_fetch"], +) +async def test_cannot_supersede_a_superseded_calibration( + setup_lib_db_with_score_set, session, mock_publication_fetch +): + test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) + + existing_calibration = await create_test_range_based_score_calibration_in_score_set( + session, setup_lib_db_with_score_set.urn, test_user_data + ) + assert existing_calibration.private is True + + published_calibration = publish_score_calibration(session, existing_calibration, test_user) + assert published_calibration.private is False + + superseding_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=published_calibration.score_set.urn, + superseded_calibration_urn=published_calibration.urn, + ) + + superseding_calibration = await create_score_calibration_in_score_set( + session, superseding_calibration_create, test_user_data + ) + + session.commit() + session.refresh(superseding_calibration) + + superseding_same_calibration_create = ScoreCalibrationCreate( + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + score_set_urn=published_calibration.score_set.urn, + superseded_calibration_urn=published_calibration.urn, + ) + + with pytest.raises(ValueError, match="Cannot supersede a superseded calibration. Please edit it instead."): + await create_score_calibration_in_score_set( + session, superseding_same_calibration_create, test_user_data + ) + + + ################################################################################ # Tests for promote_score_calibration_to_primary ################################################################################ @@ -1244,9 +1558,10 @@ async def test_publish_score_calibration_user_is_set_as_modifier( ) async def test_cannot_promote_already_primary_calibration(setup_lib_db_with_score_set, session, mock_publication_fetch): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.primary = True session.add(existing_calibration) @@ -1272,9 +1587,10 @@ async def test_cannot_promote_calibration_when_calibration_is_research_use_only( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.research_use_only = True session.add(existing_calibration) @@ -1300,9 +1616,10 @@ async def test_cannot_promote_calibration_when_calibration_is_private( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.private = True session.add(existing_calibration) @@ -1328,12 +1645,13 @@ async def test_cannot_promote_calibration_when_another_primary_exists( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_primary_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_primary_calibration.private = False existing_primary_calibration.primary = True @@ -1365,9 +1683,10 @@ async def test_promote_score_calibration_to_primary_marks_calibration_primary( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.private = False existing_calibration.primary = False @@ -1394,12 +1713,13 @@ async def test_promote_score_calibration_to_primary_demotes_existing_primary_whe setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_primary_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_primary_calibration.private = False existing_primary_calibration.primary = True @@ -1437,9 +1757,10 @@ async def test_promote_score_calibration_to_primary_user_is_set_as_modifier( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.private = False existing_calibration.primary = False @@ -1471,12 +1792,13 @@ async def test_promote_score_calibration_to_primary_demoted_existing_primary_use setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_primary_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_primary_calibration.private = False existing_primary_calibration.primary = True @@ -1517,9 +1839,10 @@ async def test_promote_score_calibration_to_primary_does_not_commit_transaction( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.private = False existing_calibration.primary = False @@ -1550,9 +1873,10 @@ async def test_promote_score_calibration_to_primary_does_not_commit_transaction( ) async def test_cannot_demote_non_primary_calibration(setup_lib_db_with_score_set, session, mock_publication_fetch): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.primary = False session.add(existing_calibration) @@ -1578,9 +1902,10 @@ async def test_demote_score_calibration_from_primary_marks_calibration_non_prima setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.primary = True session.add(existing_calibration) @@ -1607,9 +1932,10 @@ async def test_demote_score_calibration_from_primary_user_is_set_as_modifier( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.primary = True session.add(existing_calibration) @@ -1638,9 +1964,10 @@ async def test_demote_score_calibration_from_primary_does_not_commit_transaction setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.primary = True session.add(existing_calibration) @@ -1670,9 +1997,10 @@ async def test_demote_score_calibration_from_primary_does_not_commit_transaction ) async def test_cannot_delete_primary_calibration(setup_lib_db_with_score_set, session, mock_publication_fetch): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) existing_calibration.primary = True session.add(existing_calibration) @@ -1698,9 +2026,10 @@ async def test_delete_score_calibration_deletes_calibration( session, setup_lib_db_with_score_set, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) calibration_id = existing_calibration.id @@ -1726,9 +2055,10 @@ async def test_delete_score_calibration_does_not_commit_transaction( setup_lib_db_with_score_set, session, mock_publication_fetch ): test_user = session.execute(select(User)).scalars().first() + test_user_data = UserData(user=test_user, active_roles=[]) existing_calibration = await create_test_range_based_score_calibration_in_score_set( - session, setup_lib_db_with_score_set.urn, test_user + session, setup_lib_db_with_score_set.urn, test_user_data ) with mock.patch.object(session, "commit") as mock_commit: diff --git a/tests/routers/test_score_calibrations.py b/tests/routers/test_score_calibrations.py index fe1aeba7..cb80299f 100644 --- a/tests/routers/test_score_calibrations.py +++ b/tests/routers/test_score_calibrations.py @@ -459,23 +459,19 @@ def test_admin_user_can_get_score_calibration_when_public( assert calibration_response["private"] is False -########################################################### -# GET /score-calibrations/score-set/{score_set_urn} -########################################################### - - +@pytest.mark.asyncio @pytest.mark.parametrize( "mock_publication_fetch", [ [ - {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, - {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, ] ], indirect=["mock_publication_fetch"], ) -def test_cannot_get_score_calibrations_for_score_set_when_none_exist( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +async def test_anonymous_user_cannot_get_superseding_score_calibration_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -485,45 +481,28 @@ def test_cannot_get_score_calibrations_for_score_set_when_none_exist( experiment["urn"], data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") - - assert response.status_code == 404 - error = response.json() - assert "No score calibrations found for the requested score set" in error["detail"] - + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } -@pytest.mark.parametrize( - "mock_publication_fetch", - [ - [ - {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, - {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, - ] - ], - indirect=["mock_publication_fetch"], -) -def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_private( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides -): - experiment = create_experiment(client) - score_set = create_seq_score_set_with_mapped_variants( + superseding_calibration = create_test_score_calibration_in_score_set_via_client( client, - session, - data_provider, - experiment["urn"], - data_files / "scores.csv", - ) - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + score_set["urn"], + deepcamelize(superseding_calibration_data), ) with DependencyOverrider(anonymous_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{superseding_calibration['urn']}") assert response.status_code == 404 error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert f"score calibration with URN '{superseding_calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -536,7 +515,7 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_private ], indirect=["mock_publication_fetch"], ) -def test_other_user_cannot_get_score_calibrations_for_score_set_when_private( +def test_other_user_cannot_get_superseding_score_calibration_when_private( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -547,16 +526,29 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_private( experiment["urn"], data_files / "scores.csv", ) - create_test_score_calibration_in_score_set_via_client( + calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + with DependencyOverrider(extra_user_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{superseding_calibration['urn']}") assert response.status_code == 404 error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert f"score calibration with URN '{superseding_calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -569,8 +561,8 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_private( ], indirect=["mock_publication_fetch"], ) -def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_published_but_calibrations_private( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +def test_creating_user_can_get_superseding_score_calibration_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -580,19 +572,29 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_publish experiment["urn"], data_files / "scores.csv", ) - create_test_score_calibration_in_score_set_via_client( + calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) - with patch.object(ArqRedis, "enqueue_job", return_value=None): - score_set = publish_score_set(client, score_set["urn"]) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - with DependencyOverrider(anonymous_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } - assert response.status_code == 404 - error = response.json() - assert "No score calibrations found for the requested score set" in error["detail"] + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + response = client.get(f"/api/v1/score-calibrations/{superseding_calibration['urn']}") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == superseding_calibration["urn"] + assert calibration_response["private"] is True @pytest.mark.parametrize( @@ -605,7 +607,7 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_publish ], indirect=["mock_publication_fetch"], ) -def test_other_user_cannot_get_score_calibrations_for_score_set_when_published_but_calibrations_private( +def test_contributing_user_can_get_superseding_score_calibration_when_private_and_investigator_provided( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -616,53 +618,38 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_published_b experiment["urn"], data_files / "scores.csv", ) - create_test_score_calibration_in_score_set_via_client( + calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - with patch.object(ArqRedis, "enqueue_job", return_value=None): - score_set = publish_score_set(client, score_set["urn"]) - - with DependencyOverrider(extra_user_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") - - assert response.status_code == 404 - error = response.json() - assert "No score calibrations found for the requested score set" in error["detail"] - + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } -@pytest.mark.parametrize( - "mock_publication_fetch", - [ - [ - {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, - {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, - ] - ], - indirect=["mock_publication_fetch"], -) -def test_creating_user_can_get_score_calibrations_for_score_set_when_private( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files -): - experiment = create_experiment(client) - score_set = create_seq_score_set_with_mapped_variants( + superseding_calibration = create_test_score_calibration_in_score_set_via_client( client, - session, - data_provider, - experiment["urn"], - data_files / "scores.csv", + score_set["urn"], + deepcamelize(superseding_calibration_data), ) - calibration = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + + add_contributor( + session, + score_set["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], ) - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/{superseding_calibration['urn']}") assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 1 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is True + calibration_response = response.json() + assert calibration_response["urn"] == superseding_calibration["urn"] + assert calibration_response["private"] is True @pytest.mark.parametrize( @@ -675,7 +662,7 @@ def test_creating_user_can_get_score_calibrations_for_score_set_when_private( ], indirect=["mock_publication_fetch"], ) -def test_contributing_user_can_get_investigator_provided_score_calibrations_for_score_set_when_private( +def test_contributing_user_cannot_get_superseding_score_calibration_when_private_and_not_investigator_provided( client, setup_router_db, mock_publication_fetch, @@ -694,14 +681,22 @@ def test_contributing_user_can_get_investigator_provided_score_calibrations_for_ data_files / "scores.csv", ) - with DependencyOverrider(admin_app_overrides): - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - - investigator_calibration = create_test_score_calibration_in_score_set_via_client( + calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + with DependencyOverrider(admin_app_overrides): + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) add_contributor( session, @@ -713,13 +708,11 @@ def test_contributing_user_can_get_investigator_provided_score_calibrations_for_ ) with DependencyOverrider(extra_user_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{superseding_calibration['urn']}") - assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 1 - assert calibrations_response[0]["urn"] == investigator_calibration["urn"] - assert calibrations_response[0]["private"] is True + assert response.status_code == 404 + error = response.json() + assert f"score calibration with URN '{superseding_calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -732,7 +725,7 @@ def test_contributing_user_can_get_investigator_provided_score_calibrations_for_ ], indirect=["mock_publication_fetch"], ) -def test_admin_user_can_get_score_calibrations_for_score_set_when_private( +def test_admin_user_can_get_superseding_score_calibration_when_private( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides ): experiment = create_experiment(client) @@ -746,28 +739,38 @@ def test_admin_user_can_get_score_calibrations_for_score_set_when_private( calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) with DependencyOverrider(admin_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{superseding_calibration['urn']}") assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 1 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is True + calibration_response = response.json() + assert calibration_response["urn"] == superseding_calibration["urn"] + assert calibration_response["private"] is True +@pytest.mark.asyncio @pytest.mark.parametrize( "mock_publication_fetch", [ [ - {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, - {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + {"dbName": "PubMed", "identifier": f"{TEST_PUBMED_IDENTIFIER}"}, + {"dbName": "bioRxiv", "identifier": f"{TEST_BIORXIV_IDENTIFIER}"}, ] ], indirect=["mock_publication_fetch"], ) -def test_anonymous_user_can_get_score_calibrations_for_score_set_when_public( +async def test_anonymous_user_can_get_superseding_score_calibration_when_public( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides ): experiment = create_experiment(client) @@ -781,25 +784,27 @@ def test_anonymous_user_can_get_score_calibrations_for_score_set_when_public( calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that will remain private. The anonymous user should not see this one - calibration = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - - publish_test_score_calibration_via_client(client, calibration["urn"]) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } - with patch.object(ArqRedis, "enqueue_job", return_value=None): - score_set = publish_score_set(client, score_set["urn"]) + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) with DependencyOverrider(anonymous_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}") assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 1 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is False + calibration_response = response.json() + assert calibration_response["urn"] == published_superseding_calibration["urn"] + assert calibration_response["private"] is False @pytest.mark.parametrize( @@ -812,7 +817,7 @@ def test_anonymous_user_can_get_score_calibrations_for_score_set_when_public( ], indirect=["mock_publication_fetch"], ) -def test_other_user_can_get_score_calibrations_for_score_set_when_public( +def test_other_user_can_get_superseding_score_calibration_when_public( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -826,25 +831,27 @@ def test_other_user_can_get_score_calibrations_for_score_set_when_public( calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that will remain private. The other user should not see this one - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - - publish_test_score_calibration_via_client(client, calibration["urn"]) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } - with patch.object(ArqRedis, "enqueue_job", return_value=None): - score_set = publish_score_set(client, score_set["urn"]) + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) with DependencyOverrider(extra_user_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}") assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 1 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is False + calibration_response = response.json() + assert calibration_response["urn"] == published_superseding_calibration["urn"] + assert calibration_response["private"] is False @pytest.mark.parametrize( @@ -857,8 +864,8 @@ def test_other_user_can_get_score_calibrations_for_score_set_when_public( ], indirect=["mock_publication_fetch"], ) -def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_calibrations_public_score_set_private( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +def test_creating_user_can_get_superseding_score_calibration_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -871,20 +878,26 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_calibra calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that will remain private. The anonymous user should not see this one - calibration = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } - publish_test_score_calibration_via_client(client, calibration["urn"]) + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) - with DependencyOverrider(anonymous_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}") - assert response.status_code == 404 - error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == published_superseding_calibration["urn"] + assert calibration_response["private"] is False @pytest.mark.parametrize( @@ -897,7 +910,7 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_calibra ], indirect=["mock_publication_fetch"], ) -def test_other_user_cannot_get_score_calibrations_for_score_set_when_calibrations_public_score_set_private( +def test_contributing_user_can_get_superseding_score_calibration_when_public( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -911,20 +924,36 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_calibration calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that will remain private. The other user should not see this one - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) - publish_test_score_calibration_via_client(client, calibration["urn"]) + add_contributor( + session, + score_set["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) with DependencyOverrider(extra_user_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + response = client.get(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}") - assert response.status_code == 404 - error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == published_superseding_calibration["urn"] + assert calibration_response["private"] is False @pytest.mark.parametrize( @@ -937,8 +966,8 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_calibration ], indirect=["mock_publication_fetch"], ) -def test_creating_user_can_get_score_calibrations_for_score_set_when_public( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +def test_admin_user_can_get_superseding_score_calibration_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -951,20 +980,32 @@ def test_creating_user_can_get_score_calibrations_for_score_set_when_public( calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) - publish_test_score_calibration_via_client(client, calibration["urn"]) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that is private. The creating user should see this one too - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + with DependencyOverrider(admin_app_overrides): + response = client.get(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}") assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 2 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is False + calibration_response = response.json() + assert calibration_response["urn"] == published_superseding_calibration["urn"] + assert calibration_response["private"] is False + + +########################################################### +# GET /score-calibrations/score-set/{score_set_urn} +########################################################### @pytest.mark.parametrize( @@ -977,7 +1018,69 @@ def test_creating_user_can_get_score_calibrations_for_score_set_when_public( ], indirect=["mock_publication_fetch"], ) -def test_contributing_user_can_get_score_calibrations_for_score_set_when_public( +def test_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_RANGE_BASED) + ) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "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) @@ -988,33 +1091,1766 @@ def test_contributing_user_can_get_score_calibrations_for_score_set_when_public( experiment["urn"], data_files / "scores.csv", ) - calibration = create_test_score_calibration_in_score_set_via_client( + create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) - publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that is private. The contributing user should see this one too + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_published_but_calibrations_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert "No score calibrations found for the requested score set" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_cannot_get_score_calibrations_for_score_set_when_published_but_calibrations_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert "No score calibrations found for the requested score set" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_creating_user_can_get_score_calibrations_for_score_set_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == calibration["urn"] + assert calibrations_response[0]["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_contributing_user_can_get_investigator_provided_score_calibrations_for_score_set_when_private( + client, + setup_router_db, + mock_publication_fetch, + session, + data_provider, + data_files, + extra_user_app_overrides, + admin_app_overrides, +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + with DependencyOverrider(admin_app_overrides): + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + investigator_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + add_contributor( session, - score_set["urn"], - ScoreSetDbModel, - EXTRA_USER["username"], - EXTRA_USER["first_name"], - EXTRA_USER["last_name"], + 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_RANGE_BASED) + ) + + with DependencyOverrider(admin_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == calibration["urn"] + assert calibrations_response[0]["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_can_get_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The anonymous user should not see this one + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == calibration["urn"] + assert calibrations_response[0]["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_can_get_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The other user should not see this one + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == calibration["urn"] + assert calibrations_response[0]["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_calibrations_public_score_set_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The anonymous user should not see this one + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_cannot_get_score_calibrations_for_score_set_when_calibrations_public_score_set_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The other user should not see this one + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + publish_test_score_calibration_via_client(client, calibration["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_creating_user_can_get_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + + # add another calibration that is private. The creating user should see this one too + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 2 + # The order is different from before. + created_calibration = next( + c for c in calibrations_response if c["urn"] == calibration["urn"] + ) + assert created_calibration["private"] is False + other_calibration = next( + c for c in calibrations_response if c["urn"] != calibration["urn"] + ) + assert other_calibration["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_contributing_user_can_get_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + + # add another calibration that is private. The contributing user should see this one too + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + add_contributor( + session, + score_set["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 2 + created_calibration = next( + c for c in calibrations_response if c["urn"] == calibration["urn"] + ) + assert created_calibration["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_admin_user_can_get_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + publish_test_score_calibration_via_client(client, calibration["urn"]) + + # add another calibration that is private. The admin user should see this one too + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + with DependencyOverrider(admin_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 2 + created_calibration = next( + c for c in calibrations_response if c["urn"] == calibration["urn"] + ) + assert created_calibration["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_cannot_get_superseding_score_calibrations_for_score_set_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_cannot_get_superseding_score_calibrations_for_score_set_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_can_get_superseded_score_calibrations_for_score_set_when_published_but_superseding_calibrations_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == published_calibration["urn"] + assert calibrations_response[0]["urn"] != superseding_calibration["urn"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_can_get_superseded_score_calibrations_for_score_set_when_published_but_superseding_calibrations_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == published_calibration["urn"] + assert calibrations_response[0]["urn"] != superseding_calibration["urn"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_creating_user_can_get_superseding_score_calibrations_for_score_set_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == superseding_calibration["urn"] + assert calibrations_response[0]["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_contributing_user_can_get_investigator_provided_superseding_score_calibrations_for_score_set_when_private( + client, + setup_router_db, + mock_publication_fetch, + session, + data_provider, + data_files, + extra_user_app_overrides, + admin_app_overrides, +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + with DependencyOverrider(admin_app_overrides): + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + investigator_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, investigator_calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + add_contributor( + session, + score_set["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == superseding_calibration["urn"] + assert calibrations_response[0]["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_admin_user_can_get_superseding_score_calibrations_for_score_set_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + with DependencyOverrider(admin_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == superseding_calibration["urn"] + assert calibrations_response[0]["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_can_get_superseding_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The anonymous user should not see this one + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == published_superseding_calibration["urn"] + assert calibrations_response[0]["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_can_get_superseding_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + # add another calibration that will remain private. The other user should not see this one + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 1 + assert calibrations_response[0]["urn"] == published_superseding_calibration["urn"] + assert calibrations_response[0]["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_anonymous_user_cannot_get_superseding_score_calibrations_for_score_set_when_calibrations_public_score_set_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The anonymous user should not see this one + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_other_user_cannot_get_superseding_score_calibrations_for_score_set_when_calibrations_public_score_set_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + # add another calibration that will remain private. The other user should not see this one + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_creating_user_can_get_superseding_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + # add another calibration that is private. The creating user should see this one too + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 2 + assert calibrations_response[0]["urn"] == superseding_calibration["urn"] + assert calibrations_response[0]["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_contributing_user_can_get_superseding_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + # add another calibration that is private. The contributing user should see this one too + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + add_contributor( + session, + score_set["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 2 + assert calibrations_response[0]["urn"] == superseding_calibration["urn"] + assert calibrations_response[0]["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_admin_user_can_get_superseding_score_calibrations_for_score_set_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + # add another calibration that is private. The admin user should see this one too + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + with DependencyOverrider(admin_app_overrides): + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + + assert response.status_code == 200 + calibrations_response = response.json() + assert len(calibrations_response) == 2 + assert calibrations_response[0]["urn"] == superseding_calibration["urn"] + assert calibrations_response[0]["private"] is False + + +########################################################### +# GET /score-calibrations/score-set/{score_set_urn}/primary +########################################################### + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_get_primary_score_calibration_for_score_set_when_no_calibrations_exist( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + + assert response.status_code == 404 + error = response.json() + assert "No primary score calibrations found for the requested score set" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_get_primary_score_calibration_for_score_set_when_none_exist( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + + assert response.status_code == 404 + error = response.json() + assert "No primary score calibrations found for the requested score set" in error["detail"] + + +# primary calibrations may not be private, so no need to test different user roles + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_get_primary_score_calibration_for_score_set_when_exists( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["private"] is False + + +# TODO#544: Business logic on view models should prevent this case from arising in production, but it could occur if the database +# were sloppily edited directly. +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_get_primary_score_calibration_for_score_set_when_multiple_exist( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + calibration2 = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + publish_test_score_calibration_via_client(client, calibration2["urn"]) + + second_primary = session.execute( + select(CalibrationDbModel).where(CalibrationDbModel.urn == calibration2["urn"]) + ).scalar_one() + second_primary.primary = True + session.add(second_primary) + session.commit() + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + + assert response.status_code == 500 + error = response.json() + assert "Multiple primary score calibrations found for the requested score set" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_get_superseding_primary_score_calibration_for_score_set_when_exists( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": calibration["urn"], + } + + superseding_calibration = create_publish_and_promote_score_calibration( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == superseding_calibration["urn"] + assert calibration_response["private"] is False + + +########################################################### +# POST /score-calibrations +########################################################### + + +def test_cannot_create_score_calibration_when_missing_score_set_urn(client, setup_router_db): + response = client.post( + "/api/v1/score-calibrations", + json={**deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)}, + ) + + assert response.status_code == 422 + error = response.json() + assert "score_set_urn must be provided to create a score calibration" in str(error["detail"]) + + +def test_cannot_create_score_calibration_when_score_set_does_not_exist(client, setup_router_db): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": "urn:ngs:score-set:nonexistent", + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, + ) + + assert response.status_code == 404 + error = response.json() + assert "score set with URN 'urn:ngs:score-set:nonexistent' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_score_calibration_when_csv_file_fails_decoding( + client, setup_router_db, session, data_provider, data_files, mock_publication_fetch +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + calibration_csv_path = data_files / "calibration_classes_by_urn.csv" + with ( + open(calibration_csv_path, "rb") as class_file, + patch( + "mavedb.routers.score_calibrations.csv_data_to_df", + side_effect=UnicodeDecodeError("utf-8", b"", 0, 1, "invalid start byte"), + ), + ): + response = client.post( + "/api/v1/score-calibrations", + files={"classes_file": (calibration_csv_path.name, class_file, "text/csv")}, + data={ + "calibration_json": json.dumps( + {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} + ), + }, + ) + + assert response.status_code == 400 + error = response.json() + assert "Error decoding file:" in str(error["detail"]) + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_score_calibration_when_validation_error_is_raised_from_score_calibration_file_standardization( + client, setup_router_db, session, data_provider, data_files, mock_publication_fetch +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + calibration_csv_path = data_files / "calibration_classes_by_urn.csv" + with ( + open(calibration_csv_path, "rb") as class_file, + patch( + "mavedb.routers.score_calibrations.validate_and_standardize_calibration_classes_dataframe", + side_effect=ValidationError("Test validation error"), + ), + ): + response = client.post( + "/api/v1/score-calibrations", + files={"classes_file": (calibration_csv_path.name, class_file, "text/csv")}, + data={ + "calibration_json": json.dumps( + {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} + ), + }, + ) + + assert response.status_code == 422 + error = response.json() + assert "Test validation error" in str(error["detail"][0]["msg"]) + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_score_calibration_when_score_set_not_owned_by_user( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, + ) + + assert response.status_code == 404 + error = response.json() + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_can_create_score_calibration_in_public_score_set_as_non_contributor( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, + ) + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["investigatorProvided"] is False + assert calibration_response["private"] is True + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_class_based_score_calibration_without_classes_file( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED), + }, + ) + + assert response.status_code == 422 + error = response.json() + assert "A classes_file must be provided when creating a class-based calibration" in str(error["detail"]) + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +@pytest.mark.parametrize( + "calibration_csv_path", + ["calibration_classes_by_urn.csv", "calibration_classes_by_hgvs_nt.csv", "calibration_classes_by_hgvs_prot.csv"], +) +def test_cannot_create_range_based_score_calibration_with_classes_file( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, calibration_csv_path +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + classification_csv_path = data_files / calibration_csv_path + with open(classification_csv_path, "rb") as class_file: + response = client.post( + "/api/v1/score-calibrations", + files={"classes_file": (classification_csv_path.name, class_file, "text/csv")}, + data={ + "calibration_json": json.dumps( + {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} + ), + }, + ) + + assert response.status_code == 422 + error = response.json() + assert "A classes_file should not be provided when creating a range-based calibration" in str(error["detail"]) + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_score_calibration_as_anonymous_user( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", ) - with DependencyOverrider(extra_user_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + with DependencyOverrider(anonymous_app_overrides): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, + ) - assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 2 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is False + assert response.status_code == 401 + error = response.json() + assert "Could not validate credentials" in error["detail"] @pytest.mark.parametrize( @@ -1027,8 +2863,8 @@ def test_contributing_user_can_get_score_calibrations_for_score_set_when_public( ], indirect=["mock_publication_fetch"], ) -def test_admin_user_can_get_score_calibrations_for_score_set_when_public( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides +def test_can_create_score_calibration_as_score_set_owner( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -1038,29 +2874,19 @@ def test_admin_user_can_get_score_calibrations_for_score_set_when_public( experiment["urn"], data_files / "scores.csv", ) - calibration = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - publish_test_score_calibration_via_client(client, calibration["urn"]) - # add another calibration that is private. The admin user should see this one too - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, ) - with DependencyOverrider(admin_app_overrides): - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") - assert response.status_code == 200 - calibrations_response = response.json() - assert len(calibrations_response) == 2 - assert calibrations_response[0]["urn"] == calibration["urn"] - assert calibrations_response[0]["private"] is False - - -########################################################### -# GET /score-calibrations/score-set/{score_set_urn}/primary -########################################################### + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True @pytest.mark.parametrize( @@ -1073,7 +2899,7 @@ def test_admin_user_can_get_score_calibrations_for_score_set_when_public( ], indirect=["mock_publication_fetch"], ) -def test_cannot_get_primary_score_calibration_for_score_set_when_no_calibrations_exist( +def test_can_create_score_calibration_as_score_set_owner_form( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) @@ -1085,11 +2911,19 @@ def test_cannot_get_primary_score_calibration_for_score_set_when_no_calibrations data_files / "scores.csv", ) - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + response = client.post( + "/api/v1/score-calibrations", + data={ + "calibration_json": json.dumps( + {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} + ), + }, + ) - assert response.status_code == 404 - error = response.json() - assert "No primary score calibrations found for the requested score set" in error["detail"] + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True @pytest.mark.parametrize( @@ -1102,8 +2936,8 @@ def test_cannot_get_primary_score_calibration_for_score_set_when_no_calibrations ], indirect=["mock_publication_fetch"], ) -def test_cannot_get_primary_score_calibration_for_score_set_when_none_exist( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +def test_can_create_score_calibration_as_score_set_contributor( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -1113,18 +2947,29 @@ def test_cannot_get_primary_score_calibration_for_score_set_when_none_exist( experiment["urn"], data_files / "scores.csv", ) - create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") - assert response.status_code == 404 - error = response.json() - assert "No primary score calibrations found for the requested score set" in error["detail"] + add_contributor( + session, + score_set["urn"], + ScoreSetDbModel, + EXTRA_USER["username"], + EXTRA_USER["first_name"], + EXTRA_USER["last_name"], + ) + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, + ) -# primary calibrations may not be private, so no need to test different user roles + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True @pytest.mark.parametrize( @@ -1137,8 +2982,8 @@ def test_cannot_get_primary_score_calibration_for_score_set_when_none_exist( ], indirect=["mock_publication_fetch"], ) -def test_get_primary_score_calibration_for_score_set_when_exists( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +def test_can_create_score_calibration_as_admin_user( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -1148,20 +2993,22 @@ def test_get_primary_score_calibration_for_score_set_when_exists( experiment["urn"], data_files / "scores.csv", ) - calibration = create_publish_and_promote_score_calibration( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") + with DependencyOverrider(admin_app_overrides): + response = client.post( + "/api/v1/score-calibrations", + json={ + "scoreSetUrn": score_set["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), + }, + ) assert response.status_code == 200 calibration_response = response.json() - assert calibration_response["urn"] == calibration["urn"] - assert calibration_response["private"] is False + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True -# TODO#544: Business logic on view models should prevent this case from arising in production, but it could occur if the database -# were sloppily edited directly. @pytest.mark.parametrize( "mock_publication_fetch", [ @@ -1172,8 +3019,12 @@ def test_get_primary_score_calibration_for_score_set_when_exists( ], indirect=["mock_publication_fetch"], ) -def test_get_primary_score_calibration_for_score_set_when_multiple_exist( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +@pytest.mark.parametrize( + "calibration_csv_path", + ["calibration_classes_by_urn.csv", "calibration_classes_by_hgvs_nt.csv", "calibration_classes_by_hgvs_prot.csv"], +) +def test_can_create_class_based_score_calibration_form( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, calibration_csv_path ): experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( @@ -1183,58 +3034,29 @@ def test_get_primary_score_calibration_for_score_set_when_multiple_exist( experiment["urn"], data_files / "scores.csv", ) + with patch.object(ArqRedis, "enqueue_job", return_value=None): + score_set = publish_score_set(client, score_set["urn"]) - create_publish_and_promote_score_calibration( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - calibration2 = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) - publish_test_score_calibration_via_client(client, calibration2["urn"]) - - second_primary = session.execute( - select(CalibrationDbModel).where(CalibrationDbModel.urn == calibration2["urn"]) - ).scalar_one() - second_primary.primary = True - session.add(second_primary) - session.commit() - - response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}/primary") - - assert response.status_code == 500 - error = response.json() - assert "Multiple primary score calibrations found for the requested score set" in error["detail"] - - -########################################################### -# POST /score-calibrations -########################################################### - - -def test_cannot_create_score_calibration_when_missing_score_set_urn(client, setup_router_db): - response = client.post( - "/api/v1/score-calibrations", - json={**deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)}, - ) - - assert response.status_code == 422 - error = response.json() - assert "score_set_urn must be provided to create a score calibration" in str(error["detail"]) - + classification_csv_path = data_files / calibration_csv_path + with open(classification_csv_path, "rb") as class_file: + response = client.post( + "/api/v1/score-calibrations", + files={"classes_file": (classification_csv_path.name, class_file, "text/csv")}, + data={ + "calibration_json": json.dumps( + {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} + ), + }, + ) -def test_cannot_create_score_calibration_when_score_set_does_not_exist(client, setup_router_db): - response = client.post( - "/api/v1/score-calibrations", - json={ - "scoreSetUrn": "urn:ngs:score-set:nonexistent", - **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), - }, + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True + assert all( + classification["variantCount"] == 1 for classification in calibration_response["functionalClassifications"] ) - assert response.status_code == 404 - error = response.json() - assert "score set with URN 'urn:ngs:score-set:nonexistent' not found" in error["detail"] - @pytest.mark.parametrize( "mock_publication_fetch", @@ -1246,7 +3068,7 @@ def test_cannot_create_score_calibration_when_score_set_does_not_exist(client, s ], indirect=["mock_publication_fetch"], ) -def test_cannot_create_score_calibration_when_csv_file_fails_decoding( +def test_cannot_create_superseding_score_calibration_when_csv_file_fails_decoding( client, setup_router_db, session, data_provider, data_files, mock_publication_fetch ): experiment = create_experiment(client) @@ -1258,9 +3080,14 @@ def test_cannot_create_score_calibration_when_csv_file_fails_decoding( data_files / "scores.csv", ) - calibration_csv_path = data_files / "calibration_classes_by_urn.csv" + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_csv_path = data_files / "calibration_classes_by_urn.csv" with ( - open(calibration_csv_path, "rb") as class_file, + open(superseding_calibration_csv_path, "rb") as class_file, patch( "mavedb.routers.score_calibrations.csv_data_to_df", side_effect=UnicodeDecodeError("utf-8", b"", 0, 1, "invalid start byte"), @@ -1268,10 +3095,14 @@ def test_cannot_create_score_calibration_when_csv_file_fails_decoding( ): response = client.post( "/api/v1/score-calibrations", - files={"classes_file": (calibration_csv_path.name, class_file, "text/csv")}, + files={"classes_file": (superseding_calibration_csv_path.name, class_file, "text/csv")}, data={ "calibration_json": json.dumps( - {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], + **TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED, + } ), }, ) @@ -1291,7 +3122,7 @@ def test_cannot_create_score_calibration_when_csv_file_fails_decoding( ], indirect=["mock_publication_fetch"], ) -def test_cannot_create_score_calibration_when_validation_error_is_raised_from_score_calibration_file_standardization( +def test_cannot_create_superseding_score_calibration_when_validation_error_is_raised_from_score_calibration_file_standardization( client, setup_router_db, session, data_provider, data_files, mock_publication_fetch ): experiment = create_experiment(client) @@ -1303,9 +3134,14 @@ def test_cannot_create_score_calibration_when_validation_error_is_raised_from_sc data_files / "scores.csv", ) - calibration_csv_path = data_files / "calibration_classes_by_urn.csv" + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_csv_path = data_files / "calibration_classes_by_urn.csv" with ( - open(calibration_csv_path, "rb") as class_file, + open(superseding_calibration_csv_path, "rb") as class_file, patch( "mavedb.routers.score_calibrations.validate_and_standardize_calibration_classes_dataframe", side_effect=ValidationError("Test validation error"), @@ -1313,10 +3149,13 @@ def test_cannot_create_score_calibration_when_validation_error_is_raised_from_sc ): response = client.post( "/api/v1/score-calibrations", - files={"classes_file": (calibration_csv_path.name, class_file, "text/csv")}, + files={"classes_file": (superseding_calibration_csv_path.name, class_file, "text/csv")}, data={ "calibration_json": json.dumps( - {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} ), }, ) @@ -1336,7 +3175,7 @@ def test_cannot_create_score_calibration_when_validation_error_is_raised_from_sc ], indirect=["mock_publication_fetch"], ) -def test_cannot_create_score_calibration_when_score_set_not_owned_by_user( +def test_cannot_create_superseding_score_calibration_when_score_set_not_owned_by_user( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -1348,11 +3187,17 @@ def test_cannot_create_score_calibration_when_score_set_not_owned_by_user( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + with DependencyOverrider(extra_user_app_overrides): response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), }, ) @@ -1372,7 +3217,7 @@ def test_cannot_create_score_calibration_when_score_set_not_owned_by_user( ], indirect=["mock_publication_fetch"], ) -def test_can_create_score_calibration_in_public_score_set_as_non_contributor( +def test_can_create_superseding_score_calibration_in_public_score_set_as_non_contributor( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -1387,11 +3232,17 @@ def test_can_create_score_calibration_in_public_score_set_as_non_contributor( with patch.object(ArqRedis, "enqueue_job", return_value=None): score_set = publish_score_set(client, score_set["urn"]) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + with DependencyOverrider(extra_user_app_overrides): response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), }, ) @@ -1401,6 +3252,7 @@ def test_can_create_score_calibration_in_public_score_set_as_non_contributor( assert calibration_response["scoreSetUrn"] == score_set["urn"] assert calibration_response["investigatorProvided"] is False assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] @pytest.mark.parametrize( @@ -1413,7 +3265,7 @@ def test_can_create_score_calibration_in_public_score_set_as_non_contributor( ], indirect=["mock_publication_fetch"], ) -def test_cannot_create_class_based_score_calibration_without_classes_file( +def test_cannot_create_superseding_class_based_score_calibration_without_classes_file( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) @@ -1425,10 +3277,16 @@ def test_cannot_create_class_based_score_calibration_without_classes_file( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED), }, ) @@ -1452,7 +3310,7 @@ def test_cannot_create_class_based_score_calibration_without_classes_file( "calibration_csv_path", ["calibration_classes_by_urn.csv", "calibration_classes_by_hgvs_nt.csv", "calibration_classes_by_hgvs_prot.csv"], ) -def test_cannot_create_range_based_score_calibration_with_classes_file( +def test_cannot_create_superseding_range_based_score_calibration_with_classes_file( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, calibration_csv_path ): experiment = create_experiment(client) @@ -1464,6 +3322,11 @@ def test_cannot_create_range_based_score_calibration_with_classes_file( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + classification_csv_path = data_files / calibration_csv_path with open(classification_csv_path, "rb") as class_file: response = client.post( @@ -1471,7 +3334,10 @@ def test_cannot_create_range_based_score_calibration_with_classes_file( files={"classes_file": (classification_csv_path.name, class_file, "text/csv")}, data={ "calibration_json": json.dumps( - {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} ), }, ) @@ -1491,7 +3357,7 @@ def test_cannot_create_range_based_score_calibration_with_classes_file( ], indirect=["mock_publication_fetch"], ) -def test_cannot_create_score_calibration_as_anonymous_user( +def test_cannot_create_superseding_score_calibration_as_anonymous_user( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, anonymous_app_overrides ): experiment = create_experiment(client) @@ -1503,11 +3369,17 @@ def test_cannot_create_score_calibration_as_anonymous_user( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + with DependencyOverrider(anonymous_app_overrides): response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), }, ) @@ -1527,7 +3399,7 @@ def test_cannot_create_score_calibration_as_anonymous_user( ], indirect=["mock_publication_fetch"], ) -def test_can_create_score_calibration_as_score_set_owner( +def test_can_create_superseding_score_calibration_as_score_set_owner( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) @@ -1539,10 +3411,16 @@ def test_can_create_score_calibration_as_score_set_owner( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), }, ) @@ -1551,6 +3429,7 @@ def test_can_create_score_calibration_as_score_set_owner( calibration_response = response.json() assert calibration_response["scoreSetUrn"] == score_set["urn"] assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] @pytest.mark.parametrize( @@ -1563,7 +3442,7 @@ def test_can_create_score_calibration_as_score_set_owner( ], indirect=["mock_publication_fetch"], ) -def test_can_create_score_calibration_as_score_set_owner_form( +def test_can_create_superseding_score_calibration_as_score_set_owner_form( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) @@ -1575,11 +3454,19 @@ def test_can_create_score_calibration_as_score_set_owner_form( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + response = client.post( "/api/v1/score-calibrations", data={ "calibration_json": json.dumps( - {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} ), }, ) @@ -1588,6 +3475,7 @@ def test_can_create_score_calibration_as_score_set_owner_form( calibration_response = response.json() assert calibration_response["scoreSetUrn"] == score_set["urn"] assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] @pytest.mark.parametrize( @@ -1600,7 +3488,7 @@ def test_can_create_score_calibration_as_score_set_owner_form( ], indirect=["mock_publication_fetch"], ) -def test_can_create_score_calibration_as_score_set_contributor( +def test_can_create_superseding_score_calibration_as_score_set_contributor( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -1621,11 +3509,17 @@ def test_can_create_score_calibration_as_score_set_contributor( EXTRA_USER["last_name"], ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + with DependencyOverrider(extra_user_app_overrides): response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), }, ) @@ -1634,6 +3528,7 @@ def test_can_create_score_calibration_as_score_set_contributor( calibration_response = response.json() assert calibration_response["scoreSetUrn"] == score_set["urn"] assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] @pytest.mark.parametrize( @@ -1646,7 +3541,7 @@ def test_can_create_score_calibration_as_score_set_contributor( ], indirect=["mock_publication_fetch"], ) -def test_can_create_score_calibration_as_admin_user( +def test_can_create_superseding_score_calibration_as_admin_user( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides ): experiment = create_experiment(client) @@ -1658,11 +3553,17 @@ def test_can_create_score_calibration_as_admin_user( data_files / "scores.csv", ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + with DependencyOverrider(admin_app_overrides): response = client.post( "/api/v1/score-calibrations", json={ "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED), }, ) @@ -1671,6 +3572,7 @@ def test_can_create_score_calibration_as_admin_user( calibration_response = response.json() assert calibration_response["scoreSetUrn"] == score_set["urn"] assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] @pytest.mark.parametrize( @@ -1687,7 +3589,7 @@ def test_can_create_score_calibration_as_admin_user( "calibration_csv_path", ["calibration_classes_by_urn.csv", "calibration_classes_by_hgvs_nt.csv", "calibration_classes_by_hgvs_prot.csv"], ) -def test_can_create_class_based_score_calibration_form( +def test_can_create_superseding_class_based_score_calibration_form( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, calibration_csv_path ): experiment = create_experiment(client) @@ -1701,6 +3603,11 @@ def test_can_create_class_based_score_calibration_form( with patch.object(ArqRedis, "enqueue_job", return_value=None): score_set = publish_score_set(client, score_set["urn"]) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + classification_csv_path = data_files / calibration_csv_path with open(classification_csv_path, "rb") as class_file: response = client.post( @@ -1708,7 +3615,10 @@ def test_can_create_class_based_score_calibration_form( files={"classes_file": (classification_csv_path.name, class_file, "text/csv")}, data={ "calibration_json": json.dumps( - {"scoreSetUrn": score_set["urn"], **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_CLASS_BASED)} ), }, ) @@ -1717,11 +3627,101 @@ def test_can_create_class_based_score_calibration_form( calibration_response = response.json() assert calibration_response["scoreSetUrn"] == score_set["urn"] assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] assert all( classification["variantCount"] == 1 for classification in calibration_response["functionalClassifications"] ) +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_create_superseding_score_calibration_when_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.post( + "/api/v1/score-calibrations", + data={ + "calibration_json": json.dumps( + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": calibration["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} + ), + }, + ) + + assert response.status_code == 422 + error = response.json() + assert "Cannot supersede a private calibration. Please edit it instead." in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_can_create_superseding_score_calibration_when_public( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + response = client.post( + "/api/v1/score-calibrations", + data={ + "calibration_json": json.dumps( + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": published_calibration["urn"], + **deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED)} + ), + }, + ) + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["scoreSetUrn"] == score_set["urn"] + assert calibration_response["private"] is True + assert calibration_response["supersededCalibration"]["urn"] == published_calibration["urn"] + + ########################################################### # PUT /score-calibrations/{calibration_urn} ########################################################### @@ -2692,6 +4692,51 @@ def test_can_modify_score_calibration_to_class_based( ) +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_update_superseded_calibration_when_score_calibration_private( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + response = client.put( + f"/api/v1/score-calibrations/{calibration['urn']}", + data={ + "calibration_json": json.dumps( + { + "scoreSetUrn": score_set["urn"], + "supersededCalibrationUrn": calibration["urn"], + **deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) + } + ), + } + ) + + assert response.status_code == 200 + calibration_response = response.json() + assert calibration_response["urn"] == calibration["urn"] + assert calibration_response["supersededCalibration"] is None + + ########################################################### # DELETE /score-calibrations/{calibration_urn} ########################################################### @@ -3297,9 +5342,124 @@ def test_cannot_promote_research_use_only_to_primary( response = client.post(f"/api/v1/score-calibrations/{calibration['urn']}/promote-to-primary") - assert response.status_code == 400 - error = response.json() - assert "Research use only score calibrations cannot be promoted to primary" in error["detail"] + assert response.status_code == 400 + error = response.json() + assert "Research use only score calibrations cannot be promoted to primary" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_promote_private_calibration_to_primary( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize({**TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED}), + ) + response = client.post(f"/api/v1/score-calibrations/{calibration['urn']}/promote-to-primary") + + assert response.status_code == 400 + error = response.json() + assert "Private score calibrations cannot be promoted to primary" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_cannot_promote_to_primary_if_primary_exists( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + secondary_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) + ) + publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) + + response = client.post(f"/api/v1/score-calibrations/{secondary_calibration['urn']}/promote-to-primary") + + assert response.status_code == 400 + error = response.json() + assert "A primary score calibration already exists for this score set" in error["detail"] + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_can_promote_to_primary_if_primary_exists_when_demote_existing_is_true( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + primary_calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + secondary_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) + ) + publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) + + response = client.post( + f"/api/v1/score-calibrations/{secondary_calibration['urn']}/promote-to-primary?demoteExistingPrimary=true", + ) + + assert response.status_code == 200 + promotion_response = response.json() + assert promotion_response["urn"] == secondary_calibration["urn"] + assert promotion_response["scoreSetUrn"] == score_set["urn"] + assert promotion_response["primary"] is True + + # verify the previous primary is no longer primary + get_response = client.get(f"/api/v1/score-calibrations/{primary_calibration['urn']}") + assert get_response.status_code == 200 + previous_primary = get_response.json() + assert previous_primary["primary"] is False @pytest.mark.parametrize( @@ -3312,9 +5472,11 @@ def test_cannot_promote_research_use_only_to_primary( ], indirect=["mock_publication_fetch"], ) -def test_cannot_promote_private_calibration_to_primary( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +def test_score_set_owner_can_promote_to_primary_with_demote_existing_flag_on_community_calibration( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides ): + """Score set owners can change rank of community calibrations, so they can promote + their own calibration while demoting an admin-created community primary.""" experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( client, @@ -3323,17 +5485,28 @@ def test_cannot_promote_private_calibration_to_primary( experiment["urn"], data_files / "scores.csv", ) - calibration = create_test_score_calibration_in_score_set_via_client( - client, - score_set["urn"], - deepcamelize({**TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, "private": True}), + with DependencyOverrider(admin_app_overrides): + primary_calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + secondary_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) ) + publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) - response = client.post(f"/api/v1/score-calibrations/{calibration['urn']}/promote-to-primary") + response = client.post( + f"/api/v1/score-calibrations/{secondary_calibration['urn']}/promote-to-primary?demoteExistingPrimary=true", + ) - assert response.status_code == 400 - error = response.json() - assert "Private score calibrations cannot be promoted to primary" in error["detail"] + assert response.status_code == 200 + promoted = response.json() + assert promoted["primary"] is True + + # verify the previous primary was demoted + get_response = client.get(f"/api/v1/score-calibrations/{primary_calibration['urn']}") + assert get_response.status_code == 200 + previous_primary = get_response.json() + assert previous_primary["primary"] is False @pytest.mark.parametrize( @@ -3346,7 +5519,7 @@ def test_cannot_promote_private_calibration_to_primary( ], indirect=["mock_publication_fetch"], ) -def test_cannot_promote_to_primary_if_primary_exists( +def test_promote_superseding_score_calibration_if_superseded_is_not_primary( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) @@ -3357,20 +5530,35 @@ def test_cannot_promote_to_primary_if_primary_exists( experiment["urn"], data_files / "scores.csv", ) - create_publish_and_promote_score_calibration( + calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) - secondary_calibration = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), ) - publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) + published_superseding_calibration = publish_test_score_calibration_via_client(client, + superseding_calibration["urn"]) - response = client.post(f"/api/v1/score-calibrations/{secondary_calibration['urn']}/promote-to-primary") + response = client.post(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}/promote-to-primary") - assert response.status_code == 400 - error = response.json() - assert "A primary score calibration already exists for this score set" in error["detail"] + assert response.status_code == 200 + promotion_response = response.json() + assert promotion_response["urn"] == published_superseding_calibration["urn"] + assert promotion_response["scoreSetUrn"] == score_set["urn"] + assert promotion_response["primary"] is True + get_response = client.get(f"/api/v1/score-calibrations/{published_calibration['urn']}") + assert get_response.status_code == 200 + previous_primary = get_response.json() + assert previous_primary["primary"] is False @pytest.mark.parametrize( "mock_publication_fetch", @@ -3382,7 +5570,7 @@ def test_cannot_promote_to_primary_if_primary_exists( ], indirect=["mock_publication_fetch"], ) -def test_can_promote_to_primary_if_primary_exists_when_demote_existing_is_true( +def test_promote_superseding_score_calibration_if_superseded_is_primary( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): experiment = create_experiment(client) @@ -3396,18 +5584,24 @@ def test_can_promote_to_primary_if_primary_exists_when_demote_existing_is_true( primary_calibration = create_publish_and_promote_score_calibration( client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) ) - secondary_calibration = create_test_score_calibration_in_score_set_via_client( - client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) - ) - publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) - response = client.post( - f"/api/v1/score-calibrations/{secondary_calibration['urn']}/promote-to-primary?demoteExistingPrimary=true", + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": primary_calibration["urn"], + } + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, + superseding_calibration["urn"]) + + response = client.post(f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}/promote-to-primary") assert response.status_code == 200 promotion_response = response.json() - assert promotion_response["urn"] == secondary_calibration["urn"] + assert promotion_response["urn"] == published_superseding_calibration["urn"] assert promotion_response["scoreSetUrn"] == score_set["urn"] assert promotion_response["primary"] is True @@ -3428,11 +5622,9 @@ def test_can_promote_to_primary_if_primary_exists_when_demote_existing_is_true( ], indirect=["mock_publication_fetch"], ) -def test_score_set_owner_can_promote_to_primary_with_demote_existing_flag_on_community_calibration( - client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, admin_app_overrides +def test_can_promote_superseding_calibration_to_primary_if_primary_exists_when_demote_existing_is_true( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files ): - """Score set owners can change rank of community calibrations, so they can promote - their own calibration while demoting an admin-created community primary.""" experiment = create_experiment(client) score_set = create_seq_score_set_with_mapped_variants( client, @@ -3441,24 +5633,37 @@ def test_score_set_owner_can_promote_to_primary_with_demote_existing_flag_on_com experiment["urn"], data_files / "scores.csv", ) - with DependencyOverrider(admin_app_overrides): - primary_calibration = create_publish_and_promote_score_calibration( - client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) - ) + primary_calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) secondary_calibration = create_test_score_calibration_in_score_set_via_client( client, score_set["urn"], deepcamelize(TEST_PATHOGENICITY_SCORE_CALIBRATION) ) - publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) + published_secondary_calibration = publish_test_score_calibration_via_client(client, secondary_calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_secondary_calibration["urn"], + } + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, + superseding_calibration["urn"]) response = client.post( - f"/api/v1/score-calibrations/{secondary_calibration['urn']}/promote-to-primary?demoteExistingPrimary=true", + f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}/promote-to-primary?demoteExistingPrimary=true", ) assert response.status_code == 200 - promoted = response.json() - assert promoted["primary"] is True + promotion_response = response.json() + assert promotion_response["urn"] == published_superseding_calibration["urn"] + assert promotion_response["scoreSetUrn"] == score_set["urn"] + assert promotion_response["primary"] is True - # verify the previous primary was demoted + # verify the previous primary is no longer primary get_response = client.get(f"/api/v1/score-calibrations/{primary_calibration['urn']}") assert get_response.status_code == 200 previous_primary = get_response.json() @@ -3708,6 +5913,59 @@ def test_can_demote_non_primary_score_calibration( assert demotion_response["primary"] is False +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_can_demote_superseding_score_calibration_as_score_set_owner( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_publish_and_promote_score_calibration( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": calibration["urn"], + } + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + published_superseding_calibration = publish_test_score_calibration_via_client(client, + superseding_calibration["urn"]) + + response = client.post( + f"/api/v1/score-calibrations/{published_superseding_calibration['urn']}/demote-from-primary", + ) + + assert response.status_code == 200 + demotion_response = response.json() + assert demotion_response["urn"] == published_superseding_calibration["urn"] + assert demotion_response["scoreSetUrn"] == score_set["urn"] + assert demotion_response["primary"] is False + assert demotion_response["supersededCalibration"]["urn"] == calibration["urn"] + + get_response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") + assert get_response.status_code == 200 + previous_primary = get_response.json() + assert previous_primary["primary"] is False + + ########################################################### # POST /score-calibrations/{calibration_urn}/publish ########################################################### @@ -3920,6 +6178,57 @@ def test_can_publish_already_published_calibration( assert published_calibration_2["private"] is False +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_can_publish_superseding_score_calibration( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + response = client.post(f"/api/v1/score-calibrations/{superseding_calibration['urn']}/publish") + + assert response.status_code == 200 + publish_response = response.json() + assert publish_response["urn"] == superseding_calibration["urn"] + assert publish_response["scoreSetUrn"] == score_set["urn"] + assert publish_response["private"] is False + assert publish_response["supersededCalibration"]["urn"] == calibration["urn"] + + get_response = client.get(f"/api/v1/score-calibrations/score-set/{score_set['urn']}") + assert get_response.status_code == 200 + score_set_response = get_response.json() + assert len(score_set_response) == 1 + + ########################################################### # GET /score-calibrations/{urn}/functional-classifications/{id}/variants ########################################################### @@ -5014,3 +7323,110 @@ def test_user_sees_calibrations_across_multiple_score_sets( returned_urns = {c["urn"] for c in calibrations} assert cal_1["urn"] in returned_urns assert cal_2["urn"] in returned_urns + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_authenticated_user_sees_own_calibrations_including_private_superseded_calibration( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + + response = client.get("/api/v1/score-calibrations/me") + + assert response.status_code == 200 + calibrations = response.json() + assert len(calibrations) == 2 + superseding_calibration = next( + c for c in calibrations if c["urn"] == superseding_calibration["urn"] + ) + assert superseding_calibration["private"] is True + superseded_calibration = next( + c for c in calibrations if c["urn"] == published_calibration["urn"] + ) + assert superseded_calibration["private"] is False + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [ + [ + {"dbName": "PubMed", "identifier": TEST_PUBMED_IDENTIFIER}, + {"dbName": "bioRxiv", "identifier": TEST_BIORXIV_IDENTIFIER}, + ] + ], + indirect=["mock_publication_fetch"], +) +def test_authenticated_user_sees_own_calibrations_including_public_superseded_calibration( + client, setup_router_db, mock_publication_fetch, session, data_provider, data_files +): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_mapped_variants( + client, + session, + data_provider, + experiment["urn"], + data_files / "scores.csv", + ) + calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED) + ) + + published_calibration = publish_test_score_calibration_via_client(client, calibration["urn"]) + + superseding_calibration_data = { + **TEST_BRNICH_SCORE_CALIBRATION_RANGE_BASED, + "superseded_calibration_urn": published_calibration["urn"], + } + + superseding_calibration = create_test_score_calibration_in_score_set_via_client( + client, + score_set["urn"], + deepcamelize(superseding_calibration_data), + ) + publish_test_score_calibration_via_client(client, superseding_calibration["urn"]) + + response = client.get("/api/v1/score-calibrations/me") + + assert response.status_code == 200 + calibrations = response.json() + assert len(calibrations) == 2 + superseding_calibration = next( + c for c in calibrations if c["urn"] == superseding_calibration["urn"] + ) + assert superseding_calibration["private"] is False + superseded_calibration = next( + c for c in calibrations if c["urn"] == published_calibration["urn"] + ) + assert superseded_calibration["private"] is False \ No newline at end of file