diff --git a/alembic/versions/aa73d39b3705_score_set_level_score_thresholds.py b/alembic/versions/aa73d39b3705_score_set_level_score_thresholds.py new file mode 100644 index 00000000..7b1cf5e3 --- /dev/null +++ b/alembic/versions/aa73d39b3705_score_set_level_score_thresholds.py @@ -0,0 +1,29 @@ +"""score set level score thresholds + +Revision ID: aa73d39b3705 +Revises: 68a0ec57694e +Create Date: 2024-11-13 11:23:57.917725 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "aa73d39b3705" +down_revision = "68a0ec57694e" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("scoresets", sa.Column("score_calibrations", postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("scoresets", "score_calibrations") + # ### end Alembic commands ### diff --git a/alembic/versions/c404b6719110_collections_data_model.py b/alembic/versions/c404b6719110_collections_data_model.py new file mode 100644 index 00000000..a1f4e2ee --- /dev/null +++ b/alembic/versions/c404b6719110_collections_data_model.py @@ -0,0 +1,117 @@ +"""Collections data model + +Revision ID: c404b6719110 +Revises: aa73d39b3705 +Create Date: 2024-10-15 12:57:29.682271 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c404b6719110" +down_revision = "aa73d39b3705" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "collections", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("urn", sa.String(length=64), nullable=True), + sa.Column("private", sa.Boolean(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("badge_name", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column("created_by_id", sa.Integer(), nullable=True), + sa.Column("modified_by_id", sa.Integer(), nullable=True), + sa.Column("creation_date", sa.Date(), nullable=False), + sa.Column("modification_date", sa.Date(), nullable=False), + sa.ForeignKeyConstraint( + ["created_by_id"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["modified_by_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_collections_created_by_id"), "collections", ["created_by_id"], unique=False) + op.create_index(op.f("ix_collections_modified_by_id"), "collections", ["modified_by_id"], unique=False) + op.create_index(op.f("ix_collections_urn"), "collections", ["urn"], unique=True) + + op.create_table( + "collection_user_associations", + sa.Column("collection_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "contribution_role", + sa.Enum( + "admin", + "editor", + "viewer", + name="contributionrole", + native_enum=False, + create_constraint=True, + length=32, + ), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["collection_id"], + ["collections.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("collection_id", "user_id"), + ) + + op.create_table( + "collection_experiments", + sa.Column("collection_id", sa.Integer(), nullable=False), + sa.Column("experiment_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["collection_id"], + ["collections.id"], + ), + sa.ForeignKeyConstraint( + ["experiment_id"], + ["experiments.id"], + ), + sa.PrimaryKeyConstraint("collection_id", "experiment_id"), + ) + + op.create_table( + "collection_score_sets", + sa.Column("collection_id", sa.Integer(), nullable=False), + sa.Column("score_set_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["collection_id"], + ["collections.id"], + ), + sa.ForeignKeyConstraint( + ["score_set_id"], + ["scoresets.id"], + ), + sa.PrimaryKeyConstraint("collection_id", "score_set_id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("collection_score_sets") + op.drop_table("collection_experiments") + op.drop_table("collection_user_associations") + op.drop_index(op.f("ix_collections_urn"), table_name="collections") + op.drop_index(op.f("ix_collections_modified_by_id"), table_name="collections") + op.drop_index(op.f("ix_collections_created_by_id"), table_name="collections") + op.drop_table("collections") + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index 4eefc7a1..7c8c6cf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "mavedb" -version = "2025.0.0" +version = "2025.1.0" description = "API for MaveDB, the database of Multiplexed Assays of Variant Effect." license = "AGPL-3.0-only" readme = "README.md" diff --git a/src/mavedb/__init__.py b/src/mavedb/__init__.py index d0a55f5c..09616348 100644 --- a/src/mavedb/__init__.py +++ b/src/mavedb/__init__.py @@ -6,6 +6,6 @@ logger = module_logging.getLogger(__name__) __project__ = "mavedb-api" -__version__ = "2025.0.0" +__version__ = "2025.1.0" logger.info(f"MaveDB {__version__}") diff --git a/src/mavedb/lib/experiments.py b/src/mavedb/lib/experiments.py index 1dff7090..3452152a 100644 --- a/src/mavedb/lib/experiments.py +++ b/src/mavedb/lib/experiments.py @@ -8,7 +8,9 @@ from mavedb.models.contributor import Contributor from mavedb.models.controlled_keyword import ControlledKeyword from mavedb.models.experiment import Experiment -from mavedb.models.experiment_controlled_keyword import ExperimentControlledKeywordAssociation +from mavedb.models.experiment_controlled_keyword import ( + ExperimentControlledKeywordAssociation, +) from mavedb.models.publication_identifier import PublicationIdentifier from mavedb.models.score_set import ScoreSet from mavedb.models.user import User @@ -117,6 +119,9 @@ def search_experiments( items = [] save_to_logging_context({"matching_resources": len(items)}) - logger.debug(msg="Experiment search yielded {len(items)} matching resources.", extra=logging_context()) + logger.debug( + msg="Experiment search yielded {len(items)} matching resources.", + extra=logging_context(), + ) return items diff --git a/src/mavedb/lib/permissions.py b/src/mavedb/lib/permissions.py index 0a3af714..6305272c 100644 --- a/src/mavedb/lib/permissions.py +++ b/src/mavedb/lib/permissions.py @@ -5,6 +5,8 @@ from mavedb.db.base import Base from mavedb.lib.authentication import UserData from mavedb.lib.logging.context import logging_context, save_to_logging_context +from mavedb.models.collection import Collection +from mavedb.models.enums.contribution_role import ContributionRole from mavedb.models.enums.user_role import UserRole from mavedb.models.experiment import Experiment from mavedb.models.experiment_set import ExperimentSet @@ -15,6 +17,7 @@ class Action(Enum): + LOOKUP = "lookup" READ = "read" UPDATE = "update" DELETE = "delete" @@ -23,6 +26,7 @@ class Action(Enum): SET_SCORES = "set_scores" ADD_ROLE = "add_role" PUBLISH = "publish" + ADD_BADGE = "add_badge" class PermissionResponse: @@ -33,9 +37,15 @@ def __init__(self, permitted: bool, http_code: int = 403, message: Optional[str] save_to_logging_context({"permission_message": self.message, "access_permitted": self.permitted}) if self.permitted: - logger.debug(msg="Access to the requested resource is permitted.", extra=logging_context()) + logger.debug( + msg="Access to the requested resource is permitted.", + extra=logging_context(), + ) else: - logger.debug(msg="Access to the requested resource is not permitted.", extra=logging_context()) + logger.debug( + msg="Access to the requested resource is not permitted.", + extra=logging_context(), + ) class PermissionException(Exception): @@ -59,6 +69,7 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> user_is_owner = False user_is_self = False user_may_edit = False + user_may_view_private = False active_roles = user_data.active_roles if user_data else [] if isinstance(item, ExperimentSet) or isinstance(item, Experiment) or isinstance(item, ScoreSet): @@ -72,6 +83,27 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> save_to_logging_context({"resource_is_published": published}) + if isinstance(item, Collection): + assert item.private is not None + private = item.private + published = item.private is False + user_is_owner = item.created_by_id == user_data.user.id if user_data is not None else False + admin_user_ids = set() + editor_user_ids = set() + viewer_user_ids = set() + for user_association in item.user_associations: + if user_association.contribution_role == ContributionRole.admin: + admin_user_ids.add(user_association.user_id) + elif user_association.contribution_role == ContributionRole.editor: + editor_user_ids.add(user_association.user_id) + elif user_association.contribution_role == ContributionRole.viewer: + viewer_user_ids.add(user_association.user_id) + user_is_admin = user_is_owner or (user_data is not None and user_data.user.id in admin_user_ids) + user_may_edit = user_is_admin or (user_data is not None and user_data.user.id in editor_user_ids) + user_may_view_private = user_may_edit or (user_data is not None and (user_data.user.id in viewer_user_ids)) + + save_to_logging_context({"resource_is_published": published}) + if isinstance(item, User): user_is_self = item.id == user_data.user.id if user_data is not None else False user_may_edit = user_is_self @@ -254,7 +286,109 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> else: raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)") + elif isinstance(item, Collection): + if action == Action.READ: + if user_may_view_private or not private: + return PermissionResponse(True) + # Roles which may perform this operation. + elif roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + elif private: + # Do not acknowledge the existence of a private entity. + return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") + elif user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") + else: + return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") + elif action == Action.UPDATE: + if user_may_edit: + return PermissionResponse(True) + # Roles which may perform this operation. + elif roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + elif private and not user_may_view_private: + # Do not acknowledge the existence of a private entity. + return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found") + elif user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") + else: + return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") + elif action == Action.DELETE: + # A collection may be deleted even if it has been published, as long as it is not an official collection. + if user_is_owner: + return PermissionResponse( + not item.badge_name, + 403, + f"insufficient permissions for URN '{item.urn}'", + ) + # MaveDB admins may delete official collections. + elif roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + elif private and not user_may_view_private: + # Do not acknowledge the existence of a private entity. + return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") + else: + return PermissionResponse(False) + elif action == Action.PUBLISH: + if user_is_admin: + return PermissionResponse(True) + elif roles_permitted(active_roles, []): + return PermissionResponse(True) + elif private and not user_may_view_private: + # Do not acknowledge the existence of a private entity. + return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found") + else: + return PermissionResponse(False) + elif action == Action.ADD_SCORE_SET: + # Whether the collection is private or public, only permitted users can add a score set to a collection. + if user_may_edit or roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + elif private and not user_may_view_private: + return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") + else: + return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") + elif action == Action.ADD_EXPERIMENT: + # Only permitted users can add an experiment to an existing collection. + return PermissionResponse( + user_may_edit or roles_permitted(active_roles, [UserRole.admin]), + 404 if private and not user_may_view_private else 403, + ( + f"collection with URN '{item.urn}' not found" + if private and not user_may_view_private + else f"insufficient permissions for URN '{item.urn}'" + ), + ) + elif action == Action.ADD_ROLE: + # Both collection admins and MaveDB admins can add a user to a collection role + if user_is_admin or roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + else: + return PermissionResponse(False, 403, "Insufficient permissions to add user role.") + # only MaveDB admins may add a badge name to a collection, which makes the collection considered "official" + elif action == Action.ADD_BADGE: + # Roles which may perform this operation. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + elif private: + # Do not acknowledge the existence of a private entity. + return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") + elif user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") + else: + return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") + else: + raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)") + elif isinstance(item, User): + if action == Action.LOOKUP: + # any existing user can look up any mavedb user by Orcid ID + # lookup differs from read because lookup means getting the first name, last name, and orcid ID of the user, + # while read means getting an admin view of the user's details + if user_data is not None and user_data.user is not None: + return PermissionResponse(True) + else: + # TODO is this inappropriately acknowledging the existence of the user? + return PermissionResponse(False, 401, "Insufficient permissions for user lookup.") if action == Action.READ: if user_is_self: return PermissionResponse(True) diff --git a/src/mavedb/lib/score_sets.py b/src/mavedb/lib/score_sets.py index 775e067d..1d650eb6 100644 --- a/src/mavedb/lib/score_sets.py +++ b/src/mavedb/lib/score_sets.py @@ -2,7 +2,8 @@ import io import logging import re -from typing import Any, BinaryIO, Iterable, Optional, Sequence +from operator import attrgetter +from typing import Any, BinaryIO, Iterable, Optional, TYPE_CHECKING, Sequence import numpy as np import pandas as pd @@ -21,6 +22,7 @@ ) from mavedb.lib.mave.utils import is_csv_null from mavedb.lib.validation.constants.general import null_values_list +from mavedb.lib.validation.utilities import is_null as validate_is_null from mavedb.models.contributor import Contributor from mavedb.models.controlled_keyword import ControlledKeyword from mavedb.models.doi_identifier import DoiIdentifier @@ -47,6 +49,10 @@ from mavedb.models.variant import Variant from mavedb.view_models.search import ScoreSetsSearch +if TYPE_CHECKING: + from mavedb.lib.authentication import UserData + from mavedb.lib.permissions import Action + VariantData = dict[str, Optional[dict[str, dict]]] logger = logging.getLogger(__name__) @@ -68,9 +74,6 @@ def search_score_sets(db: Session, owner_or_contributor: Optional[User], search: query = db.query(ScoreSet) # \ # .filter(ScoreSet.private.is_(False)) - # filter out the score sets that are replaced by other score sets - query = query.filter(~ScoreSet.superseding_score_set.has()) - if owner_or_contributor is not None: query = query.filter( or_( @@ -262,6 +265,41 @@ def search_score_sets(db: Session, owner_or_contributor: Optional[User], search: return score_sets # filter_visible_score_sets(score_sets) +def fetch_superseding_score_set_in_search_result( + score_sets: list[ScoreSet], + requesting_user: Optional["UserData"], + search: ScoreSetsSearch) -> list[ScoreSet]: + """ + Remove superseded score set from search results. + Check whether all of the score set are correct versions. + """ + from mavedb.lib.permissions import Action + if search.published: + filtered_score_sets_tail = [ + find_publish_or_private_superseded_score_set_tail( + score_set, + Action.READ, + requesting_user, + search.published + ) for score_set in score_sets + ] + else: + filtered_score_sets_tail = [ + find_superseded_score_set_tail( + score_set, + Action.READ, + requesting_user + ) for score_set in score_sets + ] + # Remove None item. + filtered_score_sets = [score_set for score_set in filtered_score_sets_tail if score_set is not None] + if filtered_score_sets: + final_score_sets = sorted(set(filtered_score_sets), key=attrgetter("urn")) + else: + final_score_sets = [] + return final_score_sets + + def find_meta_analyses_for_experiment_sets(db: Session, urns: list[str]) -> list[ScoreSet]: """ Find all score sets that are meta-analyses for score sets from a specified collection of experiment sets. @@ -306,11 +344,66 @@ def find_meta_analyses_for_experiment_sets(db: Session, urns: list[str]) -> list ) +def find_superseded_score_set_tail( + score_set: ScoreSet, + action: Optional["Action"] = None, + user_data: Optional["UserData"] = None) -> Optional[ScoreSet]: + from mavedb.lib.permissions import has_permission + while score_set.superseding_score_set is not None: + next_score_set_in_chain = score_set.superseding_score_set + + # If we were given a permission to check and the next score set in the chain does not have that permission, + # pretend like we have reached the end of the chain. Otherwise, continue to the next score set. + if action is not None and not has_permission(user_data, next_score_set_in_chain, action).permitted: + return score_set + + score_set = next_score_set_in_chain + + # Handle unpublished superseding score set case. + # The score set has a published superseded score set but has not superseding score set. + if action is not None and not has_permission(user_data, score_set, action).permitted: + while score_set.superseded_score_set is not None: + next_score_set_in_chain = score_set.superseded_score_set + if has_permission(user_data, next_score_set_in_chain, action).permitted: + return next_score_set_in_chain + else: + score_set = next_score_set_in_chain + return None + + return score_set + + +def find_publish_or_private_superseded_score_set_tail( + score_set: ScoreSet, + action: Optional["Action"] = None, + user_data: Optional["UserData"] = None, + publish: bool = True) -> Optional[ScoreSet]: + from mavedb.lib.permissions import has_permission + if publish: + while score_set.superseding_score_set is not None: + next_score_set_in_chain = score_set.superseding_score_set + # Find the final published one. + if action is not None and has_permission(user_data, score_set, action).permitted \ + and next_score_set_in_chain.published_date is None: + return score_set + score_set = next_score_set_in_chain + else: + # Unpublished score set should not be superseded. + # It should not have superseding score set, but possible have superseded score set. + if action is not None and score_set.published_date is None \ + and has_permission(user_data, score_set, action).permitted: + return score_set + else: + return None + return score_set + + def get_score_set_counts_as_csv( db: Session, score_set: ScoreSet, start: Optional[int] = None, limit: Optional[int] = None, + drop_na_columns: Optional[bool] = None, ) -> str: assert type(score_set.dataset_columns) is dict count_columns = [str(x) for x in list(score_set.dataset_columns.get("count_columns", []))] @@ -329,6 +422,9 @@ def get_score_set_counts_as_csv( variants = db.scalars(variants_query).all() rows_data = variants_to_csv_rows(variants, columns=columns, dtype=type_column) + if drop_na_columns: + rows_data, columns = drop_na_columns_from_csv_file_rows(rows_data, columns) + stream = io.StringIO() writer = csv.DictWriter(stream, fieldnames=columns, quoting=csv.QUOTE_MINIMAL) writer.writeheader() @@ -341,6 +437,7 @@ def get_score_set_scores_as_csv( score_set: ScoreSet, start: Optional[int] = None, limit: Optional[int] = None, + drop_na_columns: Optional[bool] = None, ) -> str: assert type(score_set.dataset_columns) is dict score_columns = [str(x) for x in list(score_set.dataset_columns.get("score_columns", []))] @@ -359,6 +456,9 @@ def get_score_set_scores_as_csv( variants = db.scalars(variants_query).all() rows_data = variants_to_csv_rows(variants, columns=columns, dtype=type_column) + if drop_na_columns: + rows_data, columns = drop_na_columns_from_csv_file_rows(rows_data, columns) + stream = io.StringIO() writer = csv.DictWriter(stream, fieldnames=columns, quoting=csv.QUOTE_MINIMAL) writer.writeheader() @@ -366,6 +466,28 @@ def get_score_set_scores_as_csv( 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) diff --git a/src/mavedb/lib/urns.py b/src/mavedb/lib/urns.py index 534264cf..f58c8b96 100644 --- a/src/mavedb/lib/urns.py +++ b/src/mavedb/lib/urns.py @@ -1,6 +1,7 @@ import logging import re import string +from uuid import uuid4 from sqlalchemy import func from sqlalchemy.orm import Session @@ -130,3 +131,14 @@ def generate_score_set_urn(db: Session, experiment: Experiment): max_suffix_number = suffix_number next_suffix_number = max_suffix_number + 1 return f"{experiment_urn}-{next_suffix_number}" + + +def generate_collection_urn(): + """ + Generate a new URN for a collection. + + Collection URNs include a 16-digit UUID. + + :return: A new collection URN + """ + return f"urn:mavedb:collection-{uuid4()}" diff --git a/src/mavedb/lib/validation/urn_re.py b/src/mavedb/lib/validation/urn_re.py index 2ae7358e..82feb19a 100644 --- a/src/mavedb/lib/validation/urn_re.py +++ b/src/mavedb/lib/validation/urn_re.py @@ -8,6 +8,10 @@ MAVEDB_TMP_URN_PATTERN = r"tmp:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" MAVEDB_TMP_URN_RE = re.compile(MAVEDB_TMP_URN_PATTERN) +# Old temp URN +MAVEDB_OLD_TMP_URN_PATTERN = r"^tmp:[A-Za-z0-9]{16}$" +MAVEDB_OLD_TMP_URN_RE = re.compile(MAVEDB_OLD_TMP_URN_PATTERN) + # Experiment set URN MAVEDB_EXPERIMENT_SET_URN_PATTERN = rf"urn:{MAVEDB_URN_NAMESPACE}:\d{{{MAVEDB_EXPERIMENT_SET_URN_DIGITS}}}" MAVEDB_EXPERIMENT_SET_URN_RE = re.compile(MAVEDB_EXPERIMENT_SET_URN_PATTERN) @@ -24,6 +28,10 @@ MAVEDB_VARIANT_URN_PATTERN = rf"{MAVEDB_SCORE_SET_URN_PATTERN}#[1-9]\d*" MAVEDB_VARIANT_URN_RE = re.compile(MAVEDB_VARIANT_URN_PATTERN) +# Collection URN +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) + # Any URN MAVEDB_ANY_URN_PATTERN = "|".join( [ diff --git a/src/mavedb/models/__init__.py b/src/mavedb/models/__init__.py index 82d8e621..0143a84f 100644 --- a/src/mavedb/models/__init__.py +++ b/src/mavedb/models/__init__.py @@ -1,5 +1,6 @@ __all__ = [ "access_key", + "collection", "controlled_keyword", "doi_identifier", "ensembl_identifier", diff --git a/src/mavedb/models/collection.py b/src/mavedb/models/collection.py new file mode 100644 index 00000000..b3fb3e71 --- /dev/null +++ b/src/mavedb/models/collection.py @@ -0,0 +1,67 @@ +from datetime import date + +from sqlalchemy import Boolean, Column, Date, ForeignKey, Integer, String +from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy +from sqlalchemy.orm import Mapped, relationship + +import mavedb.models.collection_user_association +from mavedb.db.base import Base +from mavedb.lib.urns import generate_collection_urn +from mavedb.models.collection_association import ( + collection_experiments_association_table, + collection_score_sets_association_table, +) + +from .experiment import Experiment +from .score_set import ScoreSet +from .user import User + + +class Collection(Base): + __tablename__ = "collections" + + id = Column(Integer, primary_key=True) + + urn = Column( + String(64), + nullable=True, + default=generate_collection_urn, + unique=True, + index=True, + ) + private = Column(Boolean, nullable=False, default=True) + + name = Column(String, nullable=False) + badge_name = Column(String, nullable=True) + description = Column(String, nullable=True) + + created_by_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=True) + created_by: Mapped[User] = relationship("User", foreign_keys="Collection.created_by_id") + modified_by_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=True) + modified_by: Mapped[User] = relationship("User", foreign_keys="Collection.modified_by_id") + creation_date = Column(Date, nullable=False, default=date.today) + modification_date = Column(Date, nullable=False, default=date.today, onupdate=date.today) + + user_associations: Mapped[list[mavedb.models.collection_user_association.CollectionUserAssociation]] = relationship( + "CollectionUserAssociation", + back_populates="collection", + cascade="all, delete-orphan", + ) + users: AssociationProxy[list[User]] = association_proxy( + "user_associations", + "user", + creator=lambda u: mavedb.models.collection_user_association.CollectionUserAssociation( + user=u, contribution_role=u.role + ), + ) + + experiments: Mapped[list[Experiment]] = relationship( + "Experiment", + secondary=collection_experiments_association_table, + back_populates="collections", + ) + score_sets: Mapped[list[ScoreSet]] = relationship( + "ScoreSet", + secondary=collection_score_sets_association_table, + back_populates="collections", + ) diff --git a/src/mavedb/models/collection_association.py b/src/mavedb/models/collection_association.py new file mode 100644 index 00000000..6b5b2580 --- /dev/null +++ b/src/mavedb/models/collection_association.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, ForeignKey +from sqlalchemy.schema import Table + +from mavedb.db.base import Base + +collection_experiments_association_table = Table( + "collection_experiments", + Base.metadata, + Column("collection_id", ForeignKey("collections.id"), primary_key=True), + Column("experiment_id", ForeignKey("experiments.id"), primary_key=True), +) + +collection_score_sets_association_table = Table( + "collection_score_sets", + Base.metadata, + Column("collection_id", ForeignKey("collections.id"), primary_key=True), + Column("score_set_id", ForeignKey("scoresets.id"), primary_key=True), +) diff --git a/src/mavedb/models/collection_user_association.py b/src/mavedb/models/collection_user_association.py new file mode 100644 index 00000000..77a28877 --- /dev/null +++ b/src/mavedb/models/collection_user_association.py @@ -0,0 +1,34 @@ +# Prevent circular imports +from typing import TYPE_CHECKING + +from sqlalchemy import Enum, Column, ForeignKey, Integer +from sqlalchemy.orm import Mapped, relationship + +from mavedb.db.base import Base +from mavedb.models.enums.contribution_role import ContributionRole + +if TYPE_CHECKING: + from mavedb.models.user import User + from mavedb.models.collection import Collection + + +class CollectionUserAssociation(Base): + __tablename__ = "collection_user_associations" + + collection_id = Column("collection_id", Integer, ForeignKey("collections.id"), primary_key=True) + user_id = Column("user_id", Integer, ForeignKey("users.id"), primary_key=True) + contribution_role: Mapped["ContributionRole"] = Column( + Enum( + ContributionRole, + create_constraint=True, + length=32, + native_enum=False, + validate_strings=True, + ), + nullable=False, + ) + + collection: Mapped["Collection"] = relationship( + "mavedb.models.collection.Collection", back_populates="user_associations" + ) + user: Mapped["User"] = relationship("User") diff --git a/src/mavedb/models/enums/contribution_role.py b/src/mavedb/models/enums/contribution_role.py new file mode 100644 index 00000000..bf25b99d --- /dev/null +++ b/src/mavedb/models/enums/contribution_role.py @@ -0,0 +1,7 @@ +import enum + + +class ContributionRole(enum.Enum): + admin = "admin" + editor = "editor" + viewer = "viewer" diff --git a/src/mavedb/models/experiment.py b/src/mavedb/models/experiment.py index bfd53f8d..2fbdef83 100644 --- a/src/mavedb/models/experiment.py +++ b/src/mavedb/models/experiment.py @@ -10,11 +10,18 @@ from mavedb.db.base import Base from mavedb.lib.temp_urns import generate_temp_urn +from mavedb.models.collection_association import ( + collection_experiments_association_table, +) from mavedb.models.contributor import Contributor from mavedb.models.controlled_keyword import ControlledKeyword from mavedb.models.doi_identifier import DoiIdentifier -from mavedb.models.experiment_controlled_keyword import ExperimentControlledKeywordAssociation -from mavedb.models.experiment_publication_identifier import ExperimentPublicationIdentifierAssociation +from mavedb.models.experiment_controlled_keyword import ( + ExperimentControlledKeywordAssociation, +) +from mavedb.models.experiment_publication_identifier import ( + ExperimentPublicationIdentifierAssociation, +) from mavedb.models.experiment_set import ExperimentSet from mavedb.models.legacy_keyword import LegacyKeyword from mavedb.models.publication_identifier import PublicationIdentifier @@ -22,6 +29,7 @@ from mavedb.models.user import User if TYPE_CHECKING: + from mavedb.models.collection import Collection from mavedb.models.score_set import ScoreSet experiments_contributors_association_table = Table( @@ -75,6 +83,19 @@ class Experiment(Base): num_score_sets = Column("num_scoresets", Integer, nullable=False, default=0) score_sets: Mapped[List["ScoreSet"]] = relationship(back_populates="experiment", cascade="all, delete-orphan") + collections: Mapped[list["Collection"]] = relationship( + "Collection", + secondary=collection_experiments_association_table, + back_populates="experiments", + ) + official_collections: Mapped[list["Collection"]] = relationship( + "Collection", + secondary=collection_experiments_association_table, + secondaryjoin="and_(collection_experiments.c.collection_id == Collection.id, Collection.badge_name != None)", + back_populates="experiments", + viewonly=True, + ) + experiment_set_id = Column(Integer, ForeignKey("experiment_sets.id"), index=True, nullable=True) experiment_set: Mapped[Optional[ExperimentSet]] = relationship(back_populates="experiments") @@ -85,19 +106,27 @@ class Experiment(Base): creation_date = Column(Date, nullable=False, default=date.today) modification_date = Column(Date, nullable=False, default=date.today, onupdate=date.today) contributors: Mapped[list["Contributor"]] = relationship( - "Contributor", secondary=experiments_contributors_association_table, backref="experiments" + "Contributor", + secondary=experiments_contributors_association_table, + backref="experiments", ) keyword_objs: Mapped[list["ExperimentControlledKeywordAssociation"]] = relationship( back_populates="experiment", cascade="all, delete-orphan" ) legacy_keyword_objs: Mapped[list[LegacyKeyword]] = relationship( - "LegacyKeyword", secondary=experiments_legacy_keywords_association_table, backref="experiments" + "LegacyKeyword", + secondary=experiments_legacy_keywords_association_table, + backref="experiments", ) doi_identifiers: Mapped[list[DoiIdentifier]] = relationship( - "DoiIdentifier", secondary=experiments_doi_identifiers_association_table, backref="experiments" + "DoiIdentifier", + secondary=experiments_doi_identifiers_association_table, + backref="experiments", ) publication_identifier_associations: Mapped[list[ExperimentPublicationIdentifierAssociation]] = relationship( - "ExperimentPublicationIdentifierAssociation", back_populates="experiment", cascade="all, delete-orphan" + "ExperimentPublicationIdentifierAssociation", + back_populates="experiment", + cascade="all, delete-orphan", ) publication_identifiers: AssociationProxy[List[PublicationIdentifier]] = association_proxy( "publication_identifier_associations", @@ -107,7 +136,9 @@ class Experiment(Base): # sra_identifiers = relationship('SraIdentifier', secondary=experiments_sra_identifiers_association_table, backref='experiments') raw_read_identifiers: Mapped[list[RawReadIdentifier]] = relationship( - "RawReadIdentifier", secondary=experiments_raw_read_identifiers_association_table, backref="experiments" + "RawReadIdentifier", + secondary=experiments_raw_read_identifiers_association_table, + backref="experiments", ) # Unfortunately, we can't use association_proxy here, because in spite of what the documentation seems to imply, it @@ -157,7 +188,10 @@ async def set_keywords(self, db, keywords: list): ExperimentControlledKeywordAssociation( experiment=self, controlled_keyword=await self._find_keyword( - db, keyword_obj.keyword.key, keyword_obj.keyword.value, keyword_obj.keyword.vocabulary + db, + keyword_obj.keyword.key, + keyword_obj.keyword.value, + keyword_obj.keyword.vocabulary, ), description=keyword_obj.description, ) diff --git a/src/mavedb/models/score_set.py b/src/mavedb/models/score_set.py index 40cfce21..ecf63be9 100644 --- a/src/mavedb/models/score_set.py +++ b/src/mavedb/models/score_set.py @@ -9,6 +9,7 @@ import mavedb.models.score_set_publication_identifier from mavedb.db.base import Base +from mavedb.models.collection_association import collection_score_sets_association_table from mavedb.models.contributor import Contributor from mavedb.models.doi_identifier import DoiIdentifier from mavedb.models.enums.mapping_state import MappingState @@ -20,6 +21,7 @@ from mavedb.models.user import User if TYPE_CHECKING: + from mavedb.models.collection import Collection from mavedb.models.target_gene import TargetGene from mavedb.models.variant import Variant @@ -88,7 +90,13 @@ class ScoreSet(Base): approved = Column(Boolean, nullable=False, default=False) published_date = Column(Date, nullable=True) processing_state = Column( - Enum(ProcessingState, create_constraint=True, length=32, native_enum=False, validate_strings=True), + Enum( + ProcessingState, + create_constraint=True, + length=32, + native_enum=False, + validate_strings=True, + ), nullable=True, ) processing_errors = Column(JSONB, nullable=True) @@ -98,7 +106,13 @@ class ScoreSet(Base): variants: Mapped[list["Variant"]] = relationship(back_populates="score_set", cascade="all, delete-orphan") mapping_state = Column( - Enum(MappingState, create_constraint=True, length=32, native_enum=False, validate_strings=True), + Enum( + MappingState, + create_constraint=True, + length=32, + native_enum=False, + validate_strings=True, + ), nullable=True, ) mapping_errors = Column(JSONB, nullable=True) @@ -111,7 +125,10 @@ class ScoreSet(Base): license: Mapped["License"] = relationship("License") superseded_score_set_id = Column("replaces_id", Integer, ForeignKey("scoresets.id"), index=True, nullable=True) superseded_score_set: Mapped[Optional["ScoreSet"]] = relationship( - "ScoreSet", uselist=False, foreign_keys="ScoreSet.superseded_score_set_id", remote_side=[id] + "ScoreSet", + uselist=False, + foreign_keys="ScoreSet.superseded_score_set_id", + remote_side=[id], ) superseding_score_set: Mapped[Optional["ScoreSet"]] = relationship( "ScoreSet", uselist=False, back_populates="superseded_score_set" @@ -125,18 +142,26 @@ class ScoreSet(Base): modification_date = Column(Date, nullable=False, default=date.today, onupdate=date.today) legacy_keyword_objs: Mapped[list["LegacyKeyword"]] = relationship( - "LegacyKeyword", secondary=score_sets_legacy_keywords_association_table, backref="score_sets" + "LegacyKeyword", + secondary=score_sets_legacy_keywords_association_table, + backref="score_sets", ) contributors: Mapped[list["Contributor"]] = relationship( - "Contributor", secondary=score_sets_contributors_association_table, backref="score_sets" + "Contributor", + secondary=score_sets_contributors_association_table, + backref="score_sets", ) doi_identifiers: Mapped[list["DoiIdentifier"]] = relationship( - "DoiIdentifier", secondary=score_sets_doi_identifiers_association_table, backref="score_sets" + "DoiIdentifier", + secondary=score_sets_doi_identifiers_association_table, + backref="score_sets", ) publication_identifier_associations: Mapped[ list[mavedb.models.score_set_publication_identifier.ScoreSetPublicationIdentifierAssociation] ] = relationship( - "ScoreSetPublicationIdentifierAssociation", back_populates="score_set", cascade="all, delete-orphan" + "ScoreSetPublicationIdentifierAssociation", + back_populates="score_set", + cascade="all, delete-orphan", ) publication_identifiers: AssociationProxy[List[PublicationIdentifier]] = association_proxy( "publication_identifier_associations", @@ -157,6 +182,20 @@ class ScoreSet(Base): target_genes: Mapped[List["TargetGene"]] = relationship(back_populates="score_set", cascade="all, delete-orphan") score_ranges = Column(JSONB, nullable=True) + score_calibrations = Column(JSONB, nullable=True) + + collections: Mapped[list["Collection"]] = relationship( + "Collection", + secondary=collection_score_sets_association_table, + back_populates="score_sets", + ) + official_collections: Mapped[list["Collection"]] = relationship( + "Collection", + secondary=collection_score_sets_association_table, + secondaryjoin="and_(collection_score_sets.c.collection_id == Collection.id, Collection.badge_name != None)", + back_populates="score_sets", + viewonly=True, + ) # Unfortunately, we can't use association_proxy here, because in spite of what the documentation seems to imply, it # doesn't check for a pre-existing keyword with the same text. diff --git a/src/mavedb/routers/collections.py b/src/mavedb/routers/collections.py new file mode 100644 index 00000000..f813ce5b --- /dev/null +++ b/src/mavedb/routers/collections.py @@ -0,0 +1,820 @@ +import logging +from datetime import date +from typing import Any, Dict, Optional, Sequence + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.encoders import jsonable_encoder +from sqlalchemy import and_, select +from sqlalchemy.orm import Session +from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound + +from mavedb import deps +from mavedb.lib.authentication import UserData, get_current_user +from mavedb.lib.authorization import require_current_user, require_current_user_with_email +from mavedb.lib.logging import LoggedRoute +from mavedb.lib.logging.context import ( + format_raised_exception_info_as_dict, + logging_context, + save_to_logging_context, +) +from mavedb.lib.permissions import Action, assert_permission, has_permission +from mavedb.models.collection import Collection +from mavedb.models.collection_user_association import CollectionUserAssociation +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.models.experiment import Experiment +from mavedb.models.score_set import ScoreSet +from mavedb.models.user import User +from mavedb.view_models import collection +from mavedb.view_models import collection_bundle + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/v1", + tags=["collections"], + responses={404: {"description": "Not found"}}, + route_class=LoggedRoute, +) + + +@router.get( + "/users/me/collections", + status_code=200, + response_model=collection_bundle.CollectionBundle, + response_model_exclude_none=True, +) +def list_my_collections( + *, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user), +) -> Dict[str, Sequence[Collection]]: + """ + List my collections. + """ + collection_bundle: Dict[str, Sequence[Collection]] = {} + for role in ContributionRole: + collection_bundle[role.value] = ( + db.execute( + select(Collection) + .join(CollectionUserAssociation) + .where(CollectionUserAssociation.user_id == user_data.user.id) + .where(CollectionUserAssociation.contribution_role == role.value) + ) + .scalars() + .all() + ) + + for item in collection_bundle[role.value]: + # filter score sets and experiments based on user permissions + item.score_sets = [ + score_set for score_set in item.score_sets if has_permission(user_data, score_set, Action.READ) + ] + item.experiments = [ + experiment for experiment in item.experiments if has_permission(user_data, experiment, Action.READ) + ] + # unless user is admin of this collection, filter users to only admins + # the rationale is that all collection contributors should be able to see admins + # to know who to contact, but only collection admins should be able to see viewers and editors + if role in (ContributionRole.viewer, ContributionRole.editor): + admins = [] + for user_assoc in item.user_associations: + if user_assoc.contribution_role == ContributionRole.admin: + admin = user_assoc.user + # role must be set in order to assign users to collection + setattr(admin, "role", ContributionRole.admin) + admins.append(admin) + item.users = admins + + return collection_bundle + + +@router.get( + "/collections/{urn}", + status_code=200, + response_model=collection.Collection, + responses={404: {}}, + response_model_exclude_none=True, +) +def fetch_collection( + *, + urn: str, + db: Session = Depends(deps.get_db), + user_data: Optional[UserData] = Depends(get_current_user), +) -> Collection: + """ + Fetch a single collection by URN. + """ + save_to_logging_context({"requested_resource": urn}) + + item = db.execute(select(Collection).where(Collection.urn == urn)).scalars().one_or_none() + if not item: + logger.debug(msg="The requested collection does not exist.", extra=logging_context()) + raise HTTPException(status_code=404, detail=f"Collection with URN {urn} not found") + + assert_permission(user_data, item, Action.READ) + # filter score sets and experiments based on user permissions + item.score_sets = [score_set for score_set in item.score_sets if has_permission(user_data, score_set, Action.READ)] + item.experiments = [ + experiment for experiment in item.experiments if has_permission(user_data, experiment, Action.READ) + ] + + # Only collection admins can see all user roles for the collection. Other users can only see the list of admins. + # We could create a new permission action for this. But for now, assume that any user who has the ADD_ROLE + # permission is a collection admin and should be able to see all user roles for the collection. + if not has_permission(user_data, item, Action.ADD_ROLE): + admins = [] + for user_assoc in item.user_associations: + if user_assoc.contribution_role == ContributionRole.admin: + admin = user_assoc.user + # role must be set in order to assign users to collection + setattr(admin, "role", ContributionRole.admin) + admins.append(admin) + item.users = admins + + return item + + +@router.post( + "/collections/", + response_model=collection.Collection, + responses={422: {}}, + response_model_exclude_none=True, +) +async def create_collection( + *, + item_create: collection.CollectionCreate, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user_with_email), +) -> Any: + """ + Create a collection. + """ + logger.debug(msg="Began creation of new collection.", extra=logging_context()) + + users = [] + user_orcid_ids = set() + + try: + # always assign creator as admin, as collections permissions do not distinguish between owner/creator and admin + creator_user = user_data.user + setattr(creator_user, "role", ContributionRole.admin) + users.append(creator_user) + user_orcid_ids.add(creator_user.username) + + for admin in item_create.admins or []: + admin_orcid = admin.orcid_id + if admin_orcid not in user_orcid_ids: + user = db.scalars(select(User).where(User.username == admin_orcid)).one() + setattr(user, "role", ContributionRole.admin) + users.append(user) + user_orcid_ids.add(admin_orcid) + + for editor in item_create.editors or []: + editor_orcid = editor.orcid_id + if editor_orcid not in user_orcid_ids: + user = db.scalars(select(User).where(User.username == editor_orcid)).one() + setattr(user, "role", ContributionRole.editor) + users.append(user) + user_orcid_ids.add(editor_orcid) + + for viewer in item_create.viewers or []: + viewer_orcid = viewer.orcid_id + if viewer_orcid not in user_orcid_ids: + user = db.scalars(select(User).where(User.username == viewer_orcid)).one() + setattr(user, "role", ContributionRole.viewer) + users.append(user) + user_orcid_ids.add(viewer_orcid) + + except NoResultFound as e: + save_to_logging_context(format_raised_exception_info_as_dict(e)) + logger.error( + msg="No existing user found with the given ORCID iD", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail="No MaveDB user found with the given ORCID iD") + + except MultipleResultsFound as e: + save_to_logging_context(format_raised_exception_info_as_dict(e)) + logger.error(msg="Multiple users found with the given ORCID iD", extra=logging_context()) + raise HTTPException( + status_code=400, + detail="Multiple MaveDB users found with the given ORCID iD", + ) + + try: + score_sets = [ + db.scalars(select(ScoreSet).where(ScoreSet.urn == score_set_urn)).one() + for score_set_urn in item_create.score_set_urns or [] + ] + + experiments = [ + db.scalars(select(Experiment).where(Experiment.urn == experiment_urn)).one() + for experiment_urn in item_create.experiment_urns or [] + ] + + except NoResultFound as e: + save_to_logging_context(format_raised_exception_info_as_dict(e)) + logger.error(msg="No resource found with the given URN", extra=logging_context()) + raise HTTPException(status_code=404, detail="No resource found with the given URN") + + except MultipleResultsFound as e: + save_to_logging_context(format_raised_exception_info_as_dict(e)) + logger.error(msg="Multiple resources found with the given URN", extra=logging_context()) + raise HTTPException(status_code=400, detail="Multiple resources found with the given URN") + + item = Collection( + **jsonable_encoder( + item_create, + by_alias=False, + exclude={ + "viewers", + "editors", + "admins", + "score_set_urns", + "experiment_urns", + "badge_name", + }, + ), + users=users, + score_sets=score_sets, + experiments=experiments, + created_by=user_data.user, + modified_by=user_data.user, + ) # type: ignore + + db.add(item) + db.commit() + db.refresh(item) + + save_to_logging_context({"created_resource": item.urn}) + return item + + +@router.patch( + "/collections/{urn}", + response_model=collection.Collection, + responses={422: {}}, + response_model_exclude_none=True, +) +async def update_collection( + *, + item_update: collection.CollectionModify, + urn: str, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user_with_email), +) -> Any: + """ + Modify a collection's metadata. + """ + save_to_logging_context({"requested_resource": urn}) + logger.debug(msg="Began collection metadata update.", extra=logging_context()) + + item = db.execute(select(Collection).where(Collection.urn == urn)).scalars().one_or_none() + if item is None: + logger.info( + msg="Failed to update collection; The requested collection does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"collection with URN {urn} not found") + + assert_permission(user_data, item, Action.UPDATE) + + # Editors may update metadata, but not all editors can publish (which is just setting private to public). + if item.private and not item_update.private: + assert_permission(user_data, item, Action.PUBLISH) + + # Unpublishing requires the same permissions as publishing. + if not item.private and item_update.private: + assert_permission(user_data, item, Action.PUBLISH) + + if item_update.badge_name: + assert_permission(user_data, item, Action.ADD_BADGE) + + # Only access fields set by the user. Note the value of set fields will be updated even if the value is None + pairs = {k: v for k, v in vars(item_update).items() if k in item_update.__fields_set__} + for var, value in pairs.items(): # vars(item_update).items(): + setattr(item, var, value) + + item.modified_by = user_data.user + + db.add(item) + db.commit() + db.refresh(item) + + save_to_logging_context({"updated_resource": item.urn}) + # filter score sets and experiments based on user permissions + # note that this filtering occurs after saving changes to db; the filtering is only for the returned view model + item.score_sets = [score_set for score_set in item.score_sets if has_permission(user_data, score_set, Action.READ)] + item.experiments = [ + experiment for experiment in item.experiments if has_permission(user_data, experiment, Action.READ) + ] + + # Only collection admins can see all user roles for the collection. Other users can only see the list of admins. + # We could create a new permission action for this. But for now, assume that any user who has the ADD_ROLE + # permission is a collection admin and should be able to see all user roles for the collection. + if not has_permission(user_data, item, Action.ADD_ROLE): + admins = [] + for user_assoc in item.user_associations: + if user_assoc.contribution_role == ContributionRole.admin: + admin = user_assoc.user + # role must be set in order to assign users to collection + setattr(admin, "role", ContributionRole.admin) + admins.append(admin) + item.users = admins + + return item + + +@router.post( + "/collections/{collection_urn}/score-sets", + response_model=collection.Collection, + responses={422: {}}, +) +async def add_score_set_to_collection( + *, + body: collection.AddScoreSetToCollectionRequest, + collection_urn: str, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user_with_email), +) -> Any: + """ + Add an existing score set to an existing collection. + """ + save_to_logging_context({"requested_resource": collection_urn}) + + item = db.execute(select(Collection).where(Collection.urn == collection_urn)).scalars().one_or_none() + if not item: + logger.info( + msg="Failed to add score set to collection; The requested collection does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"collection with URN '{collection_urn}' not found") + + score_set = db.execute(select(ScoreSet).where(ScoreSet.urn == body.score_set_urn)).scalars().one_or_none() + if not score_set: + logger.info( + msg="Failed to add score set to collection; The requested score set does not exist.", + extra=logging_context(), + ) + raise HTTPException( + status_code=404, + detail=f"score set with URN '{body.score_set_urn}' not found", + ) + + assert_permission(user_data, item, Action.ADD_SCORE_SET) + + item.score_sets.append(score_set) + item.modification_date = date.today() + item.modified_by = user_data.user + + db.add(item) + db.commit() + db.refresh(item) + + save_to_logging_context({"updated_resource": item.urn}) + + # filter score sets and experiments based on user permissions + # note that this filtering occurs after saving changes to db; the filtering is only for the returned view model + item.score_sets = [score_set for score_set in item.score_sets if has_permission(user_data, score_set, Action.READ)] + item.experiments = [ + experiment for experiment in item.experiments if has_permission(user_data, experiment, Action.READ) + ] + + # Only collection admins can see all user roles for the collection. Other users can only see the list of admins. + # We could create a new permission action for this. But for now, assume that any user who has the ADD_ROLE + # permission is a collection admin and should be able to see all user roles for the collection. + if not has_permission(user_data, item, Action.ADD_ROLE): + admins = [] + for user_assoc in item.user_associations: + if user_assoc.contribution_role == ContributionRole.admin: + admin = user_assoc.user + # role must be set in order to assign users to collection + setattr(admin, "role", ContributionRole.admin) + admins.append(admin) + item.users = admins + + return item + + +@router.delete( + "/collections/{collection_urn}/score-sets/{score_set_urn}", + response_model=collection.Collection, + responses={422: {}}, +) +async def delete_score_set_from_collection( + *, + collection_urn: str, + score_set_urn: str, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user_with_email), +) -> Any: + """ + Remove a score set from an existing collection. Preserves the score set in the database, only removes the association between the score set and the collection. + """ + save_to_logging_context({"requested_resource": collection_urn}) + + item = db.execute(select(Collection).where(Collection.urn == collection_urn)).scalars().one_or_none() + if not item: + logger.info( + msg="Failed to remove score set from collection; The requested collection does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"collection with URN '{collection_urn}' not found") + + score_set = db.execute(select(ScoreSet).where(ScoreSet.urn == score_set_urn)).scalars().one_or_none() + if not score_set: + logger.info( + msg="Failed to remove score set from collection; The requested score set does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"score set with URN '{score_set_urn}' not found") + + if score_set not in item.score_sets: + logger.info( + msg="Failed to remove score set from collection; The requested score set is not associated with the requested collection.", + extra=logging_context(), + ) + raise HTTPException( + status_code=404, + detail=f"association between score set '{score_set_urn}' and collection '{collection_urn}' not found", + ) + + # add and remove permissions are the same + assert_permission(user_data, item, Action.ADD_SCORE_SET) + + item.score_sets.remove(score_set) + item.modification_date = date.today() + item.modified_by = user_data.user + + db.add(item) + db.commit() + db.refresh(item) + + save_to_logging_context({"updated_resource": item.urn}) + + # filter score sets and experiments based on user permissions + # note that this filtering occurs after saving changes to db; the filtering is only for the returned view model + item.score_sets = [score_set for score_set in item.score_sets if has_permission(user_data, score_set, Action.READ)] + item.experiments = [ + experiment for experiment in item.experiments if has_permission(user_data, experiment, Action.READ) + ] + + # Only collection admins can see all user roles for the collection. Other users can only see the list of admins. + # We could create a new permission action for this. But for now, assume that any user who has the ADD_ROLE + # permission is a collection admin and should be able to see all user roles for the collection. + if not has_permission(user_data, item, Action.ADD_ROLE): + admins = [] + for user_assoc in item.user_associations: + if user_assoc.contribution_role == ContributionRole.admin: + admin = user_assoc.user + # role must be set in order to assign users to collection + setattr(admin, "role", ContributionRole.admin) + admins.append(admin) + item.users = admins + + return item + + +@router.post( + "/collections/{collection_urn}/experiments", + response_model=collection.Collection, + responses={422: {}}, +) +async def add_experiment_to_collection( + *, + body: collection.AddExperimentToCollectionRequest, + collection_urn: str, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user_with_email), +) -> Any: + """ + Add an existing experiment to an existing collection. + """ + save_to_logging_context({"requested_resource": collection_urn}) + + item = db.execute(select(Collection).where(Collection.urn == collection_urn)).scalars().one_or_none() + if not item: + logger.info( + msg="Failed to add experiment to collection; The requested collection does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"collection with URN '{collection_urn}' not found") + + experiment = db.execute(select(Experiment).where(Experiment.urn == body.experiment_urn)).scalars().one_or_none() + if not experiment: + logger.info( + msg="Failed to add experiment to collection; The requested experiment does not exist.", + extra=logging_context(), + ) + raise HTTPException( + status_code=404, + detail=f"experiment with URN '{body.experiment_urn}' not found", + ) + + assert_permission(user_data, item, Action.ADD_EXPERIMENT) + + item.experiments.append(experiment) + item.modification_date = date.today() + item.modified_by = user_data.user + + db.add(item) + db.commit() + db.refresh(item) + + save_to_logging_context({"updated_resource": item.urn}) + + # filter score sets and experiments based on user permissions + # note that this filtering occurs after saving changes to db; the filtering is only for the returned view model + item.score_sets = [score_set for score_set in item.score_sets if has_permission(user_data, score_set, Action.READ)] + item.experiments = [ + experiment for experiment in item.experiments if has_permission(user_data, experiment, Action.READ) + ] + + # Only collection admins can see all user roles for the collection. Other users can only see the list of admins. + # We could create a new permission action for this. But for now, assume that any user who has the ADD_ROLE + # permission is a collection admin and should be able to see all user roles for the collection. + if not has_permission(user_data, item, Action.ADD_ROLE): + admins = [] + for user_assoc in item.user_associations: + if user_assoc.contribution_role == ContributionRole.admin: + admin = user_assoc.user + # role must be set in order to assign users to collection + setattr(admin, "role", ContributionRole.admin) + admins.append(admin) + item.users = admins + + return item + + +@router.delete( + "/collections/{collection_urn}/experiments/{experiment_urn}", + response_model=collection.Collection, + responses={422: {}}, +) +async def delete_experiment_from_collection( + *, + collection_urn: str, + experiment_urn: str, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user_with_email), +) -> Any: + """ + Remove an experiment from an existing collection. Preserves the experiment in the database, only removes the association between the experiment and the collection. + """ + save_to_logging_context({"requested_resource": collection_urn}) + + item = db.execute(select(Collection).where(Collection.urn == collection_urn)).scalars().one_or_none() + if not item: + logger.info( + msg="Failed to remove experiment from collection; The requested collection does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"collection with URN '{collection_urn}' not found") + + experiment = db.execute(select(Experiment).where(Experiment.urn == experiment_urn)).scalars().one_or_none() + if not experiment: + logger.info( + msg="Failed to remove experiment from collection; The requested experiment does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"experiment with URN '{experiment_urn}' not found") + + if experiment not in item.experiments: + logger.info( + msg="Failed to remove experiment from collection; The requested experiment is not associated with the requested collection.", + extra=logging_context(), + ) + raise HTTPException( + status_code=404, + detail=f"association between experiment '{experiment_urn}' and collection '{collection_urn}' not found", + ) + + # add and remove permissions are the same + assert_permission(user_data, item, Action.ADD_EXPERIMENT) + + item.experiments.remove(experiment) + item.modification_date = date.today() + item.modified_by = user_data.user + + db.add(item) + db.commit() + db.refresh(item) + + save_to_logging_context({"updated_resource": item.urn}) + + # filter score sets and experiments based on user permissions + # note that this filtering occurs after saving changes to db; the filtering is only for the returned view model + item.score_sets = [score_set for score_set in item.score_sets if has_permission(user_data, score_set, Action.READ)] + item.experiments = [ + experiment for experiment in item.experiments if has_permission(user_data, experiment, Action.READ) + ] + + # Only collection admins can see all user roles for the collection. Other users can only see the list of admins. + # We could create a new permission action for this. But for now, assume that any user who has the ADD_ROLE + # permission is a collection admin and should be able to see all user roles for the collection. + if not has_permission(user_data, item, Action.ADD_ROLE): + admins = [] + for user_assoc in item.user_associations: + if user_assoc.contribution_role == ContributionRole.admin: + admin = user_assoc.user + # role must be set in order to assign users to collection + setattr(admin, "role", ContributionRole.admin) + admins.append(admin) + item.users = admins + + return item + + +@router.post( + "/collections/{urn}/{role}s", + response_model=collection.Collection, + responses={422: {}}, +) +async def add_user_to_collection_role( + *, + body: collection.AddUserToCollectionRoleRequest, + urn: str, + role: ContributionRole, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user_with_email), +) -> Any: + """ + Add an existing user to a collection under the specified role. + Removes the user from any other roles in this collection. + """ + save_to_logging_context({"requested_resource": urn}) + + item = db.execute(select(Collection).where(Collection.urn == urn)).scalars().one_or_none() + if not item: + logger.info( + msg="Failed to add user to collection role; The requested collection does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"collection with URN '{urn}' not found") + + user = db.execute(select(User).where(User.username == body.orcid_id)).scalars().one_or_none() + if not user: + logger.info( + msg="Failed to add user to collection role; The requested user does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"user with ORCID iD '{body.orcid_id}' not found") + + # get current user role + collection_user_association = ( + db.execute( + select(CollectionUserAssociation) + .where(CollectionUserAssociation.collection_id == item.id) + .where(CollectionUserAssociation.user_id == user.id) + ) + .scalars() + .one_or_none() + ) + + assert_permission(user_data, item, Action.ADD_ROLE) + + # Since this is a post request, user should not already be in this role + if collection_user_association and collection_user_association.contribution_role == role: + logger.info( + msg="Failed to add user to collection role; the requested user already has the requested role for this collection.", + extra=logging_context(), + ) + raise HTTPException( + status_code=400, + detail=f"user with ORCID iD '{body.orcid_id}' is already a {role} for collection '{urn}'", + ) + # A user can only be in one role per collection, so remove from any other roles + elif collection_user_association: + item.users.remove(user) + + setattr(user, "role", role) + item.users.append(user) + + item.modified_by = user_data.user + + db.add(item) + db.commit() + db.refresh(item) + + save_to_logging_context({"updated_resource": item.urn}) + + # filter score sets and experiments based on user permissions + # note that this filtering occurs after saving changes to db; the filtering is only for the returned view model + item.score_sets = [score_set for score_set in item.score_sets if has_permission(user_data, score_set, Action.READ)] + item.experiments = [ + experiment for experiment in item.experiments if has_permission(user_data, experiment, Action.READ) + ] + + # Only collection admins can get to this point in the function, so here we don't need to filter the list of user + # roles to show only admins. + + return item + + +@router.delete( + "/collections/{urn}/{role}s/{orcid_id}", + response_model=collection.Collection, + responses={422: {}}, +) +async def remove_user_from_collection_role( + *, + urn: str, + role: ContributionRole, + orcid_id: str, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user_with_email), +) -> Any: + """ + Remove a user from a collection role. + """ + save_to_logging_context({"requested_resource": urn}) + + item = db.execute(select(Collection).where(Collection.urn == urn)).scalars().one_or_none() + if not item: + logger.info( + msg="Failed to add user to collection role; The requested collection does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"collection with URN '{urn}' not found") + + user = db.execute(select(User).where(User.username == orcid_id)).scalars().one_or_none() + if not user: + logger.info( + msg="Failed to add user to collection role; The requested user does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"user with ORCID iD '{orcid_id}' not found") + + # get current user role + collection_user_association = ( + db.execute( + select(CollectionUserAssociation).where( + and_( + CollectionUserAssociation.collection_id == item.id, + CollectionUserAssociation.user_id == user.id, + ) + ) + ) + .scalars() + .one_or_none() + ) + + assert_permission(user_data, item, Action.ADD_ROLE) + + # Since this is a post request, user should not already be in this role + if collection_user_association is not None and collection_user_association.contribution_role != role: + logger.info( + msg="Failed to remove user from collection role; the requested user does not currently hold the requested role for this collection.", + extra=logging_context(), + ) + raise HTTPException( + status_code=404, + detail=f"user with ORCID iD '{orcid_id}' does not currently hold the role {role} for collection '{urn}'", + ) + + item.users.remove(user) + item.modified_by = user_data.user + + db.add(item) + db.commit() + db.refresh(item) + + save_to_logging_context({"updated_resource": item.urn}) + + # filter score sets and experiments based on user permissions + # note that this filtering occurs after saving changes to db; the filtering is only for the returned view model + item.score_sets = [score_set for score_set in item.score_sets if has_permission(user_data, score_set, Action.READ)] + item.experiments = [ + experiment for experiment in item.experiments if has_permission(user_data, experiment, Action.READ) + ] + + # Only collection admins can get to this point in the function, so here we don't need to filter the list of user + # roles to show only admins. + + return item + + +@router.delete("/collections/{urn}", responses={422: {}}) +async def delete_collection( + *, + urn: str, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(require_current_user_with_email), +) -> Any: + """ + Delete a collection. + """ + save_to_logging_context({"requested_resource": urn}) + + item = db.execute(select(Collection).where(Collection.urn == urn)).scalars().one_or_none() + if not item: + logger.info( + msg="Failed to delete collection; The requested collection does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"collection with URN '{urn}' not found") + + assert_permission(user_data, item, Action.DELETE) + + db.delete(item) + db.commit() diff --git a/src/mavedb/routers/experiments.py b/src/mavedb/routers/experiments.py index ec0d65e0..458f40a5 100644 --- a/src/mavedb/routers/experiments.py +++ b/src/mavedb/routers/experiments.py @@ -23,7 +23,8 @@ from mavedb.lib.keywords import search_keyword from mavedb.lib.logging import LoggedRoute from mavedb.lib.logging.context import logging_context, save_to_logging_context -from mavedb.lib.permissions import Action, assert_permission, has_permission +from mavedb.lib.permissions import Action, assert_permission +from mavedb.lib.score_sets import find_superseded_score_set_tail from mavedb.lib.validation.exceptions import ValidationError from mavedb.lib.validation.keywords import validate_keyword_list from mavedb.models.contributor import Contributor @@ -166,20 +167,25 @@ def get_experiment_score_sets( .filter(~ScoreSet.superseding_score_set.has()) .all() ) - score_set_result[:] = [ - score_set for score_set in score_set_result if has_permission(user_data, score_set, Action.READ).permitted - ] - if not score_set_result: + filter_superseded_score_set_tails = [ + find_superseded_score_set_tail( + score_set, + Action.READ, + user_data + ) for score_set in score_set_result + ] + filtered_score_sets = [score_set for score_set in filter_superseded_score_set_tails if score_set is not None] + if not filtered_score_sets: save_to_logging_context({"associated_resources": []}) logger.info(msg="No score sets are associated with the requested experiment.", extra=logging_context()) raise HTTPException(status_code=404, detail="no associated score sets") else: - score_set_result.sort(key=attrgetter("urn")) + filtered_score_sets.sort(key=attrgetter("urn")) save_to_logging_context({"associated_resources": [item.urn for item in score_set_result]}) - return score_set_result + return filtered_score_sets @router.post( diff --git a/src/mavedb/routers/permissions.py b/src/mavedb/routers/permissions.py index 02187a17..c10f49e2 100644 --- a/src/mavedb/routers/permissions.py +++ b/src/mavedb/routers/permissions.py @@ -10,6 +10,7 @@ from mavedb.lib.logging import LoggedRoute from mavedb.lib.logging.context import logging_context, save_to_logging_context from mavedb.lib.permissions import Action, has_permission +from mavedb.models.collection import Collection from mavedb.models.experiment import Experiment from mavedb.models.experiment_set import ExperimentSet from mavedb.models.score_set import ScoreSet @@ -25,12 +26,17 @@ class ModelName(str, Enum): + collection = "collection" experiment = "experiment" experiment_set = "experiment-set" score_set = "score-set" -@router.get("/user-is-permitted/{model_name}/{urn}/{action}", status_code=200, response_model=bool) +@router.get( + "/user-is-permitted/{model_name}/{urn}/{action}", + status_code=200, + response_model=bool, +) async def check_permission( *, model_name: ModelName, @@ -44,7 +50,7 @@ async def check_permission( """ save_to_logging_context({"requested_resource": urn}) - item: Optional[Union[ExperimentSet, Experiment, ScoreSet]] = None + item: Optional[Union[Collection, ExperimentSet, Experiment, ScoreSet]] = None if model_name == ModelName.experiment_set: item = db.query(ExperimentSet).filter(ExperimentSet.urn == urn).one_or_none() @@ -52,6 +58,8 @@ async def check_permission( item = db.query(Experiment).filter(Experiment.urn == urn).one_or_none() elif model_name == ModelName.score_set: item = db.query(ScoreSet).filter(ScoreSet.urn == urn).one_or_none() + elif model_name == ModelName.collection: + item = db.query(Collection).filter(Collection.urn == urn).one_or_none() if item: permission = has_permission(user_data, item, action).permitted diff --git a/src/mavedb/routers/score_sets.py b/src/mavedb/routers/score_sets.py index 353ee1ab..be990f35 100644 --- a/src/mavedb/routers/score_sets.py +++ b/src/mavedb/routers/score_sets.py @@ -9,13 +9,18 @@ from fastapi.encoders import jsonable_encoder from fastapi.exceptions import HTTPException from fastapi.responses import StreamingResponse -from sqlalchemy import or_ -from sqlalchemy.exc import MultipleResultsFound +from sqlalchemy import or_, select +from sqlalchemy.exc import MultipleResultsFound, NoResultFound from sqlalchemy.orm import Session from mavedb import deps from mavedb.lib.authentication import UserData -from mavedb.lib.authorization import get_current_user, require_current_user, require_current_user_with_email +from mavedb.lib.authorization import ( + get_current_user, + require_current_user, + require_current_user_with_email, + RoleRequirer, +) from mavedb.lib.contributors import find_or_create_contributor from mavedb.lib.exceptions import MixedTargetError, NonexistentOrcidUserError, ValidationError from mavedb.lib.identifiers import ( @@ -29,7 +34,7 @@ logging_context, save_to_logging_context, ) -from mavedb.lib.permissions import Action, assert_permission +from mavedb.lib.permissions import Action, assert_permission, has_permission from mavedb.lib.score_sets import ( csv_data_to_df, find_meta_analyses_for_experiment_sets, @@ -38,6 +43,7 @@ variants_to_csv_rows, ) from mavedb.lib.score_sets import ( + fetch_superseding_score_set_in_search_result, search_score_sets as _search_score_sets, refresh_variant_urns, ) @@ -49,6 +55,7 @@ ) from mavedb.models.contributor import Contributor from mavedb.models.enums.processing_state import ProcessingState +from mavedb.models.enums.user_role import UserRole from mavedb.models.experiment import Experiment from mavedb.models.license import License from mavedb.models.mapped_variant import MappedVariant @@ -57,7 +64,7 @@ from mavedb.models.target_gene import TargetGene from mavedb.models.target_sequence import TargetSequence from mavedb.models.variant import Variant -from mavedb.view_models import mapped_variant, score_set +from mavedb.view_models import mapped_variant, score_set, calibration from mavedb.view_models.search import ScoreSetsSearch logger = logging.getLogger(__name__) @@ -103,6 +110,10 @@ async def fetch_score_set_by_urn( raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") assert_permission(user, item, Action.READ) + + if item.superseding_score_set and not has_permission(user, item.superseding_score_set, Action.READ).permitted: + item.superseding_score_set = None + return item @@ -115,11 +126,16 @@ async def fetch_score_set_by_urn( @router.post("/score-sets/search", status_code=200, response_model=list[score_set.ShortScoreSet]) -def search_score_sets(search: ScoreSetsSearch, db: Session = Depends(deps.get_db)) -> Any: # = Body(..., embed=True), +def search_score_sets( + search: ScoreSetsSearch, + db: Session = Depends(deps.get_db), + user_data: Optional[UserData] = Depends(get_current_user), +) -> Any: # = Body(..., embed=True), """ Search score sets. """ - return _search_score_sets(db, None, search) + score_sets = _search_score_sets(db, None, search) + return fetch_superseding_score_set_in_search_result(score_sets, user_data, search) @router.post( @@ -135,7 +151,8 @@ def search_my_score_sets( """ Search score sets created by the current user.. """ - return _search_score_sets(db, user_data.user, search) + score_sets = _search_score_sets(db, user_data.user, search) + return fetch_superseding_score_set_in_search_result(score_sets, user_data, search) @router.get( @@ -174,6 +191,7 @@ def get_score_set_scores_csv( urn: str, start: int = Query(default=None, description="Start index for pagination"), limit: int = Query(default=None, description="Number of variants to return"), + drop_na_columns: Optional[bool] = None, db: Session = Depends(deps.get_db), user_data: Optional[UserData] = Depends(get_current_user), ) -> Any: @@ -208,7 +226,7 @@ def get_score_set_scores_csv( assert_permission(user_data, score_set, Action.READ) - csv_str = get_score_set_scores_as_csv(db, score_set, start, limit) + csv_str = get_score_set_scores_as_csv(db, score_set, start, limit, drop_na_columns) return StreamingResponse(iter([csv_str]), media_type="text/csv") @@ -228,6 +246,7 @@ async def get_score_set_counts_csv( urn: str, start: int = Query(default=None, description="Start index for pagination"), limit: int = Query(default=None, description="Number of variants to return"), + drop_na_columns: Optional[bool] = None, db: Session = Depends(deps.get_db), user_data: Optional[UserData] = Depends(get_current_user), ) -> Any: @@ -262,7 +281,7 @@ async def get_score_set_counts_csv( assert_permission(user_data, score_set, Action.READ) - csv_str = get_score_set_counts_as_csv(db, score_set, start, limit) + csv_str = get_score_set_counts_as_csv(db, score_set, start, limit, drop_na_columns) return StreamingResponse(iter([csv_str]), media_type="text/csv") @@ -293,10 +312,10 @@ def get_score_set_mapped_variants( mapped_variants = ( db.query(MappedVariant) - .filter(ScoreSet.urn == urn) - .filter(ScoreSet.id == Variant.score_set_id) - .filter(Variant.id == MappedVariant.variant_id) - .all() + .filter(ScoreSet.urn == urn) + .filter(ScoreSet.id == Variant.score_set_id) + .filter(Variant.id == MappedVariant.variant_id) + .all() ) if not mapped_variants: @@ -336,8 +355,10 @@ async def create_score_set( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown experiment") # Not allow add score set in meta-analysis experiments. if any(s.meta_analyzes_score_sets for s in experiment.score_sets): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, - detail="Score sets may not be added to a meta-analysis experiment.") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Score sets may not be added to a meta-analysis experiment.", + ) save_to_logging_context({"experiment": experiment.urn}) assert_permission(user_data, experiment, Action.ADD_SCORE_SET) @@ -461,9 +482,10 @@ async def create_score_set( for identifier in item_create.primary_publication_identifiers or [] ] publication_identifiers = [ - await find_or_create_publication_identifier(db, identifier.identifier, identifier.db_name) - for identifier in item_create.secondary_publication_identifiers or [] - ] + primary_publication_identifiers + await find_or_create_publication_identifier(db, identifier.identifier, + identifier.db_name) + for identifier in item_create.secondary_publication_identifiers or [] + ] + primary_publication_identifiers # create a temporary `primary` attribute on each of our publications that indicates # to our association proxy whether it is a primary publication or not @@ -656,6 +678,43 @@ async def upload_score_set_variant_data( return item +@router.post( + "/score-sets/{urn}/calibration/data", + response_model=score_set.ScoreSet, + responses={422: {}}, + response_model_exclude_none=True, +) +async def update_score_set_calibration_data( + *, + urn: str, + calibration_update: dict[str, calibration.Calibration], + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(RoleRequirer([UserRole.admin])), +): + """ + Update thresholds / score calibrations for a score set. + """ + save_to_logging_context({"requested_resource": urn, "resource_property": "score_thresholds"}) + + try: + item = db.scalars(select(ScoreSet).where(ScoreSet.urn == urn)).one() + except NoResultFound: + logger.info( + msg="Failed to add score thresholds; The requested score set does not exist.", extra=logging_context() + ) + raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") + + assert_permission(user_data, item, Action.UPDATE) + + item.score_calibrations = {k: v.dict() for k, v in calibration_update.items()} + db.add(item) + db.commit() + db.refresh(item) + + save_to_logging_context({"updated_resource": item.urn}) + return item + + @router.put( "/score-sets/{urn}", response_model=score_set.ScoreSet, responses={422: {}}, response_model_exclude_none=True ) diff --git a/src/mavedb/routers/users.py b/src/mavedb/routers/users.py index d6b32d68..09990bb9 100644 --- a/src/mavedb/routers/users.py +++ b/src/mavedb/routers/users.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session +from starlette.convertors import Convertor, register_url_convertor from mavedb import deps from mavedb.lib.authentication import UserData @@ -15,16 +16,36 @@ from mavedb.view_models import user router = APIRouter( - prefix="/api/v1", tags=["access keys"], responses={404: {"description": "Not found"}}, route_class=LoggedRoute + prefix="/api/v1", + tags=["access keys"], + responses={404: {"description": "Not found"}}, + route_class=LoggedRoute, ) logger = logging.getLogger(__name__) +# Define custom type convertor (see https://www.starlette.io/routing/#path-parameters) +# in order to recognize user lookup id as an int or orcid id, and call the appropriate function +class OrcidIdConverter(Convertor): + regex = "\d{4}-\d{4}-\d{4}-(\d{4}|\d{3}X)" + + def convert(self, value: str) -> str: + return value + + def to_string(self, value: str) -> str: + return str(value) + + +register_url_convertor("orcid_id", OrcidIdConverter()) + + # Trailing slash is deliberate @router.get("/users/", status_code=200, response_model=list[user.AdminUser], responses={404: {}}) async def list_users( - *, db: Session = Depends(deps.get_db), user_data: UserData = Depends(RoleRequirer([UserRole.admin])) + *, + db: Session = Depends(deps.get_db), + user_data: UserData = Depends(RoleRequirer([UserRole.admin])), ) -> Any: """ List users. @@ -33,7 +54,12 @@ async def list_users( return items -@router.get("/users/me", status_code=200, response_model=user.CurrentUser, responses={404: {}, 500: {}}) +@router.get( + "/users/me", + status_code=200, + response_model=user.CurrentUser, + responses={404: {}, 500: {}}, +) async def show_me(*, user_data: UserData = Depends(require_current_user)) -> Any: """ Return the current user. @@ -41,22 +67,71 @@ async def show_me(*, user_data: UserData = Depends(require_current_user)) -> Any return user_data.user -@router.get("/users/{id}", status_code=200, response_model=user.AdminUser, responses={404: {}, 500: {}}) -async def show_user( - *, id: int, user_data: UserData = Depends(RoleRequirer([UserRole.admin])), db: Session = Depends(deps.get_db) +@router.get( + "/users/{id:int}", + status_code=200, + response_model=user.AdminUser, + responses={404: {}, 500: {}}, +) +async def show_user_admin( + *, + id: int, + user_data: UserData = Depends(RoleRequirer([UserRole.admin])), + db: Session = Depends(deps.get_db), ) -> Any: """ - Fetch a single user by ID. + Fetch a single user by ID. Returns admin view of requested user. """ save_to_logging_context({"requested_user": id}) item = db.query(User).filter(User.id == id).one_or_none() if not item: - logger.warning(msg="Could not show user; Requested user does not exist.", extra=logging_context()) + logger.warning( + msg="Could not show user; Requested user does not exist.", + extra=logging_context(), + ) raise HTTPException(status_code=404, detail=f"User with ID {id} not found") + + # moving toward always accessing permissions module, even though this function does already require admin role to access + assert_permission(user_data, item, Action.READ) return item -@router.put("/users/me", status_code=200, response_model=user.CurrentUser, responses={404: {}, 500: {}}) +@router.get( + "/users/{orcid_id:orcid_id}", + status_code=200, + response_model=user.User, + responses={404: {}, 500: {}}, +) +async def show_user( + *, + orcid_id: str, + user_data: UserData = Depends(require_current_user), + db: Session = Depends(deps.get_db), +) -> Any: + """ + Fetch a single user by Orcid ID. Returns limited view of user. + """ + save_to_logging_context({"requested_user": orcid_id}) + + item = db.query(User).filter(User.username == orcid_id).one_or_none() + if not item: + logger.warning( + msg="Could not show user; Requested user does not exist.", + extra=logging_context(), + ) + raise HTTPException(status_code=404, detail=f"User with ID {orcid_id} not found") + + # moving toward always accessing permissions module, even though this function does already require existing user in order to access + assert_permission(user_data, item, Action.LOOKUP) + return item + + +@router.put( + "/users/me", + status_code=200, + response_model=user.CurrentUser, + responses={404: {}, 500: {}}, +) async def update_me( *, user_update: user.CurrentUserUpdate, @@ -76,7 +151,12 @@ async def update_me( return current_user -@router.put("/users/me/has-logged-in", status_code=200, response_model=user.CurrentUser, responses={404: {}, 500: {}}) +@router.put( + "/users/me/has-logged-in", + status_code=200, + response_model=user.CurrentUser, + responses={404: {}, 500: {}}, +) async def user_has_logged_in( *, db: Session = Depends(deps.get_db), @@ -95,7 +175,12 @@ async def user_has_logged_in( # Double slash is deliberate. -@router.put("/users//{id}", status_code=200, response_model=user.AdminUser, responses={404: {}, 500: {}}) +@router.put( + "/users//{id}", + status_code=200, + response_model=user.AdminUser, + responses={404: {}, 500: {}}, +) async def update_user( *, id: int, @@ -109,7 +194,10 @@ async def update_user( save_to_logging_context({"requested_user": id}) item = db.query(User).filter(User.id == id).one_or_none() if not item: - logger.warning(msg="Could not update user; Requested user does not exist.", extra=logging_context()) + logger.warning( + msg="Could not update user; Requested user does not exist.", + extra=logging_context(), + ) raise HTTPException(status_code=404, detail=f"User with id {id} not found.") assert_permission(user_data, item, Action.UPDATE) diff --git a/src/mavedb/server_main.py b/src/mavedb/server_main.py index 4651569d..b0e966cf 100644 --- a/src/mavedb/server_main.py +++ b/src/mavedb/server_main.py @@ -12,10 +12,18 @@ from starlette import status from starlette.requests import Request from starlette.responses import JSONResponse -from starlette_context.plugins import CorrelationIdPlugin, RequestIdPlugin, UserAgentPlugin +from starlette_context.plugins import ( + CorrelationIdPlugin, + RequestIdPlugin, + UserAgentPlugin, +) from mavedb import __version__ -from mavedb.lib.exceptions import AmbiguousIdentifierError, MixedTargetError, NonexistentIdentifierError +from mavedb.lib.exceptions import ( + AmbiguousIdentifierError, + MixedTargetError, + NonexistentIdentifierError, +) from mavedb.lib.logging.canonical import log_request from mavedb.lib.logging.context import ( PopulatedRawContextMiddleware, @@ -29,6 +37,7 @@ from mavedb.routers import ( access_keys, api_information, + collections, controlled_keywords, doi_identifiers, experiment_sets, @@ -72,6 +81,7 @@ ) app.include_router(access_keys.router) app.include_router(api_information.router) +app.include_router(collections.router) app.include_router(controlled_keywords.router) app.include_router(doi_identifiers.router) app.include_router(experiment_sets.router) @@ -146,7 +156,11 @@ async def mixed_target_exception_handler(request: Request, exc: MixedTargetError def customize_validation_error(error): # surface custom validation loc context if error.get("ctx", {}).get("custom_loc"): - error = {"loc": error["ctx"]["custom_loc"], "msg": error["msg"], "type": error["type"]} + error = { + "loc": error["ctx"]["custom_loc"], + "msg": error["msg"], + "type": error["type"], + } if error["type"] == "type_error.none.not_allowed": return {"loc": error["loc"], "msg": "Required", "type": error["type"]} diff --git a/src/mavedb/view_models/__init__.py b/src/mavedb/view_models/__init__.py index 6d32815b..5c7f77c9 100644 --- a/src/mavedb/view_models/__init__.py +++ b/src/mavedb/view_models/__init__.py @@ -3,6 +3,8 @@ from pydantic import validator from pydantic.utils import GetterDict +from mavedb.models.enums.contribution_role import ContributionRole + class PublicationIdentifiersGetter(GetterDict): """ @@ -27,6 +29,26 @@ def get(self, key: Any, default: Any = ...) -> Any: return super().get(key, default) +class UserContributionRoleGetter(GetterDict): + """ + Custom class used in transforming ContributionAssociation SQLAlchemy model objects + into Pydantic view model objects, with special handling of user association objects. + + Pydantic uses GetterDict objects to access source objects as dictionaries, which can + then be turned into Pydantic view model objects. We need to remap the underlying + SQLAlchemy model's AssociationList objects with information about the role for a + contributing user + """ + + def get(self, key: Any, default: Any = ...) -> Any: + # The standard is to name properties as the plural of the enum value + if key[:-1] in ContributionRole._member_map_: + user_assc = getattr(self._obj, "user_associations") + return [user.user for user in user_assc if key[:-1] == user.contribution_role.name] + else: + return super().get(key, default) + + def record_type_validator(): return validator("record_type", allow_reuse=True, pre=True, always=True) diff --git a/src/mavedb/view_models/calibration.py b/src/mavedb/view_models/calibration.py new file mode 100644 index 00000000..935b64bf --- /dev/null +++ b/src/mavedb/view_models/calibration.py @@ -0,0 +1,43 @@ +from typing import Union + +from pydantic import root_validator + +from mavedb.lib.validation.exceptions import ValidationError +from mavedb.view_models.base.base import BaseModel + + +class PillarProjectParameters(BaseModel): + skew: float + location: float + scale: float + + +class PillarProjectParameterSet(BaseModel): + functionally_altering: PillarProjectParameters + functionally_normal: PillarProjectParameters + fraction_functionally_altering: float + + +class PillarProjectCalibration(BaseModel): + parameter_sets: list[PillarProjectParameterSet] + evidence_strengths: list[int] + thresholds: list[float] + positive_likelihood_ratios: list[float] + prior_probability_pathogenicity: float + + @root_validator + def validate_all_calibrations_have_a_pairwise_companion(cls, values): + num_es = len(values.get("evidence_strengths")) + num_st = len(values.get("thresholds")) + num_plr = len(values.get("positive_likelihood_ratios")) + + if len(set((num_es, num_st, num_plr))) != 1: + raise ValidationError( + "Calibration object must provide the same number of evidence strengths, score thresholds, and positive likelihood ratios. " + "One or more of these provided objects was not the same length as the others." + ) + + return values + + +Calibration = Union[PillarProjectCalibration] diff --git a/src/mavedb/view_models/collection.py b/src/mavedb/view_models/collection.py new file mode 100644 index 00000000..542b35cf --- /dev/null +++ b/src/mavedb/view_models/collection.py @@ -0,0 +1,108 @@ +from datetime import date +from typing import Any, Sequence + +from pydantic import Field +from pydantic.types import Optional + +from mavedb.view_models import UserContributionRoleGetter, record_type_validator, set_record_type +from mavedb.view_models.base.base import BaseModel +from mavedb.view_models.contributor import ContributorCreate +from mavedb.view_models.user import SavedUser, User + + +class CollectionGetter(UserContributionRoleGetter): + def get(self, key: Any, default: Any = ...) -> Any: + if key == "score_set_urns": + score_sets = getattr(self._obj, "score_sets") or [] + return sorted([score_set.urn for score_set in score_sets if score_set.superseding_score_set is None]) + elif key == "experiment_urns": + experiments = getattr(self._obj, "experiments") or [] + return sorted([experiment.urn for experiment in experiments]) + else: + return super().get(key, default) + + +class CollectionBase(BaseModel): + private: bool = Field( + description="Whether the collection is visible to all MaveDB users. If set during collection update, input ignored unless requesting user is collection admin." + ) + name: str + description: Optional[str] + badge_name: Optional[str] = Field( + description="Badge name. Input ignored unless requesting user has MaveDB admin privileges." + ) + + +class CollectionModify(BaseModel): + # all fields should be optional, because the client should specify only the fields they want to update + private: Optional[bool] = Field( + description="Whether the collection is visible to all MaveDB users. If set during collection update, input ignored unless requesting user is collection admin." + ) + name: Optional[str] + description: Optional[str] + badge_name: Optional[str] = Field( + description="Badge name. Input ignored unless requesting user has MaveDB admin privileges." + ) + + +class CollectionCreate(CollectionBase): + experiment_urns: Optional[list[str]] + score_set_urns: Optional[list[str]] + + viewers: Optional[list[ContributorCreate]] + editors: Optional[list[ContributorCreate]] + admins: Optional[list[ContributorCreate]] + + +class AddScoreSetToCollectionRequest(BaseModel): + score_set_urn: str + + +class AddExperimentToCollectionRequest(BaseModel): + experiment_urn: str + + +class AddUserToCollectionRoleRequest(BaseModel): + orcid_id: str + + +# Properties shared by models stored in DB +class SavedCollection(CollectionBase): + record_type: str = None # type: ignore + urn: str + + created_by: Optional[SavedUser] + modified_by: Optional[SavedUser] + + experiment_urns: list[str] + score_set_urns: list[str] + + admins: Sequence[SavedUser] + viewers: Sequence[SavedUser] + editors: Sequence[SavedUser] + + creation_date: date + modification_date: date + + _record_type_factory = record_type_validator()(set_record_type) + + class Config: + orm_mode = True + getter_dict = CollectionGetter + + +# Properties to return to non-admin clients +# NOTE: Coupled to ContributionRole enum +class Collection(SavedCollection): + created_by: Optional[User] + modified_by: Optional[User] + + admins: Sequence[User] + viewers: Sequence[User] + editors: Sequence[User] + + +# Properties to return to admin clients or non-admin clients who are admins of the returned collection +# NOTE: Coupled to ContributionRole enum +class AdminCollection(Collection): + pass diff --git a/src/mavedb/view_models/collection_bundle.py b/src/mavedb/view_models/collection_bundle.py new file mode 100644 index 00000000..6354988a --- /dev/null +++ b/src/mavedb/view_models/collection_bundle.py @@ -0,0 +1,8 @@ +from mavedb.view_models.base.base import BaseModel +from mavedb.view_models.collection import Collection + + +class CollectionBundle(BaseModel): + admin: list[Collection] + editor: list[Collection] + viewer: list[Collection] diff --git a/src/mavedb/view_models/experiment.py b/src/mavedb/view_models/experiment.py index 4d639e95..e0b50b5a 100644 --- a/src/mavedb/view_models/experiment.py +++ b/src/mavedb/view_models/experiment.py @@ -29,6 +29,16 @@ from mavedb.view_models.user import SavedUser, User +class OfficialCollection(BaseModel): + badge_name: str + name: str + urn: str + + class Config: + orm_mode = True + arbitrary_types_allowed = True + + class ExperimentGetter(PublicationIdentifiersGetter): def get(self, key: Any, default: Any = ...) -> Any: if key == "score_set_urns": @@ -127,6 +137,7 @@ class Experiment(SavedExperiment): doi_identifiers: Sequence[DoiIdentifier] primary_publication_identifiers: Sequence[PublicationIdentifier] secondary_publication_identifiers: Sequence[PublicationIdentifier] + official_collections: Sequence[OfficialCollection] keywords: Sequence[ExperimentControlledKeyword] raw_read_identifiers: Sequence[RawReadIdentifier] created_by: User diff --git a/src/mavedb/view_models/score_set.py b/src/mavedb/view_models/score_set.py index 6c0bfc1e..a577d10c 100644 --- a/src/mavedb/view_models/score_set.py +++ b/src/mavedb/view_models/score_set.py @@ -15,6 +15,7 @@ from mavedb.models.enums.processing_state import ProcessingState from mavedb.view_models import PublicationIdentifiersGetter, record_type_validator, set_record_type from mavedb.view_models.base.base import BaseModel, validator +from mavedb.view_models.calibration import Calibration from mavedb.view_models.contributor import Contributor, ContributorCreate from mavedb.view_models.doi_identifier import ( DoiIdentifier, @@ -41,6 +42,16 @@ class ExternalLink(BaseModel): url: Optional[str] +class OfficialCollection(BaseModel): + badge_name: str + name: str + urn: str + + class Config: + orm_mode = True + arbitrary_types_allowed = True + + class ScoreRange(BaseModel): label: str description: Optional[str] @@ -387,6 +398,7 @@ class SavedScoreSet(ScoreSetBase): external_links: Dict[str, ExternalLink] contributors: list[Contributor] score_ranges: Optional[ScoreRanges] + score_calibrations: Optional[dict[str, Calibration]] _record_type_factory = record_type_validator()(set_record_type) @@ -414,6 +426,7 @@ class ScoreSet(SavedScoreSet): doi_identifiers: Sequence[DoiIdentifier] primary_publication_identifiers: Sequence[PublicationIdentifier] secondary_publication_identifiers: Sequence[PublicationIdentifier] + official_collections: Sequence[OfficialCollection] created_by: Optional[User] modified_by: Optional[User] target_genes: Sequence[TargetGene] diff --git a/tests/conftest.py b/tests/conftest.py index b58a5dd9..429c6cca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,7 +31,7 @@ sys.path.append(".") -from tests.helpers.constants import ADMIN_USER, TEST_USER +from tests.helpers.constants import ADMIN_USER, EXTRA_USER, TEST_USER # needs the pytest_postgresql plugin installed assert pytest_postgresql.factories @@ -242,6 +242,39 @@ def override_hgvs_data_provider(): yield anonymous_overrides +@pytest.fixture() +def extra_user_app_overrides(session, data_provider, arq_redis): + def override_get_db(): + try: + yield session + finally: + session.close() + + async def override_get_worker(): + yield arq_redis + + def override_current_user(): + default_user = session.query(User).filter(User.username == EXTRA_USER["username"]).one_or_none() + yield UserData(default_user, default_user.roles) + + def override_require_user(): + default_user = session.query(User).filter(User.username == EXTRA_USER["username"]).one_or_none() + yield UserData(default_user, default_user.roles) + + def override_hgvs_data_provider(): + yield data_provider + + anonymous_overrides = { + get_db: override_get_db, + get_worker: override_get_worker, + get_current_user: override_current_user, + require_current_user: require_current_user, + hgvs_data_provider: override_hgvs_data_provider, + } + + yield anonymous_overrides + + @pytest.fixture() def admin_app_overrides(session, data_provider, arq_redis): def override_get_db(): diff --git a/tests/helpers/constants.py b/tests/helpers/constants.py index c6c88269..a0aeb42e 100644 --- a/tests/helpers/constants.py +++ b/tests/helpers/constants.py @@ -5,6 +5,7 @@ from mavedb.models.enums.processing_state import ProcessingState TEST_PUBMED_IDENTIFIER = "20711194" +TEST_PUBMED_URL_IDENTIFIER = "https://pubmed.ncbi.nlm.nih.gov/37162834/" TEST_BIORXIV_IDENTIFIER = "2021.06.21.212592" TEST_MEDRXIV_IDENTIFIER = "2021.06.22.21259265" TEST_CROSSREF_IDENTIFIER = "10.1371/2021.06.22.21259265" @@ -244,6 +245,7 @@ # keys to be set after receiving response "urn": None, "experimentSetUrn": None, + "officialCollections": [], } TEST_EXPERIMENT_WITH_KEYWORD_RESPONSE = { @@ -282,6 +284,7 @@ # keys to be set after receiving response "urn": None, "experimentSetUrn": None, + "officialCollections": [], } TEST_EXPERIMENT_WITH_KEYWORD_HAS_DUPLICATE_OTHERS_RESPONSE = { @@ -330,6 +333,7 @@ # keys to be set after receiving response "urn": None, "experimentSetUrn": None, + "officialCollections": [], } TEST_TAXONOMY = { @@ -522,6 +526,7 @@ # keys to be set after receiving response "urn": None, "processingState": ProcessingState.incomplete.name, + "officialCollections": [], } TEST_MINIMAL_ACC_SCORESET = { @@ -606,6 +611,7 @@ # keys to be set after receiving response "urn": None, "processingState": ProcessingState.incomplete.name, + "officialCollections": [], } TEST_CDOT_TRANSCRIPT = { @@ -658,6 +664,7 @@ ], } + TEST_SAVED_SCORESET_RANGE = { "wtScore": 1.0, "ranges": [ @@ -665,3 +672,70 @@ {"label": "test2", "classification": "abnormal", "range": [-2.0, 0.0]}, ], } + + +TEST_SCORE_CALIBRATION = { + "parameter_sets": [ + { + "functionally_altering": {"skew": 1.15, "location": -2.20, "scale": 1.20}, + "functionally_normal": {"skew": -1.5, "location": 2.25, "scale": 0.8}, + "fraction_functionally_altering": 0.20, + }, + ], + "evidence_strengths": [3, 2, 1, -1], + "thresholds": [1.25, 2.5, 3, 5.5], + "positive_likelihood_ratios": [100, 10, 1, 0.1], + "prior_probability_pathogenicity": 0.20, +} + + +TEST_SAVED_SCORE_CALIBRATION = { + "parameterSets": [ + { + "functionallyAltering": {"skew": 1.15, "location": -2.20, "scale": 1.20}, + "functionallyNormal": {"skew": -1.5, "location": 2.25, "scale": 0.8}, + "fractionFunctionallyAltering": 0.20, + }, + ], + "evidenceStrengths": [3, 2, 1, -1], + "thresholds": [1.25, 2.5, 3, 5.5], + "positiveLikelihoodRatios": [100, 10, 1, 0.1], + "priorProbabilityPathogenicity": 0.20, +} + + +TEST_COLLECTION = {"name": "Test collection", "description": None, "private": True} + + +TEST_COLLECTION_RESPONSE = { + "recordType": "Collection", + "name": "Test collection", + # "description": None, + "private": True, + "createdBy": { + "recordType": "User", + "firstName": TEST_USER["first_name"], + "lastName": TEST_USER["last_name"], + "orcidId": TEST_USER["username"], + }, + "modifiedBy": { + "recordType": "User", + "firstName": TEST_USER["first_name"], + "lastName": TEST_USER["last_name"], + "orcidId": TEST_USER["username"], + }, + "creationDate": date.today().isoformat(), + "modificationDate": date.today().isoformat(), + "experimentUrns": [], + "scoreSetUrns": [], + "admins": [ + { + "recordType": "User", + "firstName": TEST_USER["first_name"], + "lastName": TEST_USER["last_name"], + "orcidId": TEST_USER["username"], + } + ], + "editors": [], + "viewers": [], +} diff --git a/tests/helpers/util.py b/tests/helpers/util.py index 108ec983..f4df4586 100644 --- a/tests/helpers/util.py +++ b/tests/helpers/util.py @@ -14,11 +14,13 @@ from mavedb.models.score_set import ScoreSet as ScoreSetDbModel from mavedb.models.license import License from mavedb.models.user import User +from mavedb.view_models.collection import Collection from mavedb.view_models.experiment import Experiment, ExperimentCreate from mavedb.view_models.score_set import ScoreSet, ScoreSetCreate from tests.helpers.constants import ( EXTRA_USER, TEST_CDOT_TRANSCRIPT, + TEST_COLLECTION, TEST_MINIMAL_ACC_SCORESET, TEST_MINIMAL_EXPERIMENT, TEST_MINIMAL_SEQ_SCORESET, @@ -64,6 +66,19 @@ def change_to_inactive_license(db, urn): db.commit() +def create_collection(client, update=None): + collection_payload = deepcopy(TEST_COLLECTION) + if update is not None: + collection_payload.update(update) + + response = client.post("/api/v1/collections/", json=collection_payload) + assert response.status_code == 200, "Could not create collection." + + response_data = response.json() + jsonschema.validate(instance=response_data, schema=Collection.schema()) + return response_data + + def create_experiment(client, update=None): experiment_payload = deepcopy(TEST_MINIMAL_EXPERIMENT) if update is not None: @@ -144,7 +159,7 @@ def mock_worker_variant_insertion(client, db, data_provider, score_set, scores_c score_df = csv_data_to_df(score_file) if counts_csv_path is not None: - with open(scores_csv_path, "rb") as counts_file: + with open(counts_csv_path, "rb") as counts_file: counts_df = csv_data_to_df(counts_file) else: counts_df = None diff --git a/tests/routers/test_collections.py b/tests/routers/test_collections.py new file mode 100644 index 00000000..3fae0d91 --- /dev/null +++ b/tests/routers/test_collections.py @@ -0,0 +1,549 @@ +import re +from copy import deepcopy + +import jsonschema +import pytest + +from mavedb.lib.validation.urn_re import MAVEDB_COLLECTION_URN_RE +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.view_models.collection import Collection +from tests.helpers.constants import ( + EXTRA_USER, + TEST_USER, + TEST_COLLECTION, + TEST_COLLECTION_RESPONSE, +) +from tests.helpers.dependency_overrider import DependencyOverrider +from tests.helpers.util import ( + create_collection, + create_experiment, + create_seq_score_set_with_variants, + publish_score_set, +) + + +def test_create_private_collection(client, setup_router_db): + response = client.post("/api/v1/collections/", json=TEST_COLLECTION) + assert response.status_code == 200 + response_data = response.json() + jsonschema.validate(instance=response_data, schema=Collection.schema()) + assert isinstance(MAVEDB_COLLECTION_URN_RE.fullmatch(response_data["urn"]), re.Match) + expected_response = deepcopy(TEST_COLLECTION_RESPONSE) + expected_response.update({"urn": response_data["urn"]}) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +def test_create_public_collection(client, setup_router_db): + collection = deepcopy(TEST_COLLECTION) + collection["private"] = False + response = client.post("/api/v1/collections/", json=collection) + assert response.status_code == 200 + response_data = response.json() + jsonschema.validate(instance=response_data, schema=Collection.schema()) + assert isinstance(MAVEDB_COLLECTION_URN_RE.fullmatch(response_data["urn"]), re.Match) + expected_response = deepcopy(TEST_COLLECTION_RESPONSE) + expected_response.update({"urn": response_data["urn"], "private": False}) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +@pytest.mark.parametrize("role", ContributionRole._member_names_) +def test_add_collection_user_to_collection_role(role, client, setup_router_db): + collection = create_collection(client, {"private": True}) + + response = client.post( + f"/api/v1/collections/{collection['urn']}/{role}s", json={"orcid_id": EXTRA_USER["username"]} + ) + assert response.status_code == 200 + response_data = response.json() + expected_response = deepcopy(TEST_COLLECTION_RESPONSE) + expected_response.update( + { + "urn": collection["urn"], + "badgeName": None, + "description": None, + } + ) + expected_response[f"{role}s"].extend( + [ + { + "recordType": "User", + "firstName": EXTRA_USER["first_name"], + "lastName": EXTRA_USER["last_name"], + "orcidId": EXTRA_USER["username"], + }, + ] + ) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +def test_creator_can_read_private_collection(session, client, setup_router_db, anonymous_app_overrides): + collection = create_collection(client) + + response = client.get(f"/api/v1/collections/{collection['urn']}") + assert response.status_code == 200 + response_data = response.json() + expected_response = deepcopy(TEST_COLLECTION_RESPONSE) + expected_response.update({"urn": response_data["urn"]}) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +def test_admin_can_read_private_collection(session, client, setup_router_db, extra_user_app_overrides): + collection = create_collection(client) + client.post(f"/api/v1/collections/{collection['urn']}/admins", json={"orcid_id": EXTRA_USER["username"]}) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/collections/{collection['urn']}") + + assert response.status_code == 200 + response_data = response.json() + expected_response = deepcopy(TEST_COLLECTION_RESPONSE) + expected_response.update( + { + "urn": response_data["urn"], + "admins": [ + { + "recordType": "User", + "firstName": TEST_USER["first_name"], + "lastName": TEST_USER["last_name"], + "orcidId": TEST_USER["username"], + }, + { + "recordType": "User", + "firstName": EXTRA_USER["first_name"], + "lastName": EXTRA_USER["last_name"], + "orcidId": EXTRA_USER["username"], + }, + ], + } + ) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +def test_editor_can_read_private_collection(session, client, setup_router_db, extra_user_app_overrides): + collection = create_collection(client) + client.post(f"/api/v1/collections/{collection['urn']}/editors", json={"orcid_id": EXTRA_USER["username"]}) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/collections/{collection['urn']}") + + assert response.status_code == 200 + response_data = response.json() + expected_response = deepcopy(TEST_COLLECTION_RESPONSE) + expected_response.update( + { + "urn": response_data["urn"], + "editors": [ + { + "recordType": "User", + "firstName": EXTRA_USER["first_name"], + "lastName": EXTRA_USER["last_name"], + "orcidId": EXTRA_USER["username"], + } + ], + } + ) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +def test_viewer_can_read_private_collection(session, client, setup_router_db, extra_user_app_overrides): + collection = create_collection(client) + client.post(f"/api/v1/collections/{collection['urn']}/viewers", json={"orcid_id": EXTRA_USER["username"]}) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/collections/{collection['urn']}") + + assert response.status_code == 200 + response_data = response.json() + expected_response = deepcopy(TEST_COLLECTION_RESPONSE) + expected_response.update( + { + "urn": response_data["urn"], + "viewers": [ + { + "recordType": "User", + "firstName": EXTRA_USER["first_name"], + "lastName": EXTRA_USER["last_name"], + "orcidId": EXTRA_USER["username"], + } + ], + } + ) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +def test_unauthorized_user_cannot_read_private_collection(session, client, setup_router_db, extra_user_app_overrides): + collection = create_collection(client) + + with DependencyOverrider(extra_user_app_overrides): + response = client.get(f"/api/v1/collections/{collection['urn']}") + + assert response.status_code == 404 + assert f"collection with URN '{collection['urn']}' not found" in response.json()["detail"] + + +def test_anonymous_cannot_read_private_collection(session, client, setup_router_db, anonymous_app_overrides): + collection = create_collection(client) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/collections/{collection['urn']}") + + assert response.status_code == 404 + assert f"collection with URN '{collection['urn']}' not found" in response.json()["detail"] + + +def test_anonymous_can_read_public_collection(session, client, setup_router_db, anonymous_app_overrides): + collection = create_collection(client, {"private": False}) + + with DependencyOverrider(anonymous_app_overrides): + response = client.get(f"/api/v1/collections/{collection['urn']}") + + assert response.status_code == 200 + response_data = response.json() + expected_response = deepcopy(TEST_COLLECTION_RESPONSE) + expected_response.update({"urn": response_data["urn"], "private": False}) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +def test_admin_can_add_experiment_to_collection( + session, client, data_provider, data_files, setup_router_db, extra_user_app_overrides +): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + score_set = publish_score_set(client, unpublished_score_set["urn"]) + + collection = create_collection(client) + client.post(f"/api/v1/collections/{collection['urn']}/admins", json={"orcid_id": EXTRA_USER["username"]}) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + f"/api/v1/collections/{collection['urn']}/experiments", + json={"experiment_urn": score_set["experiment"]["urn"]}, + ) + + assert response.status_code == 200 + response_data = response.json() + expected_response = deepcopy(TEST_COLLECTION_RESPONSE) + expected_response.update( + { + "urn": collection["urn"], + "badgeName": None, + "description": None, + "modifiedBy": { + "recordType": "User", + "firstName": EXTRA_USER["first_name"], + "lastName": EXTRA_USER["last_name"], + "orcidId": EXTRA_USER["username"], + }, + "admins": [ + { + "recordType": "User", + "firstName": TEST_USER["first_name"], + "lastName": TEST_USER["last_name"], + "orcidId": TEST_USER["username"], + }, + { + "recordType": "User", + "firstName": EXTRA_USER["first_name"], + "lastName": EXTRA_USER["last_name"], + "orcidId": EXTRA_USER["username"], + }, + ], + "experimentUrns": [score_set["experiment"]["urn"]], + } + ) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +def test_editor_can_add_experiment_to_collection( + session, client, data_provider, data_files, setup_router_db, extra_user_app_overrides +): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + score_set = publish_score_set(client, unpublished_score_set["urn"]) + + collection = create_collection(client) + client.post(f"/api/v1/collections/{collection['urn']}/editors", json={"orcid_id": EXTRA_USER["username"]}) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + f"/api/v1/collections/{collection['urn']}/experiments", + json={"experiment_urn": score_set["experiment"]["urn"]}, + ) + + assert response.status_code == 200 + response_data = response.json() + expected_response = deepcopy(TEST_COLLECTION_RESPONSE) + expected_response.update( + { + "urn": collection["urn"], + "badgeName": None, + "description": None, + "modifiedBy": { + "recordType": "User", + "firstName": EXTRA_USER["first_name"], + "lastName": EXTRA_USER["last_name"], + "orcidId": EXTRA_USER["username"], + }, + "editors": [ + { + "recordType": "User", + "firstName": EXTRA_USER["first_name"], + "lastName": EXTRA_USER["last_name"], + "orcidId": EXTRA_USER["username"], + } + ], + "experimentUrns": [score_set["experiment"]["urn"]], + } + ) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +def test_viewer_cannot_add_experiment_to_collection( + session, client, data_provider, data_files, setup_router_db, extra_user_app_overrides +): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + score_set = publish_score_set(client, unpublished_score_set["urn"]) + + collection = create_collection(client) + client.post(f"/api/v1/collections/{collection['urn']}/viewers", json={"orcid_id": EXTRA_USER["username"]}) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + f"/api/v1/collections/{collection['urn']}/experiments", + json={"experiment_urn": score_set["experiment"]["urn"]}, + ) + + assert response.status_code == 403 + response_data = response.json() + assert f"insufficient permissions for URN '{collection['urn']}'" in response_data["detail"] + + +def test_unauthorized_user_cannot_add_experiment_to_collection( + session, client, data_provider, data_files, setup_router_db, extra_user_app_overrides +): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + score_set = publish_score_set(client, unpublished_score_set["urn"]) + + collection = create_collection(client) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + f"/api/v1/collections/{collection['urn']}/experiments", + json={"experiment_urn": score_set["experiment"]["urn"]}, + ) + + assert response.status_code == 404 + assert f"collection with URN '{collection['urn']}' not found" in response.json()["detail"] + + +def test_anonymous_cannot_add_experiment_to_collection( + session, client, data_provider, data_files, setup_router_db, anonymous_app_overrides +): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + score_set = publish_score_set(client, unpublished_score_set["urn"]) + + collection = create_collection(client) + + with DependencyOverrider(anonymous_app_overrides): + response = client.post( + f"/api/v1/collections/{collection['urn']}/experiments", + json={"experiment_urn": score_set["experiment"]["urn"]}, + ) + + assert response.status_code == 401 + assert "Could not validate credentials" in response.json()["detail"] + + +def test_admin_can_add_score_set_to_collection( + session, client, data_provider, data_files, setup_router_db, extra_user_app_overrides +): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + score_set = publish_score_set(client, unpublished_score_set["urn"]) + + collection = create_collection(client) + client.post(f"/api/v1/collections/{collection['urn']}/admins", json={"orcid_id": EXTRA_USER["username"]}) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + f"/api/v1/collections/{collection['urn']}/score-sets", json={"score_set_urn": score_set["urn"]} + ) + + assert response.status_code == 200 + response_data = response.json() + expected_response = deepcopy(TEST_COLLECTION_RESPONSE) + expected_response.update( + { + "urn": collection["urn"], + "badgeName": None, + "description": None, + "modifiedBy": { + "recordType": "User", + "firstName": EXTRA_USER["first_name"], + "lastName": EXTRA_USER["last_name"], + "orcidId": EXTRA_USER["username"], + }, + "admins": [ + { + "recordType": "User", + "firstName": TEST_USER["first_name"], + "lastName": TEST_USER["last_name"], + "orcidId": TEST_USER["username"], + }, + { + "recordType": "User", + "firstName": EXTRA_USER["first_name"], + "lastName": EXTRA_USER["last_name"], + "orcidId": EXTRA_USER["username"], + }, + ], + "scoreSetUrns": [score_set["urn"]], + } + ) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +def test_editor_can_add_score_set_to_collection( + session, client, data_provider, data_files, setup_router_db, extra_user_app_overrides +): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + score_set = publish_score_set(client, unpublished_score_set["urn"]) + + collection = create_collection(client) + client.post(f"/api/v1/collections/{collection['urn']}/editors", json={"orcid_id": EXTRA_USER["username"]}) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + f"/api/v1/collections/{collection['urn']}/score-sets", json={"score_set_urn": score_set["urn"]} + ) + + assert response.status_code == 200 + response_data = response.json() + expected_response = deepcopy(TEST_COLLECTION_RESPONSE) + expected_response.update( + { + "urn": collection["urn"], + "badgeName": None, + "description": None, + "modifiedBy": { + "recordType": "User", + "firstName": EXTRA_USER["first_name"], + "lastName": EXTRA_USER["last_name"], + "orcidId": EXTRA_USER["username"], + }, + "editors": [ + { + "recordType": "User", + "firstName": EXTRA_USER["first_name"], + "lastName": EXTRA_USER["last_name"], + "orcidId": EXTRA_USER["username"], + } + ], + "scoreSetUrns": [score_set["urn"]], + } + ) + assert sorted(expected_response.keys()) == sorted(response_data.keys()) + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +def test_viewer_cannot_add_score_set_to_collection( + session, client, data_provider, data_files, setup_router_db, extra_user_app_overrides +): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + score_set = publish_score_set(client, unpublished_score_set["urn"]) + + collection = create_collection(client) + client.post(f"/api/v1/collections/{collection['urn']}/viewers", json={"orcid_id": EXTRA_USER["username"]}) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + f"/api/v1/collections/{collection['urn']}/score-sets", json={"score_set_urn": score_set["urn"]} + ) + + assert response.status_code == 403 + response_data = response.json() + assert f"insufficient permissions for URN '{collection['urn']}'" in response_data["detail"] + + +def test_unauthorized_user_cannot_add_score_set_to_collection( + session, client, data_provider, data_files, setup_router_db, extra_user_app_overrides +): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + score_set = publish_score_set(client, unpublished_score_set["urn"]) + + collection = create_collection(client) + + with DependencyOverrider(extra_user_app_overrides): + response = client.post( + f"/api/v1/collections/{collection['urn']}/score-sets", json={"score_set_urn": score_set["urn"]} + ) + + assert response.status_code == 404 + assert f"collection with URN '{collection['urn']}' not found" in response.json()["detail"] + + +def test_anonymous_cannot_add_score_set_to_collection( + session, client, data_provider, data_files, setup_router_db, anonymous_app_overrides +): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + score_set = publish_score_set(client, unpublished_score_set["urn"]) + + collection = create_collection(client) + + with DependencyOverrider(anonymous_app_overrides): + response = client.post( + f"/api/v1/collections/{collection['urn']}/score-sets", json={"score_set_urn": score_set["urn"]} + ) + + assert response.status_code == 401 + assert "Could not validate credentials" in response.json()["detail"] diff --git a/tests/routers/test_experiments.py b/tests/routers/test_experiments.py index 5b864e9c..041c42e4 100644 --- a/tests/routers/test_experiments.py +++ b/tests/routers/test_experiments.py @@ -24,8 +24,10 @@ TEST_MEDRXIV_IDENTIFIER, TEST_MINIMAL_EXPERIMENT, TEST_MINIMAL_EXPERIMENT_RESPONSE, + TEST_MINIMAL_SEQ_SCORESET, TEST_ORCID_ID, TEST_PUBMED_IDENTIFIER, + TEST_PUBMED_URL_IDENTIFIER, TEST_USER, ) from tests.helpers.dependency_overrider import DependencyOverrider @@ -730,7 +732,35 @@ def test_create_experiment_with_new_primary_pubmed_publication(client, setup_rou "publicationYear", ] ) - # TODO: add separate tests for generating the publication url and referenceHtml + + +@pytest.mark.parametrize( + "mock_publication_fetch", + [({"dbName": "PubMed", "identifier": f"{TEST_PUBMED_URL_IDENTIFIER}"})], + indirect=["mock_publication_fetch"], +) +def test_create_experiment_with_new_primary_pubmed_url_publication(client, setup_router_db, mock_publication_fetch): + mocked_publication = mock_publication_fetch + response_data = create_experiment(client, {"primaryPublicationIdentifiers": [mocked_publication]}) + + assert len(response_data["primaryPublicationIdentifiers"]) == 1 + assert sorted(response_data["primaryPublicationIdentifiers"][0]) == sorted( + [ + "abstract", + "id", + "authors", + "dbName", + "doi", + "identifier", + "title", + "url", + "recordType", + "referenceHtml", + "publicationJournal", + "publicationYear", + ] + ) + assert response_data["primaryPublicationIdentifiers"][0]["identifier"] == '37162834' @pytest.mark.parametrize( @@ -1043,6 +1073,112 @@ def test_search_score_sets_for_experiments(session, client, setup_router_db, dat assert response.json()[0]["urn"] == published_score_set["urn"] +# Creator created a superseding score set but not published it yet. +def test_owner_searches_score_sets_with_unpublished_superseding_score_sets_for_experiments(session, client, setup_router_db, data_files, data_provider): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + publish_score_set_response = client.post(f"/api/v1/score-sets/{unpublished_score_set['urn']}/publish") + assert publish_score_set_response.status_code == 200 + published_score_set = publish_score_set_response.json() + score_set_post_payload = deepcopy(TEST_MINIMAL_SEQ_SCORESET) + score_set_post_payload["experimentUrn"] = published_score_set["experiment"]["urn"] + score_set_post_payload["supersededScoreSetUrn"] = published_score_set["urn"] + superseding_score_set_response = client.post("/api/v1/score-sets/", json=score_set_post_payload) + assert superseding_score_set_response.status_code == 200 + superseding_score_set = superseding_score_set_response.json() + + # On score set publication, the experiment will get a new urn + experiment_urn = published_score_set["experiment"]["urn"] + response = client.get(f"/api/v1/experiments/{experiment_urn}/score-sets") + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["urn"] == superseding_score_set["urn"] + + +def test_non_owner_searches_score_sets_with_unpublished_superseding_score_sets_for_experiments(session, client, setup_router_db, data_files, data_provider): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + publish_score_set_response = client.post(f"/api/v1/score-sets/{unpublished_score_set['urn']}/publish") + assert publish_score_set_response.status_code == 200 + published_score_set = publish_score_set_response.json() + score_set_post_payload = deepcopy(TEST_MINIMAL_SEQ_SCORESET) + score_set_post_payload["experimentUrn"] = published_score_set["experiment"]["urn"] + score_set_post_payload["supersededScoreSetUrn"] = published_score_set["urn"] + superseding_score_set_response = client.post("/api/v1/score-sets/", json=score_set_post_payload) + assert superseding_score_set_response.status_code == 200 + superseding_score_set = superseding_score_set_response.json() + change_ownership(session, published_score_set["urn"], ScoreSetDbModel) + change_ownership(session, superseding_score_set["urn"], ScoreSetDbModel) + # On score set publication, the experiment will get a new urn + experiment_urn = published_score_set["experiment"]["urn"] + response = client.get(f"/api/v1/experiments/{experiment_urn}/score-sets") + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["urn"] == published_score_set["urn"] + + +def test_owner_searches_published_superseding_score_sets_for_experiments(session, client, setup_router_db, data_files, data_provider): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + publish_score_set_response = client.post(f"/api/v1/score-sets/{unpublished_score_set['urn']}/publish") + assert publish_score_set_response.status_code == 200 + published_score_set = publish_score_set_response.json() + + superseding_score_set = create_seq_score_set_with_variants( + client, + session, + data_provider, + published_score_set["experiment"]["urn"], + data_files / "scores.csv", + update={"supersededScoreSetUrn": published_score_set["urn"]}, + ) + published_superseding_score_set_response = client.post(f"/api/v1/score-sets/{superseding_score_set['urn']}/publish") + assert published_superseding_score_set_response.status_code == 200 + published_superseding_score_set = published_superseding_score_set_response.json() + # On score set publication, the experiment will get a new urn + experiment_urn = published_score_set["experiment"]["urn"] + response = client.get(f"/api/v1/experiments/{experiment_urn}/score-sets") + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["urn"] == published_superseding_score_set["urn"] + + +def test_non_owner_searches_published_superseding_score_sets_for_experiments(session, client, setup_router_db, data_files, data_provider): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + publish_score_set_response = client.post(f"/api/v1/score-sets/{unpublished_score_set['urn']}/publish") + assert publish_score_set_response.status_code == 200 + published_score_set = publish_score_set_response.json() + + superseding_score_set = create_seq_score_set_with_variants( + client, + session, + data_provider, + published_score_set["experiment"]["urn"], + data_files / "scores.csv", + update={"supersededScoreSetUrn": published_score_set["urn"]}, + ) + published_superseding_score_set_response = client.post(f"/api/v1/score-sets/{superseding_score_set['urn']}/publish") + assert published_superseding_score_set_response.status_code == 200 + published_superseding_score_set = published_superseding_score_set_response.json() + change_ownership(session, published_score_set["urn"], ScoreSetDbModel) + change_ownership(session, published_superseding_score_set["urn"], ScoreSetDbModel) + # On score set publication, the experiment will get a new urn + experiment_urn = published_score_set["experiment"]["urn"] + response = client.get(f"/api/v1/experiments/{experiment_urn}/score-sets") + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["urn"] == published_superseding_score_set["urn"] + + def test_search_score_sets_for_contributor_experiments(session, client, setup_router_db, data_files, data_provider): experiment = create_experiment(client) score_set_pub = create_seq_score_set_with_variants( diff --git a/tests/routers/test_permissions.py b/tests/routers/test_permissions.py index 83cddc53..1473a125 100644 --- a/tests/routers/test_permissions.py +++ b/tests/routers/test_permissions.py @@ -65,9 +65,8 @@ def test_cannot_get_permission_with_wrong_action_in_experiment_set(client, setup assert response.status_code == 422 response_data = response.json() assert ( - response_data["detail"][0]["msg"] == "value is not a valid enumeration member; permitted: 'read', " - "'update', 'delete', 'add_experiment', 'add_score_set', 'set_scores'," - " 'add_role', 'publish'" + response_data["detail"][0]["msg"] == "value is not a valid enumeration member; permitted: 'lookup', 'read', " + "'update', 'delete', 'add_experiment', 'add_score_set', 'set_scores', 'add_role', 'publish', 'add_badge'" ) @@ -210,9 +209,8 @@ def test_cannot_get_permission_with_wrong_action_in_experiment(client, setup_rou assert response.status_code == 422 response_data = response.json() assert ( - response_data["detail"][0]["msg"] == "value is not a valid enumeration member; permitted: 'read', " - "'update', 'delete', 'add_experiment', 'add_score_set', 'set_scores'," - " 'add_role', 'publish'" + response_data["detail"][0]["msg"] == "value is not a valid enumeration member; permitted: 'lookup', 'read', " + "'update', 'delete', 'add_experiment', 'add_score_set', 'set_scores', 'add_role', 'publish', 'add_badge'" ) @@ -347,9 +345,8 @@ def test_cannot_get_permission_with_wrong_action_in_score_set(client, setup_rout assert response.status_code == 422 response_data = response.json() assert ( - response_data["detail"][0]["msg"] == "value is not a valid enumeration member; permitted: 'read', " - "'update', 'delete', 'add_experiment', 'add_score_set', 'set_scores'," - " 'add_role', 'publish'" + response_data["detail"][0]["msg"] == "value is not a valid enumeration member; permitted: 'lookup', 'read', " + "'update', 'delete', 'add_experiment', 'add_score_set', 'set_scores', 'add_role', 'publish', 'add_badge'" ) @@ -369,5 +366,5 @@ def test_cannot_get_permission_with_non_existing_item(client, setup_router_db): response_data = response.json() assert ( response_data["detail"][0]["msg"] == "value is not a valid enumeration member; permitted: " - "'experiment', 'experiment-set', 'score-set'" + "'collection', 'experiment', 'experiment-set', 'score-set'" ) diff --git a/tests/routers/test_score_set.py b/tests/routers/test_score_set.py index c0d598fe..8a38952e 100644 --- a/tests/routers/test_score_set.py +++ b/tests/routers/test_score_set.py @@ -34,6 +34,8 @@ SAVED_EXTRA_CONTRIBUTOR, SAVED_PUBMED_PUBLICATION, SAVED_SHORT_EXTRA_LICENSE, + TEST_SCORE_CALIBRATION, + TEST_SAVED_SCORE_CALIBRATION, ) from tests.helpers.dependency_overrider import DependencyOverrider from tests.helpers.util import ( @@ -1383,7 +1385,7 @@ def test_multiple_score_set_meta_analysis_multiple_experiment_sets_with_differen ######################################################################################################################## -def test_search_score_sets_no_match(session, data_provider, client, setup_router_db, data_files): +def test_search_private_score_sets_no_match(session, data_provider, client, setup_router_db, data_files): experiment_1 = create_experiment(client, {"title": "Experiment 1"}) create_seq_score_set_with_variants( client, @@ -1395,12 +1397,12 @@ def test_search_score_sets_no_match(session, data_provider, client, setup_router ) search_payload = {"text": "fnord"} - response = client.post("/api/v1/score-sets/search", json=search_payload) + response = client.post("/api/v1/me/score-sets/search", json=search_payload) assert response.status_code == 200 assert len(response.json()) == 0 -def test_search_score_sets_match(session, data_provider, client, setup_router_db, data_files): +def test_search_private_score_sets_match(session, data_provider, client, setup_router_db, data_files): experiment_1 = create_experiment(client, {"title": "Experiment 1"}) score_set_1_1 = create_seq_score_set_with_variants( client, @@ -1412,39 +1414,265 @@ def test_search_score_sets_match(session, data_provider, client, setup_router_db ) search_payload = {"text": "fnord"} - response = client.post("/api/v1/score-sets/search", json=search_payload) + response = client.post("/api/v1/me/score-sets/search", json=search_payload) assert response.status_code == 200 assert len(response.json()) == 1 assert response.json()[0]["title"] == score_set_1_1["title"] -def test_search_score_sets_urn_match(session, data_provider, client, setup_router_db, data_files): +def test_search_private_score_sets_urn_match(session, data_provider, client, setup_router_db, data_files): experiment_1 = create_experiment(client) score_set_1_1 = create_seq_score_set_with_variants( client, session, data_provider, experiment_1["urn"], data_files / "scores.csv" ) search_payload = {"urn": score_set_1_1["urn"]} - response = client.post("/api/v1/score-sets/search", json=search_payload) + response = client.post("/api/v1/me/score-sets/search", json=search_payload) assert response.status_code == 200 assert len(response.json()) == 1 assert response.json()[0]["urn"] == score_set_1_1["urn"] # There is space in the end of test urn. The search result returned nothing before. -def test_search_score_sets_urn_with_space_match(session, data_provider, client, setup_router_db, data_files): +def test_search_private_score_sets_urn_with_space_match(session, data_provider, client, setup_router_db, data_files): experiment_1 = create_experiment(client) score_set_1_1 = create_seq_score_set_with_variants( client, session, data_provider, experiment_1["urn"], data_files / "scores.csv" ) urn_with_space = score_set_1_1["urn"] + " " search_payload = {"urn": urn_with_space} - response = client.post("/api/v1/score-sets/search", json=search_payload) + response = client.post("/api/v1/me/score-sets/search", json=search_payload) assert response.status_code == 200 assert len(response.json()) == 1 assert response.json()[0]["urn"] == score_set_1_1["urn"] +def test_search_others_private_score_sets_no_match(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client, {"title": "Experiment 1"}) + score_set_1_1 = create_seq_score_set_with_variants( + client, + session, + data_provider, + experiment_1["urn"], + data_files / "scores.csv", + update={"title": "Test Score Set"}, + ) + change_ownership(session, score_set_1_1["urn"], ScoreSetDbModel) + search_payload = {"text": "fnord"} + response = client.post("/api/v1/me/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 0 + + +def test_search_others_private_score_sets_match(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client, {"title": "Experiment 1"}) + score_set_1_1 = create_seq_score_set_with_variants( + client, + session, + data_provider, + experiment_1["urn"], + data_files / "scores.csv", + update={"title": "Test Fnord Score Set"}, + ) + change_ownership(session, score_set_1_1["urn"], ScoreSetDbModel) + search_payload = {"text": "fnord"} + response = client.post("/api/v1/me/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 0 + + +def test_search_others_private_score_sets_urn_match(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client) + score_set_1_1 = create_seq_score_set_with_variants( + client, session, data_provider, experiment_1["urn"], data_files / "scores.csv" + ) + change_ownership(session, score_set_1_1["urn"], ScoreSetDbModel) + search_payload = {"urn": score_set_1_1["urn"]} + response = client.post("/api/v1/me/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 0 + + +# There is space in the end of test urn. The search result returned nothing before. +def test_search_others_private_score_sets_urn_with_space_match(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client) + score_set_1_1 = create_seq_score_set_with_variants( + client, session, data_provider, experiment_1["urn"], data_files / "scores.csv" + ) + change_ownership(session, score_set_1_1["urn"], ScoreSetDbModel) + urn_with_space = score_set_1_1["urn"] + " " + search_payload = {"urn": urn_with_space} + response = client.post("/api/v1/me/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 0 + + +def test_search_public_score_sets_no_match(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client, {"title": "Experiment 1"}) + score_set_1_1 = create_seq_score_set_with_variants( + client, + session, + data_provider, + experiment_1["urn"], + data_files / "scores.csv", + update={"title": "Test Score Set"}, + ) + score_set_response = client.post(f"/api/v1/score-sets/{score_set_1_1['urn']}/publish") + assert score_set_response.status_code == 200 + + search_payload = {"text": "fnord"} + response = client.post("/api/v1/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 0 + + +def test_search_public_score_sets_match(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client, {"title": "Experiment 1"}) + score_set_1_1 = create_seq_score_set_with_variants( + client, + session, + data_provider, + experiment_1["urn"], + data_files / "scores.csv", + update={"title": "Test Fnord Score Set"}, + ) + + score_set_response = client.post(f"/api/v1/score-sets/{score_set_1_1['urn']}/publish") + assert score_set_response.status_code == 200 + search_payload = {"text": "fnord"} + response = client.post("/api/v1/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["title"] == score_set_1_1["title"] + + +def test_search_public_score_sets_urn_with_space_match(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client) + score_set_1_1 = create_seq_score_set_with_variants( + client, session, data_provider, experiment_1["urn"], data_files / "scores.csv" + ) + score_set_response = client.post(f"/api/v1/score-sets/{score_set_1_1['urn']}/publish") + published_score_set = score_set_response.json() + assert score_set_response.status_code == 200 + urn_with_space = published_score_set["urn"] + " " + search_payload = {"urn": urn_with_space} + response = client.post("/api/v1/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["urn"] == published_score_set["urn"] + + +def test_search_others_public_score_sets_no_match(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client, {"title": "Experiment 1"}) + score_set_1_1 = create_seq_score_set_with_variants( + client, + session, + data_provider, + experiment_1["urn"], + data_files / "scores.csv", + update={"title": "Test Score Set"}, + ) + score_set_response = client.post(f"/api/v1/score-sets/{score_set_1_1['urn']}/publish") + publish_score_set = score_set_response.json() + assert score_set_response.status_code == 200 + change_ownership(session, publish_score_set["urn"], ScoreSetDbModel) + search_payload = {"text": "fnord"} + response = client.post("/api/v1/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 0 + + +def test_search_others_public_score_sets_match(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client, {"title": "Experiment 1"}) + score_set_1_1 = create_seq_score_set_with_variants( + client, + session, + data_provider, + experiment_1["urn"], + data_files / "scores.csv", + update={"title": "Test Fnord Score Set"}, + ) + score_set_response = client.post(f"/api/v1/score-sets/{score_set_1_1['urn']}/publish") + publish_score_set = score_set_response.json() + assert score_set_response.status_code == 200 + change_ownership(session, publish_score_set["urn"], ScoreSetDbModel) + assert session.query(ScoreSetDbModel).filter_by(urn=publish_score_set["urn"]).one() + search_payload = {"text": "fnord"} + response = client.post("/api/v1/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["title"] == publish_score_set["title"] + + +def test_search_others_public_score_sets_urn_match(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client) + score_set_1_1 = create_seq_score_set_with_variants( + client, session, data_provider, experiment_1["urn"], data_files / "scores.csv" + ) + score_set_response = client.post(f"/api/v1/score-sets/{score_set_1_1['urn']}/publish") + publish_score_set = score_set_response.json() + assert score_set_response.status_code == 200 + change_ownership(session, publish_score_set["urn"], ScoreSetDbModel) + search_payload = {"urn": score_set_1_1["urn"]} + response = client.post("/api/v1/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["urn"] == publish_score_set["urn"] + + +def test_search_others_public_score_sets_urn_with_space_match(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client) + score_set_1_1 = create_seq_score_set_with_variants( + client, session, data_provider, experiment_1["urn"], data_files / "scores.csv" + ) + score_set_response = client.post(f"/api/v1/score-sets/{score_set_1_1['urn']}/publish") + assert score_set_response.status_code == 200 + published_score_set = score_set_response.json() + change_ownership(session, published_score_set["urn"], ScoreSetDbModel) + urn_with_space = published_score_set["urn"] + " " + search_payload = {"urn": urn_with_space} + response = client.post("/api/v1/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["urn"] == published_score_set["urn"] + + +def test_search_private_score_sets_not_showing_public_score_set(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client) + score_set_1_1 = create_seq_score_set_with_variants( + client, session, data_provider, experiment_1["urn"], data_files / "scores.csv" + ) + score_set_1_2 = create_seq_score_set_with_variants( + client, session, data_provider, experiment_1["urn"], data_files / "scores.csv" + ) + score_set_response = client.post(f"/api/v1/score-sets/{score_set_1_1['urn']}/publish") + assert score_set_response.status_code == 200 + search_payload = {"published": False} + response = client.post("/api/v1/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["urn"] == score_set_1_2["urn"] + + +def test_search_public_score_sets_not_showing_private_score_set(session, data_provider, client, setup_router_db, data_files): + experiment_1 = create_experiment(client) + score_set_1_1 = create_seq_score_set_with_variants( + client, session, data_provider, experiment_1["urn"], data_files / "scores.csv" + ) + create_seq_score_set_with_variants( + client, session, data_provider, experiment_1["urn"], data_files / "scores.csv" + ) + score_set_response = client.post(f"/api/v1/score-sets/{score_set_1_1['urn']}/publish") + assert score_set_response.status_code == 200 + published_score_set = score_set_response.json() + search_payload = {"published": True} + response = client.post("/api/v1/score-sets/search", json=search_payload) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["urn"] == published_score_set["urn"] + + + ######################################################################################################################## # Score set deletion ######################################################################################################################## @@ -1679,3 +1907,230 @@ def test_can_modify_metadata_for_score_set_with_inactive_license(session, client assert response.status_code == 200 response_data = response.json() assert ("title", response_data["title"]) == ("title", "Update title") + + +######################################################################################################################## +# Supersede score set +######################################################################################################################## + +def test_create_superseding_score_set(session, data_provider, client, setup_router_db, data_files): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + publish_score_set_response = client.post(f"/api/v1/score-sets/{score_set['urn']}/publish") + assert publish_score_set_response.status_code == 200 + published_score_set = publish_score_set_response.json() + score_set_post_payload = deepcopy(TEST_MINIMAL_SEQ_SCORESET) + score_set_post_payload["experimentUrn"] = published_score_set["experiment"]["urn"] + score_set_post_payload["supersededScoreSetUrn"] = published_score_set["urn"] + superseding_score_set_response = client.post("/api/v1/score-sets/", json=score_set_post_payload) + assert superseding_score_set_response.status_code == 200 + +def test_can_view_unpublished_superseding_score_set(session, data_provider, client, setup_router_db, data_files): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + publish_score_set_response = client.post(f"/api/v1/score-sets/{unpublished_score_set['urn']}/publish") + assert publish_score_set_response.status_code == 200 + published_score_set = publish_score_set_response.json() + score_set_post_payload = deepcopy(TEST_MINIMAL_SEQ_SCORESET) + score_set_post_payload["experimentUrn"] = published_score_set["experiment"]["urn"] + score_set_post_payload["supersededScoreSetUrn"] = published_score_set["urn"] + superseding_score_set_response = client.post("/api/v1/score-sets/", json=score_set_post_payload) + assert superseding_score_set_response.status_code == 200 + superseding_score_set = superseding_score_set_response.json() + score_set_response = client.get(f"/api/v1/score-sets/{published_score_set['urn']}") + score_set = score_set_response.json() + assert score_set_response.status_code == 200 + assert score_set["urn"] == superseding_score_set["supersededScoreSet"]["urn"] + assert score_set["supersedingScoreSet"]["urn"] == superseding_score_set["urn"] + +def test_cannot_view_others_unpublished_superseding_score_set(session, data_provider, client, setup_router_db, data_files): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + publish_score_set_response = client.post(f"/api/v1/score-sets/{unpublished_score_set['urn']}/publish") + assert publish_score_set_response.status_code == 200 + published_score_set = publish_score_set_response.json() + score_set_post_payload = deepcopy(TEST_MINIMAL_SEQ_SCORESET) + score_set_post_payload["experimentUrn"] = published_score_set["experiment"]["urn"] + score_set_post_payload["supersededScoreSetUrn"] = published_score_set["urn"] + superseding_score_set_response = client.post("/api/v1/score-sets/", json=score_set_post_payload) + assert superseding_score_set_response.status_code == 200 + superseding_score_set = superseding_score_set_response.json() + change_ownership(session, superseding_score_set["urn"], ScoreSetDbModel) + score_set_response = client.get(f"/api/v1/score-sets/{published_score_set['urn']}") + score_set = score_set_response.json() + assert score_set_response.status_code == 200 + assert score_set["urn"] == superseding_score_set["supersededScoreSet"]["urn"] + # Other users can't view the unpublished superseding score set. + assert "supersedingScoreSet" not in score_set + +def test_can_view_others_published_superseding_score_set(session, data_provider, client, setup_router_db, data_files): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + publish_score_set_response = client.post(f"/api/v1/score-sets/{unpublished_score_set['urn']}/publish") + assert publish_score_set_response.status_code == 200 + published_score_set = publish_score_set_response.json() + + superseding_score_set = create_seq_score_set_with_variants( + client, + session, + data_provider, + published_score_set["experiment"]["urn"], + data_files / "scores.csv", + update={"supersededScoreSetUrn": published_score_set["urn"]}, + ) + published_superseding_score_set_response = client.post(f"/api/v1/score-sets/{superseding_score_set['urn']}/publish") + assert published_superseding_score_set_response.status_code == 200 + published_superseding_score_set = published_superseding_score_set_response.json() + + change_ownership(session, published_superseding_score_set["urn"], ScoreSetDbModel) + + score_set_response = client.get(f"/api/v1/score-sets/{published_score_set['urn']}") + assert score_set_response.status_code == 200 + score_set = score_set_response.json() + assert score_set["urn"] == published_superseding_score_set["supersededScoreSet"]["urn"] + # Other users can view published superseding score set. + assert score_set["supersedingScoreSet"]["urn"] == published_superseding_score_set["urn"] + + +# The superseding score set is unpublished so the newest version to its owner is the unpublished one. +def test_show_correct_score_set_version_with_superseded_score_set_to_its_owner(session, data_provider, client, setup_router_db, data_files): + experiment = create_experiment(client) + unpublished_score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + publish_score_set_response = client.post(f"/api/v1/score-sets/{unpublished_score_set['urn']}/publish") + assert publish_score_set_response.status_code == 200 + published_score_set = publish_score_set_response.json() + score_set_post_payload = deepcopy(TEST_MINIMAL_SEQ_SCORESET) + score_set_post_payload["experimentUrn"] = published_score_set["experiment"]["urn"] + score_set_post_payload["supersededScoreSetUrn"] = published_score_set["urn"] + superseding_score_set_response = client.post("/api/v1/score-sets/", json=score_set_post_payload) + assert superseding_score_set_response.status_code == 200 + superseding_score_set = superseding_score_set_response.json() + score_set_response = client.get(f"/api/v1/score-sets/{superseding_score_set['urn']}") + score_set = score_set_response.json() + assert score_set_response.status_code == 200 + assert score_set["urn"] == superseding_score_set["urn"] + + +def test_anonymous_user_cannot_add_score_calibrations_to_score_set(client, setup_router_db, anonymous_app_overrides): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + calibration_payload = deepcopy(TEST_SCORE_CALIBRATION) + + with DependencyOverrider(anonymous_app_overrides): + response = client.post( + f"/api/v1/score-sets/{score_set['urn']}/calibration/data", json={"test_calibrations": calibration_payload} + ) + response_data = response.json() + + assert response.status_code == 401 + assert "score_calibrations" not in response_data + + +def test_user_cannot_add_score_calibrations_to_own_score_set(client, setup_router_db, anonymous_app_overrides): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + calibration_payload = deepcopy(TEST_SCORE_CALIBRATION) + + response = client.post( + f"/api/v1/score-sets/{score_set['urn']}/calibration/data", json={"test_calibrations": calibration_payload} + ) + response_data = response.json() + + assert response.status_code == 401 + assert "score_calibrations" not in response_data + + +def test_admin_can_add_score_calibrations_to_score_set(client, setup_router_db, admin_app_overrides): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + calibration_payload = deepcopy(TEST_SCORE_CALIBRATION) + + with DependencyOverrider(admin_app_overrides): + response = client.post( + f"/api/v1/score-sets/{score_set['urn']}/calibration/data", json={"test_calibrations": calibration_payload} + ) + response_data = response.json() + + expected_response = update_expected_response_for_created_resources( + deepcopy(TEST_MINIMAL_SEQ_SCORESET_RESPONSE), experiment, score_set + ) + expected_response["scoreCalibrations"] = {"test_calibrations": deepcopy(TEST_SAVED_SCORE_CALIBRATION)} + + assert response.status_code == 200 + for key in expected_response: + assert (key, expected_response[key]) == (key, response_data[key]) + + +def test_score_set_not_found_for_non_existent_score_set_when_adding_score_calibrations( + client, setup_router_db, admin_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + calibration_payload = deepcopy(TEST_SCORE_CALIBRATION) + + with DependencyOverrider(admin_app_overrides): + response = client.post( + f"/api/v1/score-sets/{score_set['urn']+'xxx'}/calibration/data", + json={"test_calibrations": calibration_payload}, + ) + response_data = response.json() + + assert response.status_code == 404 + assert "score_calibrations" not in response_data + + +######################################################################################################################## +# Score set download files +######################################################################################################################## + +# Test file doesn't have hgvs_splice so its values are all NA. +def test_download_scores_file(session, data_provider, client, setup_router_db, data_files): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], data_files / "scores.csv" + ) + + publish_score_set_response = client.post(f"/api/v1/score-sets/{score_set['urn']}/publish") + assert publish_score_set_response.status_code == 200 + publish_score_set = publish_score_set_response.json() + print(publish_score_set) + + download_scores_csv_response = client.get(f"/api/v1/score-sets/{publish_score_set['urn']}/scores?drop_na_columns=true") + assert download_scores_csv_response.status_code == 200 + download_scores_csv = download_scores_csv_response.text + csv_header = download_scores_csv.split("\n")[0] + columns = csv_header.split(",") + assert "hgvs_nt" in columns + assert "hgvs_pro" in columns + assert "hgvs_splice" not in columns + + +def test_download_counts_file(session, data_provider, client, setup_router_db, data_files): + experiment = create_experiment(client) + score_set = create_seq_score_set_with_variants( + client, session, data_provider, experiment["urn"], + scores_csv_path=data_files / "scores.csv", + counts_csv_path = data_files / "counts.csv" + ) + publish_score_set_response = client.post(f"/api/v1/score-sets/{score_set['urn']}/publish") + assert publish_score_set_response.status_code == 200 + publish_score_set = publish_score_set_response.json() + + download_counts_csv_response = client.get(f"/api/v1/score-sets/{publish_score_set['urn']}/counts?drop_na_columns=true") + assert download_counts_csv_response.status_code == 200 + download_counts_csv = download_counts_csv_response.text + csv_header = download_counts_csv.split("\n")[0] + columns = csv_header.split(",") + assert "hgvs_nt" in columns + assert "hgvs_pro" in columns + assert "hgvs_splice" not in columns