From 5ceb5f6533a4c665543ceee716e343e209f02312 Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 15 Nov 2023 15:16:31 +1100 Subject: [PATCH 001/161] In progress --- api/graphql/schema.py | 70 +++++++++++++++++++++------ api/routes/__init__.py | 13 ++--- api/routes/cohort.py | 38 +++++++++++++++ db/project.xml | 13 +++++ db/python/layers/__init__.py | 1 + db/python/layers/cohort.py | 54 +++++++++++++++++++++ db/python/tables/cohort.py | 93 ++++++++++++++++++++++++++++++++++++ scripts/cohort_builder.py | 50 +++++++++++++++++++ 8 files changed, 311 insertions(+), 21 deletions(-) create mode 100644 api/routes/cohort.py create mode 100644 db/python/layers/cohort.py create mode 100644 db/python/tables/cohort.py create mode 100644 scripts/cohort_builder.py diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 7b9a030f8..f1552cdb2 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -17,31 +17,25 @@ from api.graphql.filters import GraphQLFilter, GraphQLMetaFilter from api.graphql.loaders import LoaderKeys, get_context from db.python import enum_tables -from db.python.layers import AnalysisLayer, SampleLayer, SequencingGroupLayer +from db.python.layers import (AnalysisLayer, CohortLayer, SampleLayer, + SequencingGroupLayer) from db.python.layers.assay import AssayLayer from db.python.layers.family import FamilyLayer from db.python.tables.analysis import AnalysisFilter from db.python.tables.assay import AssayFilter +from db.python.tables.cohort import CohortFilter from db.python.tables.project import ProjectPermissionsTable from db.python.tables.sample import SampleFilter from db.python.tables.sequencing_group import SequencingGroupFilter from db.python.utils import GenericFilter from models.enums import AnalysisStatus -from models.models import ( - AnalysisInternal, - AssayInternal, - FamilyInternal, - ParticipantInternal, - Project, - SampleInternal, - SequencingGroupInternal, -) +from models.models import (AnalysisInternal, AssayInternal, FamilyInternal, + ParticipantInternal, Project, SampleInternal, + SequencingGroupInternal) from models.models.sample import sample_id_transform_to_raw from models.utils.sample_id_format import sample_id_format from models.utils.sequencing_group_id_format import ( - sequencing_group_id_format, - sequencing_group_id_transform_to_raw, -) + sequencing_group_id_format, sequencing_group_id_transform_to_raw) enum_methods = {} for enum in enum_tables.__dict__.values(): @@ -64,6 +58,27 @@ async def m(info: Info) -> list[str]: GraphQLEnum = strawberry.type(type('GraphQLEnum', (object,), enum_methods)) +#Create cohort GraphQL model +@strawberry.type +class GraphQLCohort: + """Cohort GraphQL model""" + + id: int + name: str + description: str + + project_id: strawberry.Private[int] + + + #TODO: We need to fix this, so that we can return this type. + #@staticmethod + #def from_internal(internal: CohortInternal) -> 'GraphQLCohort': + # return GraphQLCohort( + # id=internal.id, + # name=internal.name, + # description=internal.description, + # ) + @strawberry.type class GraphQLProject: """Project GraphQL model""" @@ -547,14 +562,39 @@ async def sample(self, info: Info, root: 'GraphQLAssay') -> GraphQLSample: sample = await loader.load(root.sample_id) return GraphQLSample.from_internal(sample) - @strawberry.type -class Query: +class Query: #entry point to graphql. """GraphQL Queries""" @strawberry.field() def enum(self, info: Info) -> GraphQLEnum: return GraphQLEnum() + + @strawberry.field() + async def cohort(self, info: Info, project: GraphQLFilter[str] | None = None,)-> str: + #TODO: Fix the return type for this. + connection = info.context['connection'] + clayer = CohortLayer(connection) + ptable = ProjectPermissionsTable(connection.connection) + project_name_map: dict[str, int] = {} + if project: + project_names = project.all_values() + projects = await ptable.get_and_check_access_to_projects_for_names( + user=connection.author, project_names=project_names, readonly=True + ) + project_name_map = {p.name: p.id for p in projects} + + + filter_ = CohortFilter( + project=project.to_internal_filter(lambda pname: project_name_map[pname]) + if project + else None, + ) + + cohort = await clayer.query(filter_) + print(cohort) + + return cohort @strawberry.field() async def project(self, info: Info, name: str) -> GraphQLProject: diff --git a/api/routes/__init__.py b/api/routes/__init__.py index 18edb5969..4a17aea77 100644 --- a/api/routes/__init__.py +++ b/api/routes/__init__.py @@ -1,11 +1,12 @@ -from api.routes.sample import router as sample_router -from api.routes.imports import router as import_router from api.routes.analysis import router as analysis_router from api.routes.assay import router as assay_router -from api.routes.participant import router as participant_router +from api.routes.billing import router as billing_router +from api.routes.cohort import router as cohort_router +from api.routes.enum import router as enum_router from api.routes.family import router as family_router +from api.routes.imports import router as import_router +from api.routes.participant import router as participant_router from api.routes.project import router as project_router -from api.routes.web import router as web_router -from api.routes.enum import router as enum_router +from api.routes.sample import router as sample_router from api.routes.sequencing_groups import router as sequencing_groups_router -from api.routes.billing import router as billing_router +from api.routes.web import router as web_router diff --git a/api/routes/cohort.py b/api/routes/cohort.py new file mode 100644 index 000000000..f5d865066 --- /dev/null +++ b/api/routes/cohort.py @@ -0,0 +1,38 @@ +from typing import Any + +from fastapi import APIRouter + +from api.utils.db import (Connection, get_project_readonly_connection, + get_project_write_connection, + get_projectless_db_connection) +from db.python.layers.cohort import CohortLayer + +router = APIRouter(prefix='/cohort', tags=['cohort']) + + +@router.put('/', operation_id='getSGsForCohort') +async def get_sgs_for_cohort( + projects: list[str], + connection: Connection = get_projectless_db_connection, +): + """ + Get all sequencing groups for a cohort + """ + # We should probably do this project per project? + cohortlayer = CohortLayer(connection) + return await cohortlayer.get_sgs_for_cohort(projects) + +@router.post('/{project}/', operation_id='createCohort') +async def create_cohort( + cohort_name: str, + sequencing_group_ids: list[str], + description: str, + connection: Connection = get_project_write_connection, +) -> dict[str, Any]: + """ + Create a cohort with the given name and sample/sequencing group IDs. + """ + cohortlayer = CohortLayer(connection) + cohort_id = await cohortlayer.create_cohort(project=connection.project, cohort_name=cohort_name, sequencing_group_ids=sequencing_group_ids, description=description,author=connection.author) + #sequencing_group_ids = sequencing_group_id_transform_to_raw_list(sequencing_group_ids) + return {'cohort_id': cohort_id} diff --git a/db/project.xml b/db/project.xml index 54099a8de..553a7dc93 100644 --- a/db/project.xml +++ b/db/project.xml @@ -843,4 +843,17 @@ INSERT INTO `group` (name) VALUES ('project-creators'); INSERT INTO `group` (name) VALUES ('members-admin'); + + + + + + + + + + + + + diff --git a/db/python/layers/__init__.py b/db/python/layers/__init__.py index f67ab78c6..9c9673649 100644 --- a/db/python/layers/__init__.py +++ b/db/python/layers/__init__.py @@ -1,6 +1,7 @@ from db.python.layers.analysis import AnalysisLayer from db.python.layers.assay import AssayLayer from db.python.layers.base import BaseLayer +from db.python.layers.cohort import CohortLayer from db.python.layers.family import FamilyLayer from db.python.layers.participant import ParticipantLayer from db.python.layers.sample import SampleLayer diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py new file mode 100644 index 000000000..f065b2287 --- /dev/null +++ b/db/python/layers/cohort.py @@ -0,0 +1,54 @@ + +from db.python.connect import Connection +from db.python.layers.base import BaseLayer +from db.python.tables.analysis import AnalysisTable +from db.python.tables.cohort import CohortFilter, CohortTable +from db.python.tables.project import ProjectId +from db.python.tables.sample import SampleTable +from db.python.utils import get_logger + +logger = get_logger() + +class CohortLayer(BaseLayer): + """Layer for cohort logic""" + + def __init__(self, connection: Connection): + super().__init__(connection) + + self.sampt = SampleTable(connection) + self.at = AnalysisTable(connection) + self.ct = CohortTable(connection) + + # GETS + + async def query(self, filter_: CohortFilter): + """ Query Cohorts""" + cohorts = await self.ct.query(filter_) + return cohorts + + async def get_sgs_for_cohort( + self, + projects: list[str], + ) -> list[str]: + """Get all sequencing groups for a cohort""" + print(projects) + project_objects = [await self.ptable._get_project_by_name(project) for project in projects] + project_ids = [project.id for project in project_objects] + await self.ptable.check_access_to_project_ids( + self.author, project_ids, readonly=True + ) + + return await self.ct.get_sgs_for_cohort(project_ids) + + async def create_cohort( + self, + project: ProjectId, + cohort_name: str, + sequencing_group_ids: list[str], + description: str, + author: str = None, + ) -> int: + """Create a new cohort""" + output = await self.ct.create_cohort(project=project, cohort_name=cohort_name, sequencing_group_ids=sequencing_group_ids,description=description,author=author) + return {'cohort_id': cohort_name, 'sg': sequencing_group_ids, 'output': output } + \ No newline at end of file diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py new file mode 100644 index 000000000..fa0eb9d47 --- /dev/null +++ b/db/python/tables/cohort.py @@ -0,0 +1,93 @@ +# pylint: disable=too-many-instance-attributes +import dataclasses + +from db.python.connect import DbBase +from db.python.tables.project import ProjectId +from db.python.utils import GenericFilter, GenericFilterModel +from models.utils.sequencing_group_id_format import ( + sequencing_group_id_format, sequencing_group_id_transform_to_raw) + + +@dataclasses.dataclass(kw_only=True) +class CohortFilter(GenericFilterModel): + """ + Filters for Cohort + """ + id: GenericFilter[int] | None = None + project: GenericFilter[ProjectId] | None = None + +class CohortTable(DbBase): + """ + Capture Cohort table operations and queries + """ + + table_name = 'cohort' + common_get_keys = [ + 'id', + 'derived_from', + 'description', + 'author', + 'project', + ] + + async def query(self, filter_: CohortFilter): + """ Query Cohorts""" + wheres, values = filter_.to_sql(field_overrides={}) + if not wheres: + raise ValueError(f'Invalid filter: {filter_}') + common_get_keys_str = ','.join(self.common_get_keys) + _query = f""" + SELECT {common_get_keys_str} + FROM cohort + WHERE {wheres} + """ + + print(_query) + print(values) + + rows = await self.connection.fetch_all(_query, values) + return [dict(row) for row in rows] + + + async def get_sgs_for_cohort( + self, + projects: list[str], + ) -> list[str]: + """ + Get all sequencing groups for a cohort + """ + + _query = 'SELECT * FROM sequencing_group INNER JOIN sample ON sample.id = sequencing_group.sample_id WHERE sample.project in :project' + + rows = await self.connection.fetch_all(_query, {'project': projects}) + + return [sequencing_group_id_format(row['id']) for row in rows] + + async def create_cohort( + self, + project: str, + cohort_name: str, + sequencing_group_ids: list[str], + author: str, + derived_from: str = None, + description: str = 'This field should accept null', + ) -> int: + """ + Create a new cohort + """ + + # Create cohort + + #TODO: Update scheme to handle cohort name + print(cohort_name) + + _query = 'INSERT INTO cohort (derived_from, author, description, project) VALUES (:derived_from, :author, :description, :project) RETURNING id' + cohort_id = await self.connection.fetch_val(_query, {'derived_from': derived_from , 'author': author, 'description': description, 'project': project}) + print(cohort_id) + #populate sequencing groups + _query = 'INSERT INTO cohort_sequencing_group (cohort_id, sequencing_group_id) VALUES (:cohort_id, :sequencing_group_id)' + for sg in sequencing_group_ids: + await self.connection.execute(_query, {'cohort_id': cohort_id, 'sequencing_group_id': sequencing_group_id_transform_to_raw(sg)}) + + return cohort_id + \ No newline at end of file diff --git a/scripts/cohort_builder.py b/scripts/cohort_builder.py new file mode 100644 index 000000000..55501bee6 --- /dev/null +++ b/scripts/cohort_builder.py @@ -0,0 +1,50 @@ +import argparse +import ast + +from metamist.apis import CohortApi + +capi = CohortApi() + +def main(projects, sequencing_groups, sequencing_groups_meta): + print("Projects:", projects) + print("Sequencing Groups:", sequencing_groups) + print("Sequencing Groups Meta:", sequencing_groups_meta) + + #1. Get SG's for the cohort + #Replace this with a graphql query. + sgs = capi.get_sgs_for_cohort(projects) + print(sgs) + #2. Create cohort + cohort_id = capi.create_cohort(project='vb-fewgenomes', cohort_name='second_test', description='a second test', request_body=sgs) + print(cohort_id) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Example script with argparse") + + parser.add_argument( + "--projects", + nargs="+", + help="List of project names", + required=False, + ) + parser.add_argument( + "--sequencing-groups", + nargs="+", + help="List of sequencing group names", + required=False, + ) + parser.add_argument( + "--sequencing-groups-meta", + type=ast.literal_eval, + help="Dictionary containing sequencing group metadata", + required=False, + ) + + args = parser.parse_args() + + projects = args.projects + sequencing_groups = args.sequencing_groups + sequencing_groups_meta = args.sequencing_groups_meta + + main(projects, sequencing_groups, sequencing_groups_meta) From d7ef222418aa0c413f10b0a645940823d6195f72 Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 15 Nov 2023 15:17:19 +1100 Subject: [PATCH 002/161] In progress --- api/graphql/schema.py | 6 +++--- db/python/layers/cohort.py | 2 +- db/python/tables/cohort.py | 8 ++++---- scripts/cohort_builder.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index f1552cdb2..7431201b4 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -58,7 +58,7 @@ async def m(info: Info) -> list[str]: GraphQLEnum = strawberry.type(type('GraphQLEnum', (object,), enum_methods)) -#Create cohort GraphQL model +#Create cohort GraphQL model @strawberry.type class GraphQLCohort: """Cohort GraphQL model""" @@ -569,7 +569,7 @@ class Query: #entry point to graphql. @strawberry.field() def enum(self, info: Info) -> GraphQLEnum: return GraphQLEnum() - + @strawberry.field() async def cohort(self, info: Info, project: GraphQLFilter[str] | None = None,)-> str: #TODO: Fix the return type for this. @@ -593,7 +593,7 @@ async def cohort(self, info: Info, project: GraphQLFilter[str] | None = None,)-> cohort = await clayer.query(filter_) print(cohort) - + return cohort @strawberry.field() diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index f065b2287..7f9bf30bf 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -51,4 +51,4 @@ async def create_cohort( """Create a new cohort""" output = await self.ct.create_cohort(project=project, cohort_name=cohort_name, sequencing_group_ids=sequencing_group_ids,description=description,author=author) return {'cohort_id': cohort_name, 'sg': sequencing_group_ids, 'output': output } - \ No newline at end of file + diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index fa0eb9d47..0657de36f 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -56,7 +56,7 @@ async def get_sgs_for_cohort( """ Get all sequencing groups for a cohort """ - + _query = 'SELECT * FROM sequencing_group INNER JOIN sample ON sample.id = sequencing_group.sample_id WHERE sample.project in :project' rows = await self.connection.fetch_all(_query, {'project': projects}) @@ -76,7 +76,7 @@ async def create_cohort( Create a new cohort """ - # Create cohort + # Create cohort #TODO: Update scheme to handle cohort name print(cohort_name) @@ -84,10 +84,10 @@ async def create_cohort( _query = 'INSERT INTO cohort (derived_from, author, description, project) VALUES (:derived_from, :author, :description, :project) RETURNING id' cohort_id = await self.connection.fetch_val(_query, {'derived_from': derived_from , 'author': author, 'description': description, 'project': project}) print(cohort_id) - #populate sequencing groups + #populate sequencing groups _query = 'INSERT INTO cohort_sequencing_group (cohort_id, sequencing_group_id) VALUES (:cohort_id, :sequencing_group_id)' for sg in sequencing_group_ids: await self.connection.execute(_query, {'cohort_id': cohort_id, 'sequencing_group_id': sequencing_group_id_transform_to_raw(sg)}) return cohort_id - \ No newline at end of file + diff --git a/scripts/cohort_builder.py b/scripts/cohort_builder.py index 55501bee6..b1d4cfc19 100644 --- a/scripts/cohort_builder.py +++ b/scripts/cohort_builder.py @@ -11,7 +11,7 @@ def main(projects, sequencing_groups, sequencing_groups_meta): print("Sequencing Groups Meta:", sequencing_groups_meta) #1. Get SG's for the cohort - #Replace this with a graphql query. + #Replace this with a graphql query. sgs = capi.get_sgs_for_cohort(projects) print(sgs) #2. Create cohort From da2f9db3bb99da6d59fe2fa31cd1c0b11521921b Mon Sep 17 00:00:00 2001 From: vivbak Date: Thu, 16 Nov 2023 20:59:58 +1100 Subject: [PATCH 003/161] Cohort GraphQL Skeleton -- functional now --- api/graphql/schema.py | 28 +++++----- api/routes/cohort.py | 20 ++------ db/python/layers/cohort.py | 26 +++------- db/python/tables/cohort.py | 32 ++++-------- models/models/__init__.py | 102 ++++++++++++------------------------- models/models/cohort.py | 18 +++++++ 6 files changed, 82 insertions(+), 144 deletions(-) create mode 100644 models/models/cohort.py diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 7431201b4..cb8a4db12 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -29,9 +29,9 @@ from db.python.tables.sequencing_group import SequencingGroupFilter from db.python.utils import GenericFilter from models.enums import AnalysisStatus -from models.models import (AnalysisInternal, AssayInternal, FamilyInternal, - ParticipantInternal, Project, SampleInternal, - SequencingGroupInternal) +from models.models import (AnalysisInternal, AssayInternal, Cohort, + FamilyInternal, ParticipantInternal, Project, + SampleInternal, SequencingGroupInternal) from models.models.sample import sample_id_transform_to_raw from models.utils.sample_id_format import sample_id_format from models.utils.sequencing_group_id_format import ( @@ -64,20 +64,17 @@ class GraphQLCohort: """Cohort GraphQL model""" id: int - name: str + project: str description: str - project_id: strawberry.Private[int] - - #TODO: We need to fix this, so that we can return this type. - #@staticmethod - #def from_internal(internal: CohortInternal) -> 'GraphQLCohort': - # return GraphQLCohort( - # id=internal.id, - # name=internal.name, - # description=internal.description, - # ) + @staticmethod + def from_internal(internal: Cohort) -> 'GraphQLCohort': + return GraphQLCohort( + id=internal.id, + project=internal.project, + description=internal.description, + ) @strawberry.type class GraphQLProject: @@ -571,8 +568,7 @@ def enum(self, info: Info) -> GraphQLEnum: return GraphQLEnum() @strawberry.field() - async def cohort(self, info: Info, project: GraphQLFilter[str] | None = None,)-> str: - #TODO: Fix the return type for this. + async def cohort(self, info: Info, project: GraphQLFilter[str] | None = None,)-> list[GraphQLCohort]: connection = info.context['connection'] clayer = CohortLayer(connection) ptable = ProjectPermissionsTable(connection.connection) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index f5d865066..92a7a6a09 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -2,26 +2,12 @@ from fastapi import APIRouter -from api.utils.db import (Connection, get_project_readonly_connection, - get_project_write_connection, - get_projectless_db_connection) +from api.utils.db import Connection, get_project_write_connection from db.python.layers.cohort import CohortLayer router = APIRouter(prefix='/cohort', tags=['cohort']) -@router.put('/', operation_id='getSGsForCohort') -async def get_sgs_for_cohort( - projects: list[str], - connection: Connection = get_projectless_db_connection, -): - """ - Get all sequencing groups for a cohort - """ - # We should probably do this project per project? - cohortlayer = CohortLayer(connection) - return await cohortlayer.get_sgs_for_cohort(projects) - @router.post('/{project}/', operation_id='createCohort') async def create_cohort( cohort_name: str, @@ -33,6 +19,6 @@ async def create_cohort( Create a cohort with the given name and sample/sequencing group IDs. """ cohortlayer = CohortLayer(connection) - cohort_id = await cohortlayer.create_cohort(project=connection.project, cohort_name=cohort_name, sequencing_group_ids=sequencing_group_ids, description=description,author=connection.author) - #sequencing_group_ids = sequencing_group_id_transform_to_raw_list(sequencing_group_ids) + cohort_id = await cohortlayer.create_cohort(project=connection.project, cohort_name=cohort_name, sequencing_group_ids=sequencing_group_ids, description=description, author=connection.author) + # sequencing_group_ids = sequencing_group_id_transform_to_raw_list(sequencing_group_ids) return {'cohort_id': cohort_id} diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 7f9bf30bf..7714196ce 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -1,3 +1,4 @@ +from typing import Any from db.python.connect import Connection from db.python.layers.base import BaseLayer @@ -6,9 +7,11 @@ from db.python.tables.project import ProjectId from db.python.tables.sample import SampleTable from db.python.utils import get_logger +from models.models.cohort import Cohort logger = get_logger() + class CohortLayer(BaseLayer): """Layer for cohort logic""" @@ -21,25 +24,11 @@ def __init__(self, connection: Connection): # GETS - async def query(self, filter_: CohortFilter): + async def query(self, filter_: CohortFilter) -> list[Cohort]: """ Query Cohorts""" cohorts = await self.ct.query(filter_) return cohorts - async def get_sgs_for_cohort( - self, - projects: list[str], - ) -> list[str]: - """Get all sequencing groups for a cohort""" - print(projects) - project_objects = [await self.ptable._get_project_by_name(project) for project in projects] - project_ids = [project.id for project in project_objects] - await self.ptable.check_access_to_project_ids( - self.author, project_ids, readonly=True - ) - - return await self.ct.get_sgs_for_cohort(project_ids) - async def create_cohort( self, project: ProjectId, @@ -47,8 +36,7 @@ async def create_cohort( sequencing_group_ids: list[str], description: str, author: str = None, - ) -> int: + ) -> dict[str, Any]: """Create a new cohort""" - output = await self.ct.create_cohort(project=project, cohort_name=cohort_name, sequencing_group_ids=sequencing_group_ids,description=description,author=author) - return {'cohort_id': cohort_name, 'sg': sequencing_group_ids, 'output': output } - + output = await self.ct.create_cohort(project=project, cohort_name=cohort_name, sequencing_group_ids=sequencing_group_ids, description=description, author=author) + return {'cohort_id': cohort_name, 'sg': sequencing_group_ids, 'output': output} diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 0657de36f..7f2453675 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -4,8 +4,9 @@ from db.python.connect import DbBase from db.python.tables.project import ProjectId from db.python.utils import GenericFilter, GenericFilterModel -from models.utils.sequencing_group_id_format import ( - sequencing_group_id_format, sequencing_group_id_transform_to_raw) +from models.models.cohort import Cohort +from models.utils.sequencing_group_id_format import \ + sequencing_group_id_transform_to_raw @dataclasses.dataclass(kw_only=True) @@ -16,6 +17,7 @@ class CohortFilter(GenericFilterModel): id: GenericFilter[int] | None = None project: GenericFilter[ProjectId] | None = None + class CohortTable(DbBase): """ Capture Cohort table operations and queries @@ -46,26 +48,12 @@ async def query(self, filter_: CohortFilter): print(values) rows = await self.connection.fetch_all(_query, values) - return [dict(row) for row in rows] - - - async def get_sgs_for_cohort( - self, - projects: list[str], - ) -> list[str]: - """ - Get all sequencing groups for a cohort - """ - - _query = 'SELECT * FROM sequencing_group INNER JOIN sample ON sample.id = sequencing_group.sample_id WHERE sample.project in :project' - - rows = await self.connection.fetch_all(_query, {'project': projects}) - - return [sequencing_group_id_format(row['id']) for row in rows] + cohorts = [Cohort.from_db(dict(row)) for row in rows] + return cohorts async def create_cohort( self, - project: str, + project: int, cohort_name: str, sequencing_group_ids: list[str], author: str, @@ -77,17 +65,15 @@ async def create_cohort( """ # Create cohort - - #TODO: Update scheme to handle cohort name print(cohort_name) _query = 'INSERT INTO cohort (derived_from, author, description, project) VALUES (:derived_from, :author, :description, :project) RETURNING id' cohort_id = await self.connection.fetch_val(_query, {'derived_from': derived_from , 'author': author, 'description': description, 'project': project}) print(cohort_id) - #populate sequencing groups + + # populate sequencing groups _query = 'INSERT INTO cohort_sequencing_group (cohort_id, sequencing_group_id) VALUES (:cohort_id, :sequencing_group_id)' for sg in sequencing_group_ids: await self.connection.execute(_query, {'cohort_id': cohort_id, 'sequencing_group_id': sequencing_group_id_transform_to_raw(sg)}) return cohort_id - diff --git a/models/models/__init__.py b/models/models/__init__.py index a9ded5690..7151aa3d9 100644 --- a/models/models/__init__.py +++ b/models/models/__init__.py @@ -1,70 +1,34 @@ -from models.models.analysis import ( - Analysis, - AnalysisInternal, - DateSizeModel, - ProjectSizeModel, - ProportionalDateModel, - ProportionalDateProjectModel, - ProportionalDateTemporalMethod, - SequencingGroupSizeModel, -) -from models.models.assay import ( - Assay, - AssayInternal, - AssayUpsert, - AssayUpsertInternal, -) -from models.models.family import ( - Family, - FamilyInternal, - FamilySimple, - FamilySimpleInternal, - PedRowInternal, -) -from models.models.participant import ( - NestedParticipant, - NestedParticipantInternal, - Participant, - ParticipantInternal, - ParticipantUpsert, - ParticipantUpsertInternal, -) +from models.models.analysis import (Analysis, AnalysisInternal, DateSizeModel, + ProjectSizeModel, ProportionalDateModel, + ProportionalDateProjectModel, + ProportionalDateTemporalMethod, + SequencingGroupSizeModel) +from models.models.assay import (Assay, AssayInternal, AssayUpsert, + AssayUpsertInternal) +from models.models.billing import (BillingColumn, BillingRowRecord, + BillingTotalCostQueryModel, + BillingTotalCostRecord) +from models.models.cohort import Cohort +from models.models.family import (Family, FamilyInternal, FamilySimple, + FamilySimpleInternal, PedRowInternal) +from models.models.participant import (NestedParticipant, + NestedParticipantInternal, Participant, + ParticipantInternal, ParticipantUpsert, + ParticipantUpsertInternal) from models.models.project import Project -from models.models.sample import ( - NestedSample, - NestedSampleInternal, - Sample, - SampleInternal, - SampleUpsert, - SampleUpsertInternal, -) -from models.models.search import ( - ErrorResponse, - FamilySearchResponseData, - ParticipantSearchResponseData, - SampleSearchResponseData, - SearchItem, - SearchResponse, - SearchResponseData, - SequencingGroupSearchResponseData, -) -from models.models.sequencing_group import ( - NestedSequencingGroup, - NestedSequencingGroupInternal, - SequencingGroup, - SequencingGroupInternal, - SequencingGroupUpsert, - SequencingGroupUpsertInternal, -) -from models.models.web import ( - PagingLinks, - ProjectSummary, - ProjectSummaryInternal, - WebProject, -) -from models.models.billing import ( - BillingRowRecord, - BillingTotalCostRecord, - BillingTotalCostQueryModel, - BillingColumn, -) +from models.models.sample import (NestedSample, NestedSampleInternal, Sample, + SampleInternal, SampleUpsert, + SampleUpsertInternal) +from models.models.search import (ErrorResponse, FamilySearchResponseData, + ParticipantSearchResponseData, + SampleSearchResponseData, SearchItem, + SearchResponse, SearchResponseData, + SequencingGroupSearchResponseData) +from models.models.sequencing_group import (NestedSequencingGroup, + NestedSequencingGroupInternal, + SequencingGroup, + SequencingGroupInternal, + SequencingGroupUpsert, + SequencingGroupUpsertInternal) +from models.models.web import (PagingLinks, ProjectSummary, + ProjectSummaryInternal, WebProject) diff --git a/models/models/cohort.py b/models/models/cohort.py new file mode 100644 index 000000000..db2d721bf --- /dev/null +++ b/models/models/cohort.py @@ -0,0 +1,18 @@ +from models.base import SMBase + + +class Cohort(SMBase): + """ Model for Cohort """ + id: str + project: str + description: str + + @staticmethod + def from_db(d: dict): + """ + Convert from db keys, mainly converting id to id_ + """ + _id = d.pop('id', None) + project = d.pop('project', None) + description = d.pop('description', None) + return Cohort(id=_id, project=project, description=description) From 6f1d9daad93c3711d43783e52f09a4a3abccb804 Mon Sep 17 00:00:00 2001 From: vivbak Date: Thu, 16 Nov 2023 21:07:03 +1100 Subject: [PATCH 004/161] This should go in another PR --- scripts/cohort_builder.py | 50 --------------------------------------- 1 file changed, 50 deletions(-) delete mode 100644 scripts/cohort_builder.py diff --git a/scripts/cohort_builder.py b/scripts/cohort_builder.py deleted file mode 100644 index b1d4cfc19..000000000 --- a/scripts/cohort_builder.py +++ /dev/null @@ -1,50 +0,0 @@ -import argparse -import ast - -from metamist.apis import CohortApi - -capi = CohortApi() - -def main(projects, sequencing_groups, sequencing_groups_meta): - print("Projects:", projects) - print("Sequencing Groups:", sequencing_groups) - print("Sequencing Groups Meta:", sequencing_groups_meta) - - #1. Get SG's for the cohort - #Replace this with a graphql query. - sgs = capi.get_sgs_for_cohort(projects) - print(sgs) - #2. Create cohort - cohort_id = capi.create_cohort(project='vb-fewgenomes', cohort_name='second_test', description='a second test', request_body=sgs) - print(cohort_id) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Example script with argparse") - - parser.add_argument( - "--projects", - nargs="+", - help="List of project names", - required=False, - ) - parser.add_argument( - "--sequencing-groups", - nargs="+", - help="List of sequencing group names", - required=False, - ) - parser.add_argument( - "--sequencing-groups-meta", - type=ast.literal_eval, - help="Dictionary containing sequencing group metadata", - required=False, - ) - - args = parser.parse_args() - - projects = args.projects - sequencing_groups = args.sequencing_groups - sequencing_groups_meta = args.sequencing_groups_meta - - main(projects, sequencing_groups, sequencing_groups_meta) From 25155abed8a73e756e0ed54bcfce7cf720a93c16 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Thu, 23 Nov 2023 18:00:32 +1100 Subject: [PATCH 005/161] ignore venv --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d5fa95d5a..eb675baf5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ db/postgres*.jar .vscode/ env/ +venv/ __pycache__/ *.pyc .DS_Store From 7a5d97842b282fce6d2999c3b7fbb161b1f24c10 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Thu, 23 Nov 2023 18:03:58 +1100 Subject: [PATCH 006/161] Updated docker mariadb setup instructions --- README.md | 56 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ec3150ae4..5096c2cb9 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,6 @@ export PATH="$HB_PREFIX/opt/mariadb@10.8/bin:$PATH" If you installed all the software through brew and npm like this guide suggests, your `.zshrc` may look like this: - ```shell alias openapi-generator="npx @openapitools/openapi-generator-cli" @@ -269,6 +268,12 @@ Pull mariadb image docker pull mariadb:10.8.3 ``` +If you wish, install the mysql-client using homebrew (or an equivalent linux command) so you can connect to the MariaDB server running via Docker: + +```bash +brew install mysql-client +``` + Run a mariadb container that will server your database. `-p 3307:3306` remaps the port to 3307 in case if you local MySQL is already using 3306 ```bash @@ -283,10 +288,24 @@ mysql --host=127.0.0.1 --port=3307 -u root -e 'CREATE DATABASE sm_dev;' mysql --host=127.0.0.1 --port=3307 -u root -e 'show databases;' ``` -Go into the `db/` subdirectory, download the `mariadb-java-client` and create the schema using liquibase: +Similar to the previous section, we need to create the `sm_api` user, and set the corect roles and privileges: ```bash +mysql --host=127.0.0.1 --port=3307 -u root --execute " + CREATE USER sm_api@'%'; + CREATE USER sm_api@localhost; + CREATE ROLE sm_api_role; + GRANT sm_api_role TO sm_api@'%'; + GRANT sm_api_role TO sm_api@localhost; + SET DEFAULT ROLE sm_api_role FOR sm_api@'%'; + SET DEFAULT ROLE sm_api_role FOR sm_api@localhost; + GRANT ALL PRIVILEGES ON sm_dev.* TO sm_api_role; +" +``` + +Go into the `db/` subdirectory, download the `mariadb-java-client` and create the schema using liquibase: +```bash pushd db/ wget https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/3.0.3/mariadb-java-client-3.0.3.jar liquibase \ @@ -341,22 +360,22 @@ The following `launch.json` is a good base to debug the web server in VSCode: ```json { - "version": "0.2.0", - "configurations": [ - { - "name": "Run API", - "type": "python", - "request": "launch", - "module": "api.server", - "justMyCode": false, - "env": { - "SM_ALLOWALLACCESS": "true", - "SM_LOCALONLY_DEFAULTUSER": "-local", - "SM_ENVIRONMENT": "local", - "SM_DEV_DB_USER": "sm_api", - } - } - ] + "version": "0.2.0", + "configurations": [ + { + "name": "Run API", + "type": "python", + "request": "launch", + "module": "api.server", + "justMyCode": false, + "env": { + "SM_ALLOWALLACCESS": "true", + "SM_LOCALONLY_DEFAULTUSER": "-local", + "SM_ENVIRONMENT": "local", + "SM_DEV_DB_USER": "sm_api" + } + } + ] } ``` @@ -420,7 +439,6 @@ npm start This will start a web server using Vite, running on [localhost:5173](http://localhost:5173). - ### OpenAPI and Swagger The Web API uses `apispec` with OpenAPI3 annotations on each route to describe interactions with the server. We can generate a swagger UI and an installable From d5103a10295f4c2e9855e2be7d27fccca9d9bd29 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Thu, 23 Nov 2023 18:59:36 +1100 Subject: [PATCH 007/161] support for `name`, `derived_from` & other fields --- api/routes/cohort.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 92a7a6a09..87595f4a8 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -11,14 +11,26 @@ @router.post('/{project}/', operation_id='createCohort') async def create_cohort( cohort_name: str, - sequencing_group_ids: list[str], description: str, + sequencing_group_ids: list[str], + derived_from: int | None = None, connection: Connection = get_project_write_connection, ) -> dict[str, Any]: """ Create a cohort with the given name and sample/sequencing group IDs. """ cohortlayer = CohortLayer(connection) - cohort_id = await cohortlayer.create_cohort(project=connection.project, cohort_name=cohort_name, sequencing_group_ids=sequencing_group_ids, description=description, author=connection.author) - # sequencing_group_ids = sequencing_group_id_transform_to_raw_list(sequencing_group_ids) + + if not connection.project: + raise ValueError('A cohort must belong to a project') + + cohort_id = await cohortlayer.create_cohort( + project=connection.project, + cohort_name=cohort_name, + derived_from=derived_from, + description=description, + author=connection.author, + sequencing_group_ids=sequencing_group_ids, + ) + return {'cohort_id': cohort_id} From 9d655e9680df1aa794ff22ce8d7d39d19a7c1f00 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Thu, 23 Nov 2023 19:00:15 +1100 Subject: [PATCH 008/161] Fix error if applying `ord` to int --- models/models/sample.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/models/models/sample.py b/models/models/sample.py index a41c06463..0c2e5271d 100644 --- a/models/models/sample.py +++ b/models/models/sample.py @@ -33,7 +33,9 @@ def from_db(d: dict): meta = d.pop('meta', None) active = d.pop('active', None) if active is not None: - active = bool(ord(active)) + active = bool( + ord(active) if isinstance(active, (str, bytes, bytearray)) else active + ) if meta: if isinstance(meta, bytes): meta = meta.decode() From f69cde0f13151ec67b906f790c4cdd98d69d5fb5 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Thu, 23 Nov 2023 19:00:55 +1100 Subject: [PATCH 009/161] Add all fields to model --- models/models/cohort.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/models/models/cohort.py b/models/models/cohort.py index db2d721bf..253229729 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -1,11 +1,17 @@ from models.base import SMBase +from models.models.sequencing_group import SequencingGroup, SequencingGroupExternalId class Cohort(SMBase): - """ Model for Cohort """ + """Model for Cohort""" + id: str + name: str + author: str project: str description: str + derived_from: int | None + sequencing_groups: list[SequencingGroup | SequencingGroupExternalId] @staticmethod def from_db(d: dict): @@ -15,4 +21,17 @@ def from_db(d: dict): _id = d.pop('id', None) project = d.pop('project', None) description = d.pop('description', None) - return Cohort(id=_id, project=project, description=description) + name = d.pop('name', None) + author = d.pop('author', None) + derived_from = d.pop('derived_from', None) + sequencing_groups = d.pop('sequencing_groups', []) + + return Cohort( + id=_id, + name=name, + author=author, + project=project, + description=description, + derived_from=derived_from, + sequencing_groups=sequencing_groups, + ) From 5d246699f43ff289bd76e3ad8ffe2d6572fdde24 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Thu, 23 Nov 2023 19:01:42 +1100 Subject: [PATCH 010/161] Set empty dict to fix GraphQL non-null error --- db/python/tables/assay.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/db/python/tables/assay.py b/db/python/tables/assay.py index 14653be01..6938ab93d 100644 --- a/db/python/tables/assay.py +++ b/db/python/tables/assay.py @@ -517,6 +517,11 @@ async def get_assays_for_sequencing_group_ids( projects: set[ProjectId] = set() for row in rows: drow = dict(row) + + # Set external_id map to empty dict since we don't fetch them for this query + # TODO: Get external_ids map for this query if/when they are needed. + drow['external_ids'] = drow.pop('external_ids', {}) + sequencing_group_id = drow.pop('sequencing_group_id') projects.add(drow.pop('project')) assay = AssayInternal.from_db(drow) From 32fc08858872dad8e45261ce8e56c88a2e0b0704 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Thu, 23 Nov 2023 19:02:11 +1100 Subject: [PATCH 011/161] Fix error applying `ord` to int --- db/python/layers/web.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/db/python/layers/web.py b/db/python/layers/web.py index ee580297d..d977de09d 100644 --- a/db/python/layers/web.py +++ b/db/python/layers/web.py @@ -189,7 +189,11 @@ def _project_summary_process_sample_rows( created_date=str(sample_id_start_times.get(s['id'], '')), sequencing_groups=sg_models_by_sample_id.get(s['id'], []), non_sequencing_assays=filtered_assay_models_by_sid.get(s['id'], []), - active=bool(ord(s['active'])), + active=bool( + ord(s['active']) + if isinstance(s['active'], (str, bytes, bytearray)) + else bool(s['active']) + ), ) for s in sample_rows ] From 7dc373d7ac7329ef6a12c48be71b0822141516e0 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Thu, 23 Nov 2023 19:02:35 +1100 Subject: [PATCH 012/161] Add `name` to `cohort` table --- db/project.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/db/project.xml b/db/project.xml index 553a7dc93..dde025226 100644 --- a/db/project.xml +++ b/db/project.xml @@ -854,6 +854,12 @@ - + + + + + + + From 312c6f8fd81e061e3b3c7184cf85a6bf48e0c47c Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Thu, 23 Nov 2023 19:02:48 +1100 Subject: [PATCH 013/161] version bump --- web/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 5d81f4805..3ee6c289b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "metamist", - "version": "6.3.0", + "version": "6.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metamist", - "version": "6.3.0", + "version": "6.5.0", "dependencies": { "@apollo/client": "^3.7.3", "@emotion/react": "^11.10.4", From 9fb6a0659826e06cc5b137ddcd07543e9e452574 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:41:10 +1100 Subject: [PATCH 014/161] Formatting; cohort GQL schema --- api/graphql/schema.py | 103 +++++++++++++++++++++++++++++++++++------- 1 file changed, 86 insertions(+), 17 deletions(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index f9ef5c937..0404cdb8b 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -9,6 +9,7 @@ """ import datetime from inspect import isclass +from pymysql import connect import strawberry from strawberry.extensions import QueryDepthLimiter @@ -18,8 +19,12 @@ from api.graphql.filters import GraphQLFilter, GraphQLMetaFilter from api.graphql.loaders import LoaderKeys, get_context from db.python import enum_tables -from db.python.layers import (AnalysisLayer, CohortLayer, SampleLayer, - SequencingGroupLayer) +from db.python.layers import ( + AnalysisLayer, + CohortLayer, + SampleLayer, + SequencingGroupLayer, +) from db.python.layers.assay import AssayLayer from db.python.layers.family import FamilyLayer from db.python.tables.analysis import AnalysisFilter @@ -30,13 +35,22 @@ from db.python.tables.sequencing_group import SequencingGroupFilter from db.python.utils import GenericFilter from models.enums import AnalysisStatus -from models.models import (AnalysisInternal, AssayInternal, Cohort, - FamilyInternal, ParticipantInternal, Project, - SampleInternal, SequencingGroupInternal) +from models.models import ( + AnalysisInternal, + AssayInternal, + Cohort, + FamilyInternal, + ParticipantInternal, + Project, + SampleInternal, + SequencingGroupInternal, +) from models.models.sample import sample_id_transform_to_raw from models.utils.sample_id_format import sample_id_format from models.utils.sequencing_group_id_format import ( - sequencing_group_id_format, sequencing_group_id_transform_to_raw) + sequencing_group_id_format, + sequencing_group_id_transform_to_raw, +) enum_methods = {} for enum in enum_tables.__dict__.values(): @@ -59,24 +73,42 @@ async def m(info: Info) -> list[str]: GraphQLEnum = strawberry.type(type('GraphQLEnum', (object,), enum_methods)) -#Create cohort GraphQL model +# Create cohort GraphQL model @strawberry.type class GraphQLCohort: """Cohort GraphQL model""" id: int + name: str project: str description: str - + author: str + derived_from: int | None = None @staticmethod def from_internal(internal: Cohort) -> 'GraphQLCohort': return GraphQLCohort( id=internal.id, + name=internal.name, project=internal.project, description=internal.description, + author=internal.author, + derived_from=internal.derived_from, ) + @strawberry.field() + async def sequencing_groups( + self, info: Info, root: 'Cohort' + ) -> list['GraphQLSequencingGroup']: + connection = info.context['connection'] + cohort_layer = CohortLayer(connection) + sg_ids = await cohort_layer.get_cohort_sequencing_group_ids(root.id) + + sg_layer = SequencingGroupLayer(connection) + sequencing_groups = await sg_layer.get_sequencing_groups_by_ids(sg_ids) + return [GraphQLSequencingGroup.from_internal(sg) for sg in sequencing_groups] + + @strawberry.type class GraphQLProject: """Project GraphQL model""" @@ -215,6 +247,31 @@ async def analyses( ) return [GraphQLAnalysis.from_internal(a) for a in internal_analysis] + @strawberry.field() + async def cohort( + self, + info: Info, + root: 'Project', + id: GraphQLFilter[int] | None = None, + name: GraphQLFilter[str] | None = None, + author: GraphQLFilter[str] | None = None, + derived_from: GraphQLFilter[int] | None = None, + timestamp: GraphQLFilter[datetime.datetime] | None = None, + ) -> list['GraphQLCohort']: + connection = info.context['connection'] + connection.project = root.id + + c_filter = CohortFilter( + id=id.to_internal_filter() if id else None, + name=name.to_internal_filter() if name else None, + author=author.to_internal_filter() if author else None, + derived_from=derived_from.to_internal_filter() if derived_from else None, + timestamp=timestamp.to_internal_filter() if timestamp else None, + ) + + cohorts = await CohortLayer(connection).query(c_filter) + return [GraphQLCohort.from_internal(c) for c in cohorts] + @strawberry.type class GraphQLAnalysis: @@ -533,7 +590,8 @@ async def assays( self, info: Info, root: 'GraphQLSequencingGroup' ) -> list['GraphQLAssay']: loader = info.context[LoaderKeys.ASSAYS_FOR_SEQUENCING_GROUPS] - return await loader.load(root.internal_id) + assays = await loader.load(root.internal_id) + return [GraphQLAssay.from_internal(assay) for assay in assays] @strawberry.type @@ -564,8 +622,9 @@ async def sample(self, info: Info, root: 'GraphQLAssay') -> GraphQLSample: sample = await loader.load(root.sample_id) return GraphQLSample.from_internal(sample) + @strawberry.type -class Query: #entry point to graphql. +class Query: # entry point to graphql. """GraphQL Queries""" @strawberry.field() @@ -573,28 +632,38 @@ def enum(self, info: Info) -> GraphQLEnum: return GraphQLEnum() @strawberry.field() - async def cohort(self, info: Info, project: GraphQLFilter[str] | None = None,)-> list[GraphQLCohort]: + async def cohort( + self, + info: Info, + id: GraphQLFilter[int] | None = None, + project: GraphQLFilter[str] | None = None, + name: GraphQLFilter[str] | None = None, + author: GraphQLFilter[str] | None = None, + derived_from: GraphQLFilter[int] | None = None, + ) -> list[GraphQLCohort]: connection = info.context['connection'] clayer = CohortLayer(connection) + ptable = ProjectPermissionsTable(connection.connection) project_name_map: dict[str, int] = {} + project_filter = None if project: project_names = project.all_values() projects = await ptable.get_and_check_access_to_projects_for_names( user=connection.author, project_names=project_names, readonly=True ) project_name_map = {p.name: p.id for p in projects} - + project_filter = project.to_internal_filter(lambda pname: project_name_map[pname]) filter_ = CohortFilter( - project=project.to_internal_filter(lambda pname: project_name_map[pname]) - if project - else None, + id=id.to_internal_filter() if id else None, + name=name.to_internal_filter() if name else None, + project=project_filter, + author=author.to_internal_filter() if author else None, + derived_from=derived_from.to_internal_filter() if derived_from else None, ) cohort = await clayer.query(filter_) - print(cohort) - return cohort @strawberry.field() From 57afc9fa5ef1f57f04fd5a1f489c0c3f8b089938 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:41:34 +1100 Subject: [PATCH 015/161] cohort layer update --- db/python/layers/cohort.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 7714196ce..56d6de656 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -8,6 +8,7 @@ from db.python.tables.sample import SampleTable from db.python.utils import get_logger from models.models.cohort import Cohort +from models.models.sequencing_group import SequencingGroupInternal logger = get_logger() @@ -25,18 +26,38 @@ def __init__(self, connection: Connection): # GETS async def query(self, filter_: CohortFilter) -> list[Cohort]: - """ Query Cohorts""" + """Query Cohorts""" cohorts = await self.ct.query(filter_) return cohorts + async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: + """ + Get the sequencing group IDs for the given cohort. + """ + return await self.ct.get_cohort_sequencing_group_ids(cohort_id) + + # PUTS + async def create_cohort( self, project: ProjectId, cohort_name: str, sequencing_group_ids: list[str], + author: str, description: str, - author: str = None, - ) -> dict[str, Any]: - """Create a new cohort""" - output = await self.ct.create_cohort(project=project, cohort_name=cohort_name, sequencing_group_ids=sequencing_group_ids, description=description, author=author) - return {'cohort_id': cohort_name, 'sg': sequencing_group_ids, 'output': output} + derived_from: int | None = None, + ) -> int: + """ + Create a new cohort from the given parameters. Returns the newly created cohort_id. + """ + + cohort_id = await self.ct.create_cohort( + project=project, + cohort_name=cohort_name, + sequencing_group_ids=sequencing_group_ids, + description=description, + author=author, + derived_from=derived_from, + ) + + return cohort_id From 203a49be3a67f4daae1fb090051fcbffe152df83 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:41:49 +1100 Subject: [PATCH 016/161] cohort db table update --- db/python/tables/cohort.py | 62 ++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 7f2453675..7f3ae5db0 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -1,12 +1,13 @@ # pylint: disable=too-many-instance-attributes import dataclasses +import datetime from db.python.connect import DbBase from db.python.tables.project import ProjectId +from db.python.tables.sequencing_group import SequencingGroupTable from db.python.utils import GenericFilter, GenericFilterModel from models.models.cohort import Cohort -from models.utils.sequencing_group_id_format import \ - sequencing_group_id_transform_to_raw +from models.utils.sequencing_group_id_format import sequencing_group_id_transform_to_raw @dataclasses.dataclass(kw_only=True) @@ -14,7 +15,12 @@ class CohortFilter(GenericFilterModel): """ Filters for Cohort """ + id: GenericFilter[int] | None = None + name: GenericFilter[str] | None = None + author: GenericFilter[str] | None = None + derived_from: GenericFilter[int] | None = None + timestamp: GenericFilter[datetime.datetime] | None = None project: GenericFilter[ProjectId] | None = None @@ -26,6 +32,7 @@ class CohortTable(DbBase): table_name = 'cohort' common_get_keys = [ 'id', + 'name', 'derived_from', 'description', 'author', @@ -33,10 +40,11 @@ class CohortTable(DbBase): ] async def query(self, filter_: CohortFilter): - """ Query Cohorts""" + """Query Cohorts""" wheres, values = filter_.to_sql(field_overrides={}) if not wheres: raise ValueError(f'Invalid filter: {filter_}') + common_get_keys_str = ','.join(self.common_get_keys) _query = f""" SELECT {common_get_keys_str} @@ -44,36 +52,58 @@ async def query(self, filter_: CohortFilter): WHERE {wheres} """ - print(_query) - print(values) - rows = await self.connection.fetch_all(_query, values) cohorts = [Cohort.from_db(dict(row)) for row in rows] return cohorts + async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list: + _query = """ + SELECT sequencing_group_id FROM cohort_sequencing_group WHERE cohort_id = :cohort_id + """ + rows = await self.connection.fetch_all(_query, {'cohort_id': cohort_id}) + return [row["sequencing_group_id"] for row in rows] + async def create_cohort( self, project: int, cohort_name: str, sequencing_group_ids: list[str], author: str, - derived_from: str = None, - description: str = 'This field should accept null', + description: str, + derived_from: int | None = None, ) -> int: """ Create a new cohort """ - # Create cohort - print(cohort_name) + _query = """ + INSERT INTO cohort (name, derived_from, author, description, project) + VALUES (:name, :derived_from, :author, :description, :project) RETURNING id + """ + + cohort_id = await self.connection.fetch_val( + _query, + { + 'derived_from': derived_from, + 'author': author, + 'description': description, + 'project': project, + 'name': cohort_name, + }, + ) - _query = 'INSERT INTO cohort (derived_from, author, description, project) VALUES (:derived_from, :author, :description, :project) RETURNING id' - cohort_id = await self.connection.fetch_val(_query, {'derived_from': derived_from , 'author': author, 'description': description, 'project': project}) - print(cohort_id) + _query = """ + INSERT INTO cohort_sequencing_group (cohort_id, sequencing_group_id) + VALUES (:cohort_id, :sequencing_group_id) + """ - # populate sequencing groups - _query = 'INSERT INTO cohort_sequencing_group (cohort_id, sequencing_group_id) VALUES (:cohort_id, :sequencing_group_id)' for sg in sequencing_group_ids: - await self.connection.execute(_query, {'cohort_id': cohort_id, 'sequencing_group_id': sequencing_group_id_transform_to_raw(sg)}) + await self.connection.execute( + _query, + { + 'cohort_id': cohort_id, + 'sequencing_group_id': sequencing_group_id_transform_to_raw(sg), + }, + ) return cohort_id From 60798c18e6dbebcfdc9b8deeb68d9469d93e8a23 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:41:58 +1100 Subject: [PATCH 017/161] CohortBuilder route --- web/src/Routes.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index f7f6da271..05d513d8d 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -10,6 +10,7 @@ import ProjectsAdmin from './pages/admin/ProjectsAdmin' import ErrorBoundary from './shared/utilities/errorBoundary' import AnalysisRunnerSummary from './pages/project/AnalysisRunnerView/AnalysisRunnerSummary' import BillingDashboard from './pages/billing/BillingDashboard' +import CohortBuilderView from './pages/cohort/CohortBuilderView' const Routes: React.FunctionComponent = () => ( @@ -74,6 +75,15 @@ const Routes: React.FunctionComponent = () => ( } /> + + + + + } + /> ) From 5af95cc34ece59d627921987830607f66f752acd Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:42:11 +1100 Subject: [PATCH 018/161] Single SG ID form --- web/src/pages/cohort/AddFromIdListForm.tsx | 55 ++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 web/src/pages/cohort/AddFromIdListForm.tsx diff --git a/web/src/pages/cohort/AddFromIdListForm.tsx b/web/src/pages/cohort/AddFromIdListForm.tsx new file mode 100644 index 000000000..c11291960 --- /dev/null +++ b/web/src/pages/cohort/AddFromIdListForm.tsx @@ -0,0 +1,55 @@ +import React, { useContext } from 'react' +import { Form } from 'semantic-ui-react' + +import { ThemeContext } from '../../shared/components/ThemeProvider' + +import { SequencingGroup } from './types' + +interface IAddFromIdListForm { + onAdd: (sequencingGroups: SequencingGroup[]) => void +} + +const AddFromIdListForm: React.FC = ({ onAdd }) => { + const { theme } = useContext(ThemeContext) + const inverted = theme === 'dark-mode' + + const handleInput = () => { + const element = document.getElementById('sequencing-group-ids-csv') as HTMLInputElement + + if (!element == null) { + onAdd([]) + return + } + + if (!element?.value || !element.value.trim()) { + onAdd([]) + return + } + + const ids = element.value.trim().split(',') + const sgs: SequencingGroup[] = ids.map((id: string) => ({ + id: id.trim(), + type: '?', + technology: '?', + platform: '?', + project: { id: -1, name: '?' }, + })) + + onAdd(sgs.filter((sg) => sg.id !== '')) + } + + return ( +
+

Add by Sequencing Group ID

+

Input a comma-separated list of valid Sequencing Group IDs

+ + + + ) +} + +export default AddFromIdListForm From c5cdb17ab6222d4d8ecd003ff4452f82d570699d Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:42:22 +1100 Subject: [PATCH 019/161] SG ID from project(s) form --- web/src/pages/cohort/AddFromProjectForm.tsx | 143 ++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 web/src/pages/cohort/AddFromProjectForm.tsx diff --git a/web/src/pages/cohort/AddFromProjectForm.tsx b/web/src/pages/cohort/AddFromProjectForm.tsx new file mode 100644 index 000000000..cd84d12e1 --- /dev/null +++ b/web/src/pages/cohort/AddFromProjectForm.tsx @@ -0,0 +1,143 @@ +import React, { useState, useContext } from 'react' +import { Container, Form } from 'semantic-ui-react' +import { useQuery } from '@apollo/client' + +import { gql } from '../../__generated__/gql' +import { ThemeContext } from '../../shared/components/ThemeProvider' +import MuckError from '../../shared/components/MuckError' + +import SequencingGroupTable from './SequencingGroupTable' +import { SequencingGroup } from './types' +import { setSearchParams } from '../../sm-api/common' + +const GET_PROJECTS_QUERY = gql(` +query VisibleProjects($activeOnly: Boolean!) { + myProjects { + id + name + sequencingGroups(activeOnly: {eq: $activeOnly}) { + id + type + technology + platform + } + } +}`) + +interface IAddFromProjectForm { + onAdd: (sequencingGroups: SequencingGroup[]) => void +} + +const AddFromProjectForm: React.FC = ({ onAdd }) => { + const [sequencingGroups, setSequencingGroups] = useState([]) + const [searchHits, setSearchHits] = useState([]) + + const { theme } = useContext(ThemeContext) + const inverted = theme === 'dark-mode' + + // Load all available projects and associated data for this user + const { loading, error, data } = useQuery(GET_PROJECTS_QUERY, { + variables: { activeOnly: true }, + }) + + if (loading) { + return
Loading project form...
+ } + + if (error) { + return + } + + const projectOptions: { key: any; value: any; text: any }[] | undefined = [] + if (!loading && data?.myProjects) { + data.myProjects.forEach((project) => { + projectOptions.push({ key: project.id, value: project.id, text: project.name }) + }) + } + + const search = () => { + const seqType = (document.getElementById('seq_type') as HTMLInputElement)?.value?.trim() + const technology = ( + document.getElementById('technology') as HTMLInputElement + )?.value?.trim() + const platform = (document.getElementById('platform') as HTMLInputElement)?.value?.trim() + // const batch = (document.getElementById('batch') as HTMLInputElement)?.value?.trim() + + const hits = sequencingGroups + setSearchHits( + hits.filter( + (sg: SequencingGroup) => + (!seqType || sg.type.toLowerCase().includes(seqType.toLowerCase())) && + (!technology || + sg.technology.toLowerCase().includes(technology.toLowerCase())) && + (!platform || sg.platform.toLowerCase().includes(platform.toLowerCase())) + ) + ) + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const updateSequencingGroupSelection = (_: any, d: any) => { + if (!data?.myProjects) { + setSequencingGroups([]) + return + } + + const newSequencingGroups: SequencingGroup[] = [] + data.myProjects.forEach((project) => { + if (d.value.includes(project.id)) { + project.sequencingGroups.forEach((sg: SequencingGroup) => { + newSequencingGroups.push({ + id: sg.id, + type: sg.type, + technology: sg.technology, + platform: sg.platform, + project: { id: project.id, name: project.name }, + }) + }) + } + }) + + setSequencingGroups(newSequencingGroups) + } + + return ( +
+

Project

+

Include Sequencing Groups from the following projects

+ + +

+ Including the Sequencing Groups which match the following criteria (leave blank to + include all Sequencing Groups) +

+ + + + + + +
+ + + { + onAdd(searchHits) + }} + /> + +
+ + + ) +} + +export default AddFromProjectForm From 58c80d4437961fa38d4a813a924abb3c0e998753 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:42:31 +1100 Subject: [PATCH 020/161] Cohort Builder page --- web/src/pages/cohort/CohortBuilderView.tsx | 177 +++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 web/src/pages/cohort/CohortBuilderView.tsx diff --git a/web/src/pages/cohort/CohortBuilderView.tsx b/web/src/pages/cohort/CohortBuilderView.tsx new file mode 100644 index 000000000..ba3864c38 --- /dev/null +++ b/web/src/pages/cohort/CohortBuilderView.tsx @@ -0,0 +1,177 @@ +import React, { useState, useContext } from 'react' +import { Container, Divider, Form, Tab } from 'semantic-ui-react' +import { uniqBy } from 'lodash' + +import { ThemeContext } from '../../shared/components/ThemeProvider' + +import SequencingGroupTable from './SequencingGroupTable' +import AddFromProjectForm from './AddFromProjectForm' +import AddFromIdListForm from './AddFromIdListForm' +import { SequencingGroup } from './types' + +interface CohortFormData { + name: string + description: string + sequencingGroups: SequencingGroup[] +} + +const CohortBuilderView = () => { + const { theme } = useContext(ThemeContext) + const inverted = theme === 'dark-mode' + + // State for new cohort data + const [cohortFormData, setCohortFormData] = useState({ + name: '', + description: '', + sequencingGroups: [], + }) + + const addSequencingGroups = (sgs: SequencingGroup[]) => { + setCohortFormData({ + ...cohortFormData, + sequencingGroups: uniqBy([...cohortFormData.sequencingGroups, ...sgs], 'id'), + }) + } + + const removeSequencingGroup = (id: string) => { + setCohortFormData({ + ...cohortFormData, + sequencingGroups: cohortFormData.sequencingGroups.filter((sg) => sg.id !== id), + }) + } + + const createCohort = () => { + // eslint-disable-next-line no-restricted-globals, no-alert + const proceed = confirm( + 'Are you sure you want to create this cohort? A cohort cannot be edited once created.' + ) + if (!proceed) return + console.log(cohortFormData) + } + + const tabPanes = [ + { + menuItem: 'Project Based', + render: () => ( + + + + ), + }, + { + menuItem: 'Individual', + render: () => ( + + + + ), + }, + ] + + return ( + +
+

Cohort Builder

+

+ Welcome to the cohort builder! This form will guide you through the process of + creating a new cohort. You can add sequencing groups from any projects available + to you. Once you have created a cohort, you will not be able to edit it. +

+
+ +
+
+

Details

+ + +

+ Please provide a human-readable name for this cohort. Names must + be unique across all projects. +

+ + } + placeholder="Cohort Name" + maxLength={255} + required + onChange={(e) => + setCohortFormData({ ...cohortFormData, name: e.target.value }) + } + /> + + +

+ Please provide a short-form description regarding this cohort. +

+ + } + placeholder="Cohort Description" + maxLength={255} + required + onChange={(e) => + setCohortFormData({ + ...cohortFormData, + description: e.target.value, + }) + } + /> +
+ +
+

Sequencing Groups

+

+ Add sequencing groups to this cohort. You can bulk add sequencing groups + from any project available to to you and specify filtering criteria to match + specific Sequencing Groups. You can also add sequencing groups manually by + entering their IDs in a comma-separated list. +

+ +
+ +
+

Selected Sequencing Groups

+

+ The table below displays the sequencing groups that will be added to this + cohort +

+ + +
+ +
+ + + { + // eslint-disable-next-line no-restricted-globals, no-alert + const yes = confirm( + "Remove all sequencing groups? This can't be undone." + ) + if (!yes) return + + setCohortFormData({ + ...cohortFormData, + sequencingGroups: [], + }) + }} + /> + +
+ +
+ ) +} + +export default CohortBuilderView From 003ffff7a1149e0b1d92a90bf731531f84aef1df Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:42:41 +1100 Subject: [PATCH 021/161] Re-usable SG scrolling table --- web/src/pages/cohort/SequencingGroupTable.tsx | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 web/src/pages/cohort/SequencingGroupTable.tsx diff --git a/web/src/pages/cohort/SequencingGroupTable.tsx b/web/src/pages/cohort/SequencingGroupTable.tsx new file mode 100644 index 000000000..6189acd2a --- /dev/null +++ b/web/src/pages/cohort/SequencingGroupTable.tsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react' +import { Table, Icon, Button, Input } from 'semantic-ui-react' +import { sortBy } from 'lodash' + +import { SequencingGroup } from './types' + +type Direction = 'ascending' | 'descending' | undefined + +const SequencingGroupTable = ({ + editable = true, + height = 650, + sequencingGroups = [], + onDelete = () => {}, +}: { + editable?: boolean + height?: number + sequencingGroups?: SequencingGroup[] + onDelete?: (id: string) => void +}) => { + const [sortColumn, setSortColumn] = useState('id') + const [sortDirection, setSortDirection] = useState('ascending') + const [searchTerms, setSearchTerms] = useState<{ column: string; term: string }[]>([]) + + const setSortInformation = (column: string) => { + setSortColumn(column) + setSortDirection(sortDirection === 'ascending' ? 'descending' : 'ascending') + } + + const setSearchInformation = (column: string, term: string) => { + if (searchTerms.find((st) => st.column === column)) { + setSearchTerms( + searchTerms.map((st) => (st.column === column ? { column, term: term.trim() } : st)) + ) + } else { + setSearchTerms([...searchTerms, { column, term: term.trim() }]) + } + } + + const filteredRows = sequencingGroups.filter((sg: SequencingGroup) => { + if (!searchTerms.length) return true + + return searchTerms.every(({ column, term }) => { + if (!term) return true + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const value: any = sg[column] + return value.toString().toLowerCase().includes(term.toLowerCase()) + }) + }) + + let sortedRows = sortBy(filteredRows, [sortColumn]) + sortedRows = sortDirection === 'ascending' ? sortedRows : sortedRows.reverse() + + const tableColumns = [ + { key: 'id', name: 'ID' }, + { key: 'project', name: 'Project' }, + { key: 'type', name: 'Type' }, + { key: 'technology', name: 'Technology' }, + { key: 'platform', name: 'Platform' }, + ] + + if (!sequencingGroups.length) { + return No Sequencing Groups have been added to this cohort + } + + return ( +
+ + + + {editable ? : null} + {tableColumns.map((column) => ( + +
setSortInformation(column.key)}> + {column.name} +
+ + setSearchInformation(column.key, e.target.value) + } + placeholder="search..." + /> +
+ ))} +
+
+ + {sortedRows.length ? ( + sortedRows.map((sg: SequencingGroup) => ( + + {editable ? ( + + + + ) : null} + {sg.id} + {sg.project.name} + {sg.type} + {sg.technology} + {sg.platform} + + )) + ) : ( + + + + No Sequencing Groups matching your search criteria + + + )} + +
+
+ ) +} + +export default SequencingGroupTable From 4cdfde07d7de81f05c10e4e0ef9052eef4227266 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:42:51 +1100 Subject: [PATCH 022/161] types definition --- web/src/pages/cohort/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 web/src/pages/cohort/types.ts diff --git a/web/src/pages/cohort/types.ts b/web/src/pages/cohort/types.ts new file mode 100644 index 000000000..7b859fad9 --- /dev/null +++ b/web/src/pages/cohort/types.ts @@ -0,0 +1,7 @@ +export interface SequencingGroup { + id: string + type: string + technology: string + platform: string + project: { id: number; name: string } +} From 3f271854ff45fb5a1a53e3e2015403325a4ef12e Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:43:02 +1100 Subject: [PATCH 023/161] Add link to nav bar to cohort builder --- web/src/shared/components/Header/NavBar.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/src/shared/components/Header/NavBar.tsx b/web/src/shared/components/Header/NavBar.tsx index 1b9cd09f0..14ef8a650 100644 --- a/web/src/shared/components/Header/NavBar.tsx +++ b/web/src/shared/components/Header/NavBar.tsx @@ -41,6 +41,14 @@ const NavBar: React.FunctionComponent = () => ( + + Cohort Builder + + } hoverable position="bottom center"> +
Cohort Builder
+
+
+ Swagger From f153df26d667a360d4523f36506bf526550d639b Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 27 Nov 2023 20:11:43 +1100 Subject: [PATCH 024/161] Add substring search --- api/graphql/filters.py | 10 ++++++++++ db/python/utils.py | 18 +++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/api/graphql/filters.py b/api/graphql/filters.py index 68ae3a3e5..e9ca0c7b4 100644 --- a/api/graphql/filters.py +++ b/api/graphql/filters.py @@ -18,6 +18,8 @@ class GraphQLFilter(Generic[T]): gte: T | None = None lt: T | None = None lte: T | None = None + contains: T | None = None + icontains: T | None = None def all_values(self): """ @@ -38,6 +40,10 @@ def all_values(self): v.append(self.lt) if self.lte: v.append(self.lte) + if self.contains: + v.append(self.contains) + if self.icontains: + v.append(self.icontains) return v @@ -53,6 +59,8 @@ def to_internal_filter(self, f: Callable[[T], Any] = None): gte=f(self.gte) if self.gte else None, lt=f(self.lt) if self.lt else None, lte=f(self.lte) if self.lte else None, + contains=f(self.contains) if self.contains else None, + icontains=f(self.icontains) if self.icontains else None, ) return GenericFilter( @@ -63,6 +71,8 @@ def to_internal_filter(self, f: Callable[[T], Any] = None): gte=self.gte, lt=self.lt, lte=self.lte, + contains=self.contains, + icontains=self.icontains, ) diff --git a/db/python/utils.py b/db/python/utils.py index 847ad69c1..d97983eab 100644 --- a/db/python/utils.py +++ b/db/python/utils.py @@ -92,6 +92,8 @@ class GenericFilter(Generic[T]): gte: T | None = None lt: T | None = None lte: T | None = None + contains: T | None = None + icontains: T | None = None def __init__( self, @@ -102,6 +104,8 @@ def __init__( gte: T | None = None, lt: T | None = None, lte: T | None = None, + contains: T | None = None, + icontains: T | None = None, ): self.eq = eq self.in_ = in_ @@ -110,9 +114,11 @@ def __init__( self.gte = gte self.lt = lt self.lte = lte + self.contains = contains + self.icontains = icontains def __repr__(self): - keys = ['eq', 'in_', 'nin', 'gt', 'gte', 'lt', 'lte'] + keys = ['eq', 'in_', 'nin', 'gt', 'gte', 'lt', 'lte', 'contains', 'icontains'] inner_values = ', '.join( f'{k}={getattr(self, k)!r}' for k in keys if getattr(self, k) is not None ) @@ -129,6 +135,8 @@ def __hash__(self): self.gte, self.lt, self.lte, + self.contains, + self.icontains, ) ) @@ -193,6 +201,14 @@ def to_sql( k = self.generate_field_name(column + '_lte') conditionals.append(f'{column} <= :{k}') values[k] = self._sql_value_prep(self.lte) + if self.contains is not None: + k = self.generate_field_name(column + '_contains') + conditionals.append(f'{column} LIKE :{k}') + values[k] = self._sql_value_prep(f'%{self.contains}%') + if self.icontains is not None: + k = self.generate_field_name(column + '_icontains') + conditionals.append(f'LOWER({column}) LIKE LOWER(:{k})') + values[k] = self._sql_value_prep(f'%{self.icontains}%') return ' AND '.join(conditionals), values From a0385d59436375f9ac69dbf887040a475287fa9b Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:23:59 +1100 Subject: [PATCH 025/161] Formatting; add assay filter on sample --- api/graphql/schema.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 0404cdb8b..6ac3782cb 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -462,11 +462,16 @@ async def participant( @strawberry.field async def assays( - self, info: Info, root: 'GraphQLSample', type: GraphQLFilter[str] | None = None + self, + info: Info, + root: 'GraphQLSample', + type: GraphQLFilter[str] | None = None, + meta: GraphQLMetaFilter | None = None, ) -> list['GraphQLAssay']: loader_assays_for_sample_ids = info.context[LoaderKeys.ASSAYS_FOR_SAMPLES] filter_ = AssayFilter( type=type.to_internal_filter() if type else None, + meta=meta, ) assays = await loader_assays_for_sample_ids.load( {'id': root.internal_id, 'filter': filter_} @@ -493,16 +498,20 @@ async def sequencing_groups( loader = info.context[LoaderKeys.SEQUENCING_GROUPS_FOR_SAMPLES] _filter = SequencingGroupFilter( - id=id.to_internal_filter(sequencing_group_id_transform_to_raw) - if id - else None, + id=( + id.to_internal_filter(sequencing_group_id_transform_to_raw) + if id + else None + ), meta=meta, type=type.to_internal_filter() if type else None, technology=technology.to_internal_filter() if technology else None, platform=platform.to_internal_filter() if platform else None, - active_only=active_only.to_internal_filter() - if active_only - else GenericFilter(eq=True), + active_only=( + active_only.to_internal_filter() + if active_only + else GenericFilter(eq=True) + ), ) obj = {'id': root.internal_id, 'filter': _filter} sequencing_groups = await loader.load(obj) @@ -653,7 +662,9 @@ async def cohort( user=connection.author, project_names=project_names, readonly=True ) project_name_map = {p.name: p.id for p in projects} - project_filter = project.to_internal_filter(lambda pname: project_name_map[pname]) + project_filter = project.to_internal_filter( + lambda pname: project_name_map[pname] + ) filter_ = CohortFilter( id=id.to_internal_filter() if id else None, From 7858fc7ad674a6c34228a54186203ee626701711 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:26:46 +1100 Subject: [PATCH 026/161] Allow hashable assay filter when `meta` present --- db/python/tables/assay.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/db/python/tables/assay.py b/db/python/tables/assay.py index 6938ab93d..c0a3a27d7 100644 --- a/db/python/tables/assay.py +++ b/db/python/tables/assay.py @@ -42,7 +42,9 @@ class AssayFilter(GenericFilterModel): type: GenericFilter | None = None def __hash__(self): # pylint: disable=useless-super-delegation - return super().__hash__() + return hash( + (self.id, self.sample_id, self.external_id, self.project, self.type) + ) class AssayTable(DbBase): From 39e0c8b92a71d46426d50c7dea2e55c9ef0cd13e Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:26:56 +1100 Subject: [PATCH 027/161] Type for project --- web/src/pages/cohort/types.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/src/pages/cohort/types.ts b/web/src/pages/cohort/types.ts index 7b859fad9..0bd98eaec 100644 --- a/web/src/pages/cohort/types.ts +++ b/web/src/pages/cohort/types.ts @@ -5,3 +5,8 @@ export interface SequencingGroup { platform: string project: { id: number; name: string } } + +export interface Project { + id: number + name: string +} \ No newline at end of file From adce34b3d7707f9be540597add2db3ef886b7230 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:27:26 +1100 Subject: [PATCH 028/161] Add GQL query to project form --- web/src/pages/cohort/AddFromProjectForm.tsx | 222 +++++++++++++------- 1 file changed, 144 insertions(+), 78 deletions(-) diff --git a/web/src/pages/cohort/AddFromProjectForm.tsx b/web/src/pages/cohort/AddFromProjectForm.tsx index cd84d12e1..2174bf932 100644 --- a/web/src/pages/cohort/AddFromProjectForm.tsx +++ b/web/src/pages/cohort/AddFromProjectForm.tsx @@ -1,141 +1,207 @@ import React, { useState, useContext } from 'react' import { Container, Form } from 'semantic-ui-react' -import { useQuery } from '@apollo/client' +import { useLazyQuery } from '@apollo/client' +import { uniqBy } from 'lodash' import { gql } from '../../__generated__/gql' import { ThemeContext } from '../../shared/components/ThemeProvider' import MuckError from '../../shared/components/MuckError' import SequencingGroupTable from './SequencingGroupTable' -import { SequencingGroup } from './types' -import { setSearchParams } from '../../sm-api/common' - -const GET_PROJECTS_QUERY = gql(` -query VisibleProjects($activeOnly: Boolean!) { - myProjects { - id - name - sequencingGroups(activeOnly: {eq: $activeOnly}) { - id - type - technology - platform +import { Project, SequencingGroup } from './types' +import { FetchSequencingGroupsQueryVariables } from '../../__generated__/graphql' + +const GET_SEQUENCING_GROUPS_QUERY = gql(` +query FetchSequencingGroups( + $project: String!, + $platform: String, + $technology: String, + $seqType: String, + $assayMeta: JSON + ) { + project(name: $project) { + participants { + samples { + assays(meta: $assayMeta) { + sample { + sequencingGroups( + platform: {icontains: $platform} + technology: {icontains: $technology} + type: {contains: $seqType} + ) { + id + type + technology + platform + } + } + } } + } } -}`) + } +`) + +// NOTE: Put additional objects here to add more search fields for assay metadata +const assayMetaSearchFields = [ + { label: 'Batch', id: 'batch', searchVariable: 'batch' }, + { label: 'Emoji', id: 'emoji', searchVariable: 'emoji' }, +] interface IAddFromProjectForm { + projects: Project[] onAdd: (sequencingGroups: SequencingGroup[]) => void } -const AddFromProjectForm: React.FC = ({ onAdd }) => { - const [sequencingGroups, setSequencingGroups] = useState([]) +const AddFromProjectForm: React.FC = ({ projects, onAdd }) => { + const [selectedProject, setSelectedProject] = useState() const [searchHits, setSearchHits] = useState([]) const { theme } = useContext(ThemeContext) const inverted = theme === 'dark-mode' // Load all available projects and associated data for this user - const { loading, error, data } = useQuery(GET_PROJECTS_QUERY, { - variables: { activeOnly: true }, - }) + const [searchSquencingGroups, { loading, error }] = useLazyQuery(GET_SEQUENCING_GROUPS_QUERY) - if (loading) { - return
Loading project form...
- } + const search = () => { + const seqTypeInput = document.getElementById('seq_type') as HTMLInputElement + const technologyInput = document.getElementById('technology') as HTMLInputElement + const platformInput = document.getElementById('platform') as HTMLInputElement - if (error) { - return - } + if (!selectedProject?.name) { + return + } - const projectOptions: { key: any; value: any; text: any }[] | undefined = [] - if (!loading && data?.myProjects) { - data.myProjects.forEach((project) => { - projectOptions.push({ key: project.id, value: project.id, text: project.name }) - }) - } + const searchParams: FetchSequencingGroupsQueryVariables = { + project: selectedProject.name, + seqType: null, + technology: null, + platform: null, + assayMeta: {}, + } - const search = () => { - const seqType = (document.getElementById('seq_type') as HTMLInputElement)?.value?.trim() - const technology = ( - document.getElementById('technology') as HTMLInputElement - )?.value?.trim() - const platform = (document.getElementById('platform') as HTMLInputElement)?.value?.trim() - // const batch = (document.getElementById('batch') as HTMLInputElement)?.value?.trim() - - const hits = sequencingGroups - setSearchHits( - hits.filter( - (sg: SequencingGroup) => - (!seqType || sg.type.toLowerCase().includes(seqType.toLowerCase())) && - (!technology || - sg.technology.toLowerCase().includes(technology.toLowerCase())) && - (!platform || sg.platform.toLowerCase().includes(platform.toLowerCase())) - ) - ) - } + if (seqTypeInput?.value) { + searchParams.seqType = seqTypeInput.value + } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const updateSequencingGroupSelection = (_: any, d: any) => { - if (!data?.myProjects) { - setSequencingGroups([]) - return + if (technologyInput?.value) { + searchParams.technology = technologyInput.value + } + + if (platformInput?.value) { + searchParams.platform = platformInput.value } - const newSequencingGroups: SequencingGroup[] = [] - data.myProjects.forEach((project) => { - if (d.value.includes(project.id)) { - project.sequencingGroups.forEach((sg: SequencingGroup) => { - newSequencingGroups.push({ + assayMetaSearchFields.forEach((field) => { + const input = document.getElementById(field.id) as HTMLInputElement + if (input?.value) { + searchParams.assayMeta[field.searchVariable] = input.value + } + }) + + searchSquencingGroups({ + variables: { + project: selectedProject.name, + seqType: searchParams.seqType, + technology: searchParams.technology, + platform: searchParams.platform, + assayMeta: searchParams.assayMeta, + }, + + onCompleted: (hits) => { + const samples = hits.project.participants.flatMap((p) => p.samples) + const assays = samples.flatMap((s) => s.assays) + + const sgs = assays.flatMap((a) => + a.sample.sequencingGroups.map((sg) => ({ id: sg.id, type: sg.type, technology: sg.technology, platform: sg.platform, - project: { id: project.id, name: project.name }, - }) - }) - } + project: { id: selectedProject.id, name: selectedProject.name }, + })) + ) + + setSearchHits(uniqBy(sgs, 'id')) + }, }) + } + + const projectOptions = projects.map((project) => ({ + key: project.id, + value: project.id, + text: project.name, + })) - setSequencingGroups(newSequencingGroups) + if (error) { + return } return (

Project

-

Include Sequencing Groups from the following projects

+

Include Sequencing Groups from the following project

{ + const project = projects.find((p) => p.id === d.value) + if (!project) return + setSelectedProject({ id: project.id, name: project.name }) + }} options={projectOptions} />

- Including the Sequencing Groups which match the following criteria (leave blank to - include all Sequencing Groups) + Matching the following search criteria (leave blank to include all Sequencing + Groups)

- - - - + + + + {assayMetaSearchFields.map((field) => ( + + ))}
- + { - onAdd(searchHits) + // eslint-disable-next-line no-alert + const proceed = window.confirm( + 'This will add all Sequencing Groups in the table to your Cohort. ' + + 'Sequencing Groups hidden by the interactive table search will ' + + 'also be added. Do you wish to continue?' + ) + if (proceed) { + onAdd(searchHits) + setSearchHits([]) + } }} />
- + {loading ? ( +
Finding Sequencing Groups...
+ ) : ( + + )} ) } From b4b0a5bb803b844ad50fae70cba2bbfe876635c5 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:27:37 +1100 Subject: [PATCH 029/161] Add GQL query to ID form --- web/src/pages/cohort/AddFromIdListForm.tsx | 67 +++++++++++++++++----- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/web/src/pages/cohort/AddFromIdListForm.tsx b/web/src/pages/cohort/AddFromIdListForm.tsx index c11291960..e2d099153 100644 --- a/web/src/pages/cohort/AddFromIdListForm.tsx +++ b/web/src/pages/cohort/AddFromIdListForm.tsx @@ -1,9 +1,23 @@ -import React, { useContext } from 'react' +import React, { useContext, useState } from 'react' import { Form } from 'semantic-ui-react' +import { useLazyQuery } from '@apollo/client' +import { gql } from '../../__generated__' import { ThemeContext } from '../../shared/components/ThemeProvider' import { SequencingGroup } from './types' +import SequencingGroupTable from './SequencingGroupTable' + +const GET_SEQUENCING_GROUPS_QUERY = gql(` +query FetchSequencingGroupsById($ids: [String!]!) { + sequencingGroups(id: {in_: $ids}) { + id + type + technology + platform + } + } +`) interface IAddFromIdListForm { onAdd: (sequencingGroups: SequencingGroup[]) => void @@ -13,29 +27,38 @@ const AddFromIdListForm: React.FC = ({ onAdd }) => { const { theme } = useContext(ThemeContext) const inverted = theme === 'dark-mode' - const handleInput = () => { + const [text, setText] = useState('') + const [sequencingGroups, setSequencingGroups] = useState([]) + + const [fetchSequencingGroups, { loading, error }] = useLazyQuery(GET_SEQUENCING_GROUPS_QUERY) + + const search = () => { const element = document.getElementById('sequencing-group-ids-csv') as HTMLInputElement if (!element == null) { - onAdd([]) return } if (!element?.value || !element.value.trim()) { - onAdd([]) return } const ids = element.value.trim().split(',') - const sgs: SequencingGroup[] = ids.map((id: string) => ({ - id: id.trim(), - type: '?', - technology: '?', - platform: '?', - project: { id: -1, name: '?' }, - })) - - onAdd(sgs.filter((sg) => sg.id !== '')) + fetchSequencingGroups({ + variables: { ids }, + onCompleted: (hits) => + setSequencingGroups( + hits.sequencingGroups.map((sg) => ({ + ...sg, + project: { id: -1, name: '?' }, + })) + ), + }) + } + + const addToCohort = () => { + onAdd(sequencingGroups) + setSequencingGroups([]) } return ( @@ -45,9 +68,25 @@ const AddFromIdListForm: React.FC = ({ onAdd }) => { setText(e.target.value)} placeholder="Comma separated list of Sequencing Group IDs" /> - + + + + +
+ {loading ? ( +
Fetching Sequencing Groups...
+ ) : ( + + )} ) } From 7f0b62eb29c346ef0c055d7034ec18ab25aadea8 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:27:47 +1100 Subject: [PATCH 030/161] Handle form submission --- web/src/pages/cohort/CohortBuilderView.tsx | 142 +++++++++++++++++++-- 1 file changed, 130 insertions(+), 12 deletions(-) diff --git a/web/src/pages/cohort/CohortBuilderView.tsx b/web/src/pages/cohort/CohortBuilderView.tsx index ba3864c38..8cd1e6711 100644 --- a/web/src/pages/cohort/CohortBuilderView.tsx +++ b/web/src/pages/cohort/CohortBuilderView.tsx @@ -1,13 +1,24 @@ import React, { useState, useContext } from 'react' -import { Container, Divider, Form, Tab } from 'semantic-ui-react' +import { Container, Divider, Form, Message, Tab } from 'semantic-ui-react' import { uniqBy } from 'lodash' +import { useQuery } from '@apollo/client' +import { gql } from '../../__generated__' import { ThemeContext } from '../../shared/components/ThemeProvider' import SequencingGroupTable from './SequencingGroupTable' import AddFromProjectForm from './AddFromProjectForm' import AddFromIdListForm from './AddFromIdListForm' -import { SequencingGroup } from './types' +import { Project, SequencingGroup } from './types' +import MuckError from '../../shared/components/MuckError' + +const GET_PROJECTS_QUERY = gql(` +query GetProjectsForCohortBuilder { + myProjects { + id + name + } +}`) interface CohortFormData { name: string @@ -20,12 +31,19 @@ const CohortBuilderView = () => { const inverted = theme === 'dark-mode' // State for new cohort data + const [createCohortError, setCreateCohortError] = useState(null) + const [createCohortSuccess, setCreateCohortSuccess] = useState(null) + const [createCohortLoading, setCreateCohortLoading] = useState(false) + const [selectedProject, setSelectedProject] = useState() const [cohortFormData, setCohortFormData] = useState({ name: '', description: '', sequencingGroups: [], }) + // Loading projects query for drop-down menu selection + const { loading, error, data } = useQuery(GET_PROJECTS_QUERY) + const addSequencingGroups = (sgs: SequencingGroup[]) => { setCohortFormData({ ...cohortFormData, @@ -41,25 +59,69 @@ const CohortBuilderView = () => { } const createCohort = () => { - // eslint-disable-next-line no-restricted-globals, no-alert - const proceed = confirm( + if (!selectedProject) return + + // eslint-disable-next-line no-alert + const proceed = window.confirm( 'Are you sure you want to create this cohort? A cohort cannot be edited once created.' ) if (!proceed) return - console.log(cohortFormData) + + setCreateCohortLoading(true) + setCreateCohortError(null) + setCreateCohortSuccess(null) + + fetch(`/api/v1/cohort/${selectedProject.name}/`, { + method: 'POST', + body: JSON.stringify({ + name: cohortFormData.name, + description: cohortFormData.description, + sequencingGroups: cohortFormData.sequencingGroups.map((sg) => sg.id), + }), + }) + .then((response) => { + if (!response.ok) { + let message = `Error creating cohort: ${response.status} (${response.status})` + if (response.status === 404) { + message = `Error creating cohort: Project not found (${response.status})` + } + setCreateCohortError(message) + } else { + response + .json() + .then((d) => { + // eslint-disable-next-line no-alert + setCreateCohortSuccess(`Cohort created with ID ${d.cohort_id}`) + }) + .catch((e) => { + // Catch JSON parsing error + setCreateCohortError(`Error parsing JSON response: ${e}`) + // eslint-disable-next-line no-console + console.error(e) + }) + .finally(() => setCreateCohortLoading(false)) + } + }) + .catch((e) => { + setCreateCohortError(`An unknown error occurred while creating cohort: ${e}`) + }) + .finally(() => setCreateCohortLoading(false)) } const tabPanes = [ { - menuItem: 'Project Based', + menuItem: 'From Project', render: () => ( - + ), }, { - menuItem: 'Individual', + menuItem: 'By ID(s)', render: () => ( @@ -68,6 +130,24 @@ const CohortBuilderView = () => { }, ] + const projectOptions = (data?.myProjects ?? []).map((project) => ({ + key: project.id, + value: project.id, + text: project.name, + })) + + if (error) { + return + } + + if (loading) { + return ( + +
Loading available projects..
+
+ ) + } + return (
@@ -82,9 +162,29 @@ const CohortBuilderView = () => {

Details

+ + +

+ Select this cohort's parent project. Only those projects + which are accessible to you are displayed in the drop-down menu. +

+ + } + placeholder="Select Project" + fluid + selection + onChange={(_, d) => { + const project = data?.myProjects?.find((p) => p.id === d.value) + if (!project) return + setSelectedProject({ id: 1, name: 'fake' }) + }} + options={projectOptions} + /> @@ -127,8 +227,7 @@ const CohortBuilderView = () => {

Sequencing Groups

Add sequencing groups to this cohort. You can bulk add sequencing groups - from any project available to to you and specify filtering criteria to match - specific Sequencing Groups. You can also add sequencing groups manually by + from any project available to to you, or add sequencing groups manually by entering their IDs in a comma-separated list.

@@ -149,11 +248,30 @@ const CohortBuilderView = () => {
+
-
@@ -250,7 +261,7 @@ const CohortBuilderView = () => { content={createCohortSuccess} >

Success!

-

Create a new cohort with ID {createCohortSuccess}

+

Created a new cohort with ID {createCohortSuccess}

)}
@@ -267,6 +278,7 @@ const CohortBuilderView = () => { type="button" content="Clear" color="red" + disabled={sequencingGroups.length === 0} loading={createCohortLoading} onClick={() => { // eslint-disable-next-line no-restricted-globals, no-alert From 7d1c8eba7c841dadb5657ae8d025bed777177590 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:51:03 +1100 Subject: [PATCH 039/161] Cohort detail view --- web/src/pages/cohort/CohortDetailView.tsx | 82 +++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 web/src/pages/cohort/CohortDetailView.tsx diff --git a/web/src/pages/cohort/CohortDetailView.tsx b/web/src/pages/cohort/CohortDetailView.tsx new file mode 100644 index 000000000..a893fb10c --- /dev/null +++ b/web/src/pages/cohort/CohortDetailView.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { useQuery } from '@apollo/client' +import { Container, Divider } from 'semantic-ui-react' + +import { gql } from '../../__generated__' +import MuckError from '../../shared/components/MuckError' +import { Project } from './types' +import SequencingGroupTable from './SequencingGroupTable' + +const COHORT_DETAIL_VIEW_FRAGMENT = gql(` +query CohortDetailView($id: Int!) { + cohort(id: {eq: $id}) { + id + name + description + sequencingGroups { + id + type + technology + platform + } + } +} +`) + +interface ICohortDetailViewProps { + id: number + project: Project +} + +const CohortDetailView: React.FC = ({ id, project }) => { + const { loading, error, data } = useQuery(COHORT_DETAIL_VIEW_FRAGMENT, { + variables: { id }, + }) + + if (loading) { + return ( + +
Loading Cohort...
+
+ ) + } + + if (error) { + return ( + + + + ) + } + + const cohort = data?.cohort[0] || null + if (!cohort) { + return ( + + + + ) + } + + return ( + +
+ Name: {cohort.name} + Description: {cohort.description} +
+ + ({ + id: sg.id, + type: sg.type, + technology: sg.technology, + platform: sg.platform, + project, + }))} + editable={false} + /> +
+ ) +} + +export default CohortDetailView From 706cf01f3ced67441f3f791ed218c83b25fcebda Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 14:29:23 +1100 Subject: [PATCH 040/161] import updates --- api/graphql/schema.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 1f1b51071..4bafeec96 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -18,15 +18,21 @@ from api.graphql.filters import GraphQLFilter, GraphQLMetaFilter from api.graphql.loaders import LoaderKeys, get_context from db.python import enum_tables + from db.python.layers.assay import AssayLayer +from db.python.layers.analysis import AnalysisLayer from db.python.layers.cohort import CohortLayer from db.python.layers.family import FamilyLayer +from db.python.layers.sample import SampleLayer +from db.python.layers.sequencing_group import SequencingGroupLayer + from db.python.tables.analysis import AnalysisFilter from db.python.tables.assay import AssayFilter from db.python.tables.cohort import CohortFilter from db.python.tables.project import ProjectPermissionsTable from db.python.tables.sample import SampleFilter from db.python.tables.sequencing_group import SequencingGroupFilter + from db.python.utils import GenericFilter from models.enums import AnalysisStatus from models.models import ( From 05066b075bab48b233c7c22a32dc8e2ca29836fa Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 15:09:34 +1100 Subject: [PATCH 041/161] add detail route --- web/src/Routes.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index 05d513d8d..962620413 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -11,6 +11,7 @@ import ErrorBoundary from './shared/utilities/errorBoundary' import AnalysisRunnerSummary from './pages/project/AnalysisRunnerView/AnalysisRunnerSummary' import BillingDashboard from './pages/billing/BillingDashboard' import CohortBuilderView from './pages/cohort/CohortBuilderView' +import CohortDetailView from './pages/cohort/CohortDetailView' const Routes: React.FunctionComponent = () => ( @@ -84,6 +85,15 @@ const Routes: React.FunctionComponent = () => ( } /> + + + + + } + /> ) From d60db2ae76a31c3b37ff0a079437ae0b77fd53dc Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 15:09:50 +1100 Subject: [PATCH 042/161] use with router hooks --- web/src/pages/cohort/CohortDetailView.tsx | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/web/src/pages/cohort/CohortDetailView.tsx b/web/src/pages/cohort/CohortDetailView.tsx index a893fb10c..9cc688ab3 100644 --- a/web/src/pages/cohort/CohortDetailView.tsx +++ b/web/src/pages/cohort/CohortDetailView.tsx @@ -1,10 +1,10 @@ import React from 'react' import { useQuery } from '@apollo/client' +import { useParams } from 'react-router-dom' import { Container, Divider } from 'semantic-ui-react' import { gql } from '../../__generated__' import MuckError from '../../shared/components/MuckError' -import { Project } from './types' import SequencingGroupTable from './SequencingGroupTable' const COHORT_DETAIL_VIEW_FRAGMENT = gql(` @@ -23,19 +23,18 @@ query CohortDetailView($id: Int!) { } `) -interface ICohortDetailViewProps { - id: number - project: Project -} +const CohortDetailView: React.FC = () => { + const { id } = useParams() -const CohortDetailView: React.FC = ({ id, project }) => { const { loading, error, data } = useQuery(COHORT_DETAIL_VIEW_FRAGMENT, { - variables: { id }, + variables: { id: id ? parseInt(id, 10) : 0 }, }) if (loading) { return ( +

Cohort Information

+
Loading Cohort...
) @@ -44,6 +43,8 @@ const CohortDetailView: React.FC = ({ id, project }) => if (error) { return ( +

Cohort Information

+
) @@ -53,6 +54,8 @@ const CohortDetailView: React.FC = ({ id, project }) => if (!cohort) { return ( +

Cohort Information

+
) @@ -60,8 +63,11 @@ const CohortDetailView: React.FC = ({ id, project }) => return ( +

Cohort Information

+
Name: {cohort.name} +
Description: {cohort.description}
@@ -71,7 +77,7 @@ const CohortDetailView: React.FC = ({ id, project }) => type: sg.type, technology: sg.technology, platform: sg.platform, - project, + project: { name: '?', id: 1 }, }))} editable={false} /> From 654b9aea364994a8ce6100d38a9efd037d384c63 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 15:10:02 +1100 Subject: [PATCH 043/161] exclude ids input --- web/src/pages/cohort/AddFromProjectForm.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/web/src/pages/cohort/AddFromProjectForm.tsx b/web/src/pages/cohort/AddFromProjectForm.tsx index 29efa57b4..9f3c5b34e 100644 --- a/web/src/pages/cohort/AddFromProjectForm.tsx +++ b/web/src/pages/cohort/AddFromProjectForm.tsx @@ -17,7 +17,8 @@ query FetchSequencingGroups( $platform: String, $technology: String, $seqType: String, - $assayMeta: JSON + $assayMeta: JSON, + $excludeIds: [String!] ) { project(name: $project) { participants { @@ -25,6 +26,7 @@ query FetchSequencingGroups( assays(meta: $assayMeta) { sample { sequencingGroups( + id: {nin: $excludeIds} platform: {icontains: $platform} technology: {icontains: $technology} type: {contains: $seqType} @@ -67,6 +69,7 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) const seqTypeInput = document.getElementById('seq_type') as HTMLInputElement const technologyInput = document.getElementById('technology') as HTMLInputElement const platformInput = document.getElementById('platform') as HTMLInputElement + const excludeInput = document.getElementById('exclude') as HTMLInputElement if (!selectedProject?.name) { return @@ -77,6 +80,7 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) seqType: null, technology: null, platform: null, + excludeIds: [], assayMeta: {}, } @@ -92,6 +96,13 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) searchParams.platform = platformInput.value } + if (excludeInput?.value && excludeInput.value.trim().length > 0) { + searchParams.excludeIds = excludeInput.value + .split(',') + .map((id) => id.trim()) + .filter((id) => id.length > 0) + } + assayMetaSearchFields.forEach((field) => { const input = document.getElementById(field.id) as HTMLInputElement if (input?.value) { @@ -106,6 +117,7 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) technology: searchParams.technology, platform: searchParams.platform, assayMeta: searchParams.assayMeta, + excludeIds: searchParams.excludeIds, }, onCompleted: (hits) => { @@ -188,6 +200,11 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) {assayMetaSearchFields.map((field) => ( ))} +

From c002f88d254ac7e15015891a3fcbd144c9af9263 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 15:10:15 +1100 Subject: [PATCH 044/161] navigate to new detail vie --- web/src/pages/cohort/CohortBuilderView.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/src/pages/cohort/CohortBuilderView.tsx b/web/src/pages/cohort/CohortBuilderView.tsx index 6ddfe45da..1fdb7111e 100644 --- a/web/src/pages/cohort/CohortBuilderView.tsx +++ b/web/src/pages/cohort/CohortBuilderView.tsx @@ -2,6 +2,7 @@ import React, { useState, useContext } from 'react' import { Container, Divider, Form, Message, Tab } from 'semantic-ui-react' import { uniqBy } from 'lodash' import { useQuery } from '@apollo/client' +import { useNavigate } from 'react-router-dom' import { gql } from '../../__generated__' import { CohortApi, CohortBody } from '../../sm-api' @@ -28,6 +29,8 @@ const CohortBuilderView = () => { const { theme } = useContext(ThemeContext) const inverted = theme === 'dark-mode' + const navigate = useNavigate() + // State for new cohort data const [createCohortError, setCreateCohortError] = useState(null) const [createCohortSuccess, setCreateCohortSuccess] = useState(null) @@ -90,6 +93,7 @@ const CohortBuilderView = () => { }) .then((response) => { setCreateCohortSuccess(response.data.cohort_id) + navigate(`/cohort/detail/${response.data.cohort_id}`) }) .catch((e) => { setCreateCohortError(e.response.data) From af8f4d9499f457dba8ba8f92dafa5ae0a365a7b0 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 15:11:55 +1100 Subject: [PATCH 045/161] make project required --- web/src/pages/cohort/CohortBuilderView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/pages/cohort/CohortBuilderView.tsx b/web/src/pages/cohort/CohortBuilderView.tsx index 1fdb7111e..b2b534f9d 100644 --- a/web/src/pages/cohort/CohortBuilderView.tsx +++ b/web/src/pages/cohort/CohortBuilderView.tsx @@ -174,6 +174,7 @@ const CohortBuilderView = () => { placeholder="Select Project" fluid selection + required onChange={(_, d) => { const project = data?.myProjects?.find((p) => p.id === d.value) if (!project) return From f1e491c4829198179a4ce584cda6b3e28b81a20f Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 16:02:37 +1100 Subject: [PATCH 046/161] Add space between muck and msg --- web/src/shared/components/MuckError.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/src/shared/components/MuckError.tsx b/web/src/shared/components/MuckError.tsx index 3bed1b568..4894f031d 100644 --- a/web/src/shared/components/MuckError.tsx +++ b/web/src/shared/components/MuckError.tsx @@ -6,8 +6,7 @@ const MuckError: React.FunctionComponent<{ message: string }> = ({ message }) => (

- {message} - + {message}

) From deaa49116041fca0ddb7cb8b430430360402f985 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 16:02:46 +1100 Subject: [PATCH 047/161] Get project info --- web/src/pages/cohort/CohortDetailView.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/web/src/pages/cohort/CohortDetailView.tsx b/web/src/pages/cohort/CohortDetailView.tsx index 9cc688ab3..ab214cd66 100644 --- a/web/src/pages/cohort/CohortDetailView.tsx +++ b/web/src/pages/cohort/CohortDetailView.tsx @@ -13,11 +13,21 @@ query CohortDetailView($id: Int!) { id name description + project { + id + name + } sequencingGroups { id type technology platform + sample { + project { + id + name + } + } } } } @@ -56,7 +66,7 @@ const CohortDetailView: React.FC = () => {

Cohort Information

- +
) } @@ -69,15 +79,18 @@ const CohortDetailView: React.FC = () => { Name: {cohort.name}
Description: {cohort.description} +
+ Project: {cohort.project.name} ({ id: sg.id, type: sg.type, technology: sg.technology, platform: sg.platform, - project: { name: '?', id: 1 }, + project: sg.sample.project, }))} editable={false} /> From d2662f299447caa68ed95050390f905e026c1d82 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 16:03:14 +1100 Subject: [PATCH 048/161] Convert ids to int to fix not found error --- api/graphql/loaders.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api/graphql/loaders.py b/api/graphql/loaders.py index 81fe6b44e..66c559330 100644 --- a/api/graphql/loaders.py +++ b/api/graphql/loaders.py @@ -333,11 +333,16 @@ async def load_projects_for_ids(project_ids: list[int], connection) -> list[Proj Get projects by IDs """ pttable = ProjectPermissionsTable(connection.connection) + + ids = [int(p) for p in project_ids] projects = await pttable.get_and_check_access_to_projects_for_ids( - user=connection.user, project_ids=project_ids, readonly=True + user=connection.author, project_ids=ids, readonly=True ) + p_by_id = {p.id: p for p in projects} - return [p_by_id.get(p) for p in project_ids] + projects = [p_by_id.get(p) for p in ids] + + return [p for p in projects if p is not None] @connected_data_loader(LoaderKeys.FAMILIES_FOR_PARTICIPANTS) From 1ca9df40a5c3af749d31692a322ff7a00a9f53e2 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 16:03:30 +1100 Subject: [PATCH 049/161] cohort project resolver --- api/graphql/schema.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 4bafeec96..6d3d95261 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -80,7 +80,6 @@ class GraphQLCohort: id: int name: str - project: str description: str author: str derived_from: int | None = None @@ -108,6 +107,12 @@ async def sequencing_groups( sequencing_groups = await sg_layer.get_sequencing_groups_by_ids(sg_ids) return [GraphQLSequencingGroup.from_internal(sg) for sg in sequencing_groups] + @strawberry.field() + async def project(self, info: Info, root: 'Cohort') -> 'GraphQLProject': + loader = info.context[LoaderKeys.PROJECTS_FOR_IDS] + project = await loader.load(root.project) + return GraphQLProject.from_internal(project) + @strawberry.type class GraphQLProject: From c5f0fd82b7bde9add8827274a69d88b7ab45d4f4 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 16:50:40 +1100 Subject: [PATCH 050/161] Update table rendering logic --- web/src/pages/cohort/AddFromIdListForm.tsx | 69 ++++++++++++++++----- web/src/pages/cohort/AddFromProjectForm.tsx | 54 +++++++++++----- 2 files changed, 91 insertions(+), 32 deletions(-) diff --git a/web/src/pages/cohort/AddFromIdListForm.tsx b/web/src/pages/cohort/AddFromIdListForm.tsx index 293baa079..f78de0d34 100644 --- a/web/src/pages/cohort/AddFromIdListForm.tsx +++ b/web/src/pages/cohort/AddFromIdListForm.tsx @@ -1,5 +1,5 @@ import React, { useContext, useState } from 'react' -import { Form } from 'semantic-ui-react' +import { Form, Message } from 'semantic-ui-react' import { useLazyQuery } from '@apollo/client' import { gql } from '../../__generated__' @@ -7,6 +7,7 @@ import { ThemeContext } from '../../shared/components/ThemeProvider' import { SequencingGroup } from './types' import SequencingGroupTable from './SequencingGroupTable' +import MuckError from '../../shared/components/MuckError' const GET_SEQUENCING_GROUPS_QUERY = gql(` query FetchSequencingGroupsById($ids: [String!]!) { @@ -15,13 +16,12 @@ query FetchSequencingGroupsById($ids: [String!]!) { type technology platform - # FIXME: Add project info to query when connection.user bug is fixed on server - # samples { - # project { - # id - # name - # } - # } + sample { + project { + id + name + } + } } } `) @@ -35,7 +35,7 @@ const AddFromIdListForm: React.FC = ({ onAdd }) => { const inverted = theme === 'dark-mode' const [text, setText] = useState('') - const [sequencingGroups, setSequencingGroups] = useState([]) + const [sequencingGroups, setSequencingGroups] = useState(null) const [fetchSequencingGroups, { loading, error }] = useLazyQuery(GET_SEQUENCING_GROUPS_QUERY) @@ -57,15 +57,45 @@ const AddFromIdListForm: React.FC = ({ onAdd }) => { setSequencingGroups( hits.sequencingGroups.map((sg) => ({ ...sg, - project: { id: -1, name: '?' }, + project: sg.sample.project, })) ), }) } const addToCohort = () => { + if (sequencingGroups == null) { + return + } + onAdd(sequencingGroups) - setSequencingGroups([]) + setSequencingGroups(null) + } + + const renderTable = () => { + if (loading) { + return ( + <> +
+
Finding sequencing groups...
+ + ) + } + + if (sequencingGroups == null) { + return null + } + + if (sequencingGroups.length === 0) { + return ( + <> +
+ No sequencing groups found matching your query + + ) + } + + return } return ( @@ -90,16 +120,21 @@ const AddFromIdListForm: React.FC = ({ onAdd }) => {
-
- {loading ? ( -
Fetching Sequencing Groups...
- ) : ( - + {error && ( + + + )} + {renderTable()} ) } diff --git a/web/src/pages/cohort/AddFromProjectForm.tsx b/web/src/pages/cohort/AddFromProjectForm.tsx index 9f3c5b34e..d39ccf472 100644 --- a/web/src/pages/cohort/AddFromProjectForm.tsx +++ b/web/src/pages/cohort/AddFromProjectForm.tsx @@ -1,5 +1,5 @@ import React, { useState, useContext } from 'react' -import { Container, Form } from 'semantic-ui-react' +import { Container, Form, Message } from 'semantic-ui-react' import { useLazyQuery } from '@apollo/client' import { uniq } from 'lodash' @@ -57,7 +57,7 @@ interface IAddFromProjectForm { const AddFromProjectForm: React.FC = ({ projects, onAdd }) => { const [selectedProject, setSelectedProject] = useState() - const [searchHits, setSearchHits] = useState([]) + const [searchHits, setSearchHits] = useState(null) const { theme } = useContext(ThemeContext) const inverted = theme === 'dark-mode' @@ -163,20 +163,42 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) }) } + const renderTable = () => { + if (loading) { + return ( + <> +
+
Finding sequencing groups...
+ + ) + } + + if (searchHits == null) { + return null + } + + if (searchHits.length === 0) { + return ( + <> +
+ No sequencing groups found matching your query + + ) + } + + return + } + const projectOptions = projects.map((project) => ({ key: project.id, value: project.id, text: project.name, })) - if (error) { - return - } - return (

Project

-

Include Sequencing Groups from the following project

+

Include sequencing groups from the following project

= ({ projects, onAdd }) loading || error != null || selectedProject == null || + searchHits == null || searchHits.length === 0 } content="Add" onClick={() => { + if (searchHits == null) return // eslint-disable-next-line no-alert const proceed = window.confirm( - 'This will add all Sequencing Groups in the table to your Cohort. ' + - 'Sequencing Groups hidden by the interactive table search will ' + + 'This will add all sequencing groups in the table to your Cohort. ' + + 'sequencing groups hidden by the interactive table search will ' + 'also be added. Do you wish to continue?' ) if (proceed) { onAdd(searchHits) - setSearchHits([]) + setSearchHits(null) } }} /> -
- {loading ? ( -
Finding Sequencing Groups...
- ) : ( - + {error && ( + + + )} + {renderTable()} ) } From 573b57b292c60ef88131fa3a3193036c54c915c8 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 16:51:13 +1100 Subject: [PATCH 051/161] Update table rendering logic --- web/src/pages/cohort/CohortBuilderView.tsx | 34 ++++---- web/src/pages/cohort/CohortDetailView.tsx | 1 + web/src/pages/cohort/SequencingGroupTable.tsx | 81 ++++++++++--------- 3 files changed, 66 insertions(+), 50 deletions(-) diff --git a/web/src/pages/cohort/CohortBuilderView.tsx b/web/src/pages/cohort/CohortBuilderView.tsx index b2b534f9d..33f57b1e6 100644 --- a/web/src/pages/cohort/CohortBuilderView.tsx +++ b/web/src/pages/cohort/CohortBuilderView.tsx @@ -246,6 +246,7 @@ const CohortBuilderView = () => { @@ -253,23 +254,28 @@ const CohortBuilderView = () => {
{createCohortError && ( - + <> + +
+ )} {createCohortSuccess && ( - + <> + +
+ )} -
{ ({ id: sg.id, type: sg.type, diff --git a/web/src/pages/cohort/SequencingGroupTable.tsx b/web/src/pages/cohort/SequencingGroupTable.tsx index 6189acd2a..6bfcc6649 100644 --- a/web/src/pages/cohort/SequencingGroupTable.tsx +++ b/web/src/pages/cohort/SequencingGroupTable.tsx @@ -6,16 +6,20 @@ import { SequencingGroup } from './types' type Direction = 'ascending' | 'descending' | undefined -const SequencingGroupTable = ({ - editable = true, - height = 650, - sequencingGroups = [], - onDelete = () => {}, -}: { +interface ISequencingGroupTableProps { editable?: boolean height?: number sequencingGroups?: SequencingGroup[] + emptyMessage?: string onDelete?: (id: string) => void +} + +const SequencingGroupTable: React.FC = ({ + editable = true, + height = 650, + sequencingGroups = [], + emptyMessage = 'Nothing to display', + onDelete = () => {}, }) => { const [sortColumn, setSortColumn] = useState('id') const [sortDirection, setSortDirection] = useState('ascending') @@ -60,8 +64,39 @@ const SequencingGroupTable = ({ { key: 'platform', name: 'Platform' }, ] - if (!sequencingGroups.length) { - return No Sequencing Groups have been added to this cohort + const renderTableBody = () => { + if (sequencingGroups.length && !sortedRows.length) { + return ( + + No rows matching your filters + + ) + } + + if (!sequencingGroups.length) { + return ( + + {emptyMessage} + + ) + } + + return sortedRows.map((sg: SequencingGroup) => ( + + {editable && sortedRows.length ? ( + + + + ) : null} + {sg.id} + {sg.project.name} + {sg.type} + {sg.technology} + {sg.platform} + + )) } return ( @@ -77,7 +112,7 @@ const SequencingGroupTable = ({ }} > - {editable ? : null} + {editable && sortedRows.length ? : null} {tableColumns.map((column) => ( - - {sortedRows.length ? ( - sortedRows.map((sg: SequencingGroup) => ( - - {editable ? ( - - - - ) : null} - {sg.id} - {sg.project.name} - {sg.type} - {sg.technology} - {sg.platform} - - )) - ) : ( - - - - No Sequencing Groups matching your search criteria - - - )} - + {renderTableBody()} ) From 49a1245b3987f798be9a0d5e4aaef95dfc9afde1 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Tue, 28 Nov 2023 17:04:04 +1100 Subject: [PATCH 052/161] Add search result info in warning box --- web/src/pages/cohort/AddFromIdListForm.tsx | 21 +++++++------------- web/src/pages/cohort/AddFromProjectForm.tsx | 22 +++++++-------------- 2 files changed, 14 insertions(+), 29 deletions(-) diff --git a/web/src/pages/cohort/AddFromIdListForm.tsx b/web/src/pages/cohort/AddFromIdListForm.tsx index f78de0d34..d1ea79188 100644 --- a/web/src/pages/cohort/AddFromIdListForm.tsx +++ b/web/src/pages/cohort/AddFromIdListForm.tsx @@ -53,6 +53,9 @@ const AddFromIdListForm: React.FC = ({ onAdd }) => { const ids = element.value.trim().split(',') fetchSequencingGroups({ variables: { ids }, + onError: () => { + setSequencingGroups(null) + }, onCompleted: (hits) => setSequencingGroups( hits.sequencingGroups.map((sg) => ({ @@ -73,25 +76,15 @@ const AddFromIdListForm: React.FC = ({ onAdd }) => { } const renderTable = () => { - if (loading) { - return ( - <> -
-
Finding sequencing groups...
- - ) - } - - if (sequencingGroups == null) { + if (loading || sequencingGroups == null) { return null } if (sequencingGroups.length === 0) { return ( - <> -
- No sequencing groups found matching your query - + + No sequencing groups found matching your query + ) } diff --git a/web/src/pages/cohort/AddFromProjectForm.tsx b/web/src/pages/cohort/AddFromProjectForm.tsx index d39ccf472..5c99910f5 100644 --- a/web/src/pages/cohort/AddFromProjectForm.tsx +++ b/web/src/pages/cohort/AddFromProjectForm.tsx @@ -119,7 +119,9 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) assayMeta: searchParams.assayMeta, excludeIds: searchParams.excludeIds, }, - + onError: () => { + setSearchHits(null) + }, onCompleted: (hits) => { const samples = hits.project.participants.flatMap((p) => p.samples) const assays = samples.flatMap((s) => s.assays) @@ -164,25 +166,15 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) } const renderTable = () => { - if (loading) { - return ( - <> -
-
Finding sequencing groups...
- - ) - } - - if (searchHits == null) { + if (loading || searchHits == null) { return null } if (searchHits.length === 0) { return ( - <> -
- No sequencing groups found matching your query - + + No sequencing groups found matching your query + ) } From 2b9ba76b66784c5a78459bd231a0c15b9add90cd Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:46:20 +1100 Subject: [PATCH 053/161] Linter fixes --- api/routes/cohort.py | 2 ++ db/python/layers/cohort.py | 3 --- db/python/tables/cohort.py | 13 ++++++++----- db/python/utils.py | 1 + web/src/pages/cohort/AddFromProjectForm.tsx | 6 +++--- web/src/pages/cohort/types.ts | 2 +- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index b61627c6c..6cfc3ebfe 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -10,6 +10,8 @@ class CohortBody(BaseModel): + """Represents the expected JSON body of the create cohort request""" + name: str description: str sequencing_group_ids: list[str] diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 56d6de656..56aac77c5 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -1,5 +1,3 @@ -from typing import Any - from db.python.connect import Connection from db.python.layers.base import BaseLayer from db.python.tables.analysis import AnalysisTable @@ -8,7 +6,6 @@ from db.python.tables.sample import SampleTable from db.python.utils import get_logger from models.models.cohort import Cohort -from models.models.sequencing_group import SequencingGroupInternal logger = get_logger() diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 0f9b98858..94ef9bc95 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -4,7 +4,6 @@ from db.python.connect import DbBase from db.python.tables.project import ProjectId -from db.python.tables.sequencing_group import SequencingGroupTable from db.python.utils import GenericFilter, GenericFilterModel from models.models.cohort import Cohort from models.utils.sequencing_group_id_format import sequencing_group_id_transform_to_raw @@ -56,12 +55,16 @@ async def query(self, filter_: CohortFilter): cohorts = [Cohort.from_db(dict(row)) for row in rows] return cohorts - async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list: + async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: + """ + Return all sequencing group IDs for the given cohort. + """ + _query = """ SELECT sequencing_group_id FROM cohort_sequencing_group WHERE cohort_id = :cohort_id """ rows = await self.connection.fetch_all(_query, {'cohort_id': cohort_id}) - return [row["sequencing_group_id"] for row in rows] + return [row['sequencing_group_id'] for row in rows] async def create_cohort( self, @@ -80,7 +83,7 @@ async def create_cohort( # left in an incomplete state if the query fails part way through. async with self.connection.transaction(): _query = """ - INSERT INTO cohort (name, derived_from, author, description, project) + INSERT INTO cohort (name, derived_from, author, description, project) VALUES (:name, :derived_from, :author, :description, :project) RETURNING id """ @@ -96,7 +99,7 @@ async def create_cohort( ) _query = """ - INSERT INTO cohort_sequencing_group (cohort_id, sequencing_group_id) + INSERT INTO cohort_sequencing_group (cohort_id, sequencing_group_id) VALUES (:cohort_id, :sequencing_group_id) """ diff --git a/db/python/utils.py b/db/python/utils.py index d97983eab..1f1d18308 100644 --- a/db/python/utils.py +++ b/db/python/utils.py @@ -80,6 +80,7 @@ def __init__( ) +# pylint: disable=too-many-instance-attributes class GenericFilter(Generic[T]): """ Generic filter for eq, in_ (in) and nin (not in) diff --git a/web/src/pages/cohort/AddFromProjectForm.tsx b/web/src/pages/cohort/AddFromProjectForm.tsx index 5c99910f5..f13b59e3d 100644 --- a/web/src/pages/cohort/AddFromProjectForm.tsx +++ b/web/src/pages/cohort/AddFromProjectForm.tsx @@ -13,9 +13,9 @@ import { FetchSequencingGroupsQueryVariables } from '../../__generated__/graphql const GET_SEQUENCING_GROUPS_QUERY = gql(` query FetchSequencingGroups( - $project: String!, - $platform: String, - $technology: String, + $project: String!, + $platform: String, + $technology: String, $seqType: String, $assayMeta: JSON, $excludeIds: [String!] diff --git a/web/src/pages/cohort/types.ts b/web/src/pages/cohort/types.ts index c0bcb5098..3ebc2a1e6 100644 --- a/web/src/pages/cohort/types.ts +++ b/web/src/pages/cohort/types.ts @@ -15,4 +15,4 @@ export interface APIError { name: string description: string stacktrace: string -} \ No newline at end of file +} From 0172dffbd3151d8387961ef9409e1a876ad801c6 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 4 Dec 2023 11:03:04 +1100 Subject: [PATCH 054/161] formatting --- models/models/__init__.py | 96 ++++++++++++++++++++++++++------------- 1 file changed, 64 insertions(+), 32 deletions(-) diff --git a/models/models/__init__.py b/models/models/__init__.py index 7151aa3d9..3a6eddfbf 100644 --- a/models/models/__init__.py +++ b/models/models/__init__.py @@ -1,34 +1,66 @@ -from models.models.analysis import (Analysis, AnalysisInternal, DateSizeModel, - ProjectSizeModel, ProportionalDateModel, - ProportionalDateProjectModel, - ProportionalDateTemporalMethod, - SequencingGroupSizeModel) -from models.models.assay import (Assay, AssayInternal, AssayUpsert, - AssayUpsertInternal) -from models.models.billing import (BillingColumn, BillingRowRecord, - BillingTotalCostQueryModel, - BillingTotalCostRecord) +from models.models.analysis import ( + Analysis, + AnalysisInternal, + DateSizeModel, + ProjectSizeModel, + ProportionalDateModel, + ProportionalDateProjectModel, + ProportionalDateTemporalMethod, + SequencingGroupSizeModel, +) +from models.models.assay import Assay, AssayInternal, AssayUpsert, AssayUpsertInternal +from models.models.billing import ( + BillingColumn, + BillingRowRecord, + BillingTotalCostQueryModel, + BillingTotalCostRecord, +) from models.models.cohort import Cohort -from models.models.family import (Family, FamilyInternal, FamilySimple, - FamilySimpleInternal, PedRowInternal) -from models.models.participant import (NestedParticipant, - NestedParticipantInternal, Participant, - ParticipantInternal, ParticipantUpsert, - ParticipantUpsertInternal) +from models.models.family import ( + Family, + FamilyInternal, + FamilySimple, + FamilySimpleInternal, + PedRowInternal, +) +from models.models.participant import ( + NestedParticipant, + NestedParticipantInternal, + Participant, + ParticipantInternal, + ParticipantUpsert, + ParticipantUpsertInternal, +) from models.models.project import Project -from models.models.sample import (NestedSample, NestedSampleInternal, Sample, - SampleInternal, SampleUpsert, - SampleUpsertInternal) -from models.models.search import (ErrorResponse, FamilySearchResponseData, - ParticipantSearchResponseData, - SampleSearchResponseData, SearchItem, - SearchResponse, SearchResponseData, - SequencingGroupSearchResponseData) -from models.models.sequencing_group import (NestedSequencingGroup, - NestedSequencingGroupInternal, - SequencingGroup, - SequencingGroupInternal, - SequencingGroupUpsert, - SequencingGroupUpsertInternal) -from models.models.web import (PagingLinks, ProjectSummary, - ProjectSummaryInternal, WebProject) +from models.models.sample import ( + NestedSample, + NestedSampleInternal, + Sample, + SampleInternal, + SampleUpsert, + SampleUpsertInternal, +) +from models.models.search import ( + ErrorResponse, + FamilySearchResponseData, + ParticipantSearchResponseData, + SampleSearchResponseData, + SearchItem, + SearchResponse, + SearchResponseData, + SequencingGroupSearchResponseData, +) +from models.models.sequencing_group import ( + NestedSequencingGroup, + NestedSequencingGroupInternal, + SequencingGroup, + SequencingGroupInternal, + SequencingGroupUpsert, + SequencingGroupUpsertInternal, +) +from models.models.web import ( + PagingLinks, + ProjectSummary, + ProjectSummaryInternal, + WebProject, +) From 4b0661a5031998ac4bd392250154f170c5efac42 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 4 Dec 2023 12:40:57 +1100 Subject: [PATCH 055/161] lint fix --- models/models/__init__.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/models/models/__init__.py b/models/models/__init__.py index 957084259..ab2e0d707 100644 --- a/models/models/__init__.py +++ b/models/models/__init__.py @@ -14,6 +14,8 @@ BillingRowRecord, BillingTotalCostQueryModel, BillingTotalCostRecord, + BillingCostBudgetRecord, + BillingCostDetailsRecord, ) from models.models.cohort import Cohort from models.models.family import ( @@ -64,11 +66,3 @@ ProjectSummaryInternal, WebProject, ) -from models.models.billing import ( - BillingRowRecord, - BillingTotalCostRecord, - BillingTotalCostQueryModel, - BillingColumn, - BillingCostBudgetRecord, - BillingCostDetailsRecord, -) From cfd21ebbb66e045766bce5e629b0bbf081420463 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 4 Dec 2023 12:41:06 +1100 Subject: [PATCH 056/161] unused import --- web/src/Routes.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index cfc1cdcfe..dfa20047a 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -15,7 +15,6 @@ import ProjectSummaryView from './pages/project/ProjectSummary' import ProjectsAdmin from './pages/admin/ProjectsAdmin' import ErrorBoundary from './shared/utilities/errorBoundary' import AnalysisRunnerSummary from './pages/project/AnalysisRunnerView/AnalysisRunnerSummary' -import BillingDashboard from './pages/billing/BillingDashboard' import CohortBuilderView from './pages/cohort/CohortBuilderView' import CohortDetailView from './pages/cohort/CohortDetailView' From 7a3611cf6e0f97ad8f2a2e3b2f0f381e01e7f501 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Mon, 4 Dec 2023 12:41:33 +1100 Subject: [PATCH 057/161] Tmp fix for billing query inside NavBar --- web/src/shared/components/Header/NavBar.tsx | 243 +++++++++++++++----- 1 file changed, 181 insertions(+), 62 deletions(-) diff --git a/web/src/shared/components/Header/NavBar.tsx b/web/src/shared/components/Header/NavBar.tsx index 9dbf09949..ded4e11d3 100644 --- a/web/src/shared/components/Header/NavBar.tsx +++ b/web/src/shared/components/Header/NavBar.tsx @@ -2,14 +2,10 @@ import * as React from 'react' import { Link } from 'react-router-dom' import { Menu, Dropdown, Popup } from 'semantic-ui-react' -import { BillingApi } from '../../../sm-api' - // this wasn't working, so added import to HTML // import 'bootstrap/dist/css/bootstrap.min.css' -import Searchbar from './Search' -import MuckTheDuck from '../MuckTheDuck' -import SwaggerIcon from '../SwaggerIcon' -import ConstructionIcon from '@mui/icons-material/Construction'; + +import ConstructionIcon from '@mui/icons-material/Construction' import HomeIcon from '@mui/icons-material/Home' import ExploreIcon from '@mui/icons-material/Explore' import InsightsIcon from '@mui/icons-material/Insights' @@ -17,64 +13,88 @@ import TableRowsIcon from '@mui/icons-material/TableRows' import AttachMoneyIcon from '@mui/icons-material/AttachMoney' import DescriptionIcon from '@mui/icons-material/Description' import TroubleshootIcon from '@mui/icons-material/Troubleshoot' +import CodeIcon from '@mui/icons-material/Code' import DarkModeTriButton from './DarkModeTriButton/DarkModeTriButton' +// import { BillingApi } from '../../../sm-api' + +import MuckTheDuck from '../MuckTheDuck' +import SwaggerIcon from '../SwaggerIcon' import { ThemeContext } from '../ThemeProvider' +import Searchbar from './Search' + import './NavBar.css' -const billingPages = { - title: 'Billing', - url: '/billing', - icon: , - submenu: [ - { - title: 'Home', - url: '/billing', - icon: , - }, - { - title: 'Invoice Month Cost', - url: '/billing/invoiceMonthCost', - icon: , - }, - { - title: 'Cost By Time', - url: '/billing/costByTime', - icon: , - }, - { - title: 'Seqr Prop Map', - url: '/billing/seqrPropMap', - icon: , - }, - ], -} +// FIXME: Billing pages API query takes a long time on load and locks up the entire application +// so I'm commenting this query out for now until it becomes faster. See below for the useEffect +// where this query is performed. In general, I think we should avoid making API calls in the +// NavBar unless they are absolutely necessary. -interface MenuItem { +// const billingPages = { +// title: 'Billing', +// url: '/billing', +// icon: , +// submenu: [ +// { +// title: 'Home', +// url: '/billing', +// icon: , +// }, +// { +// title: 'Invoice Month Cost', +// url: '/billing/invoiceMonthCost', +// icon: , +// }, +// { +// title: 'Cost By Time', +// url: '/billing/costByTime', +// icon: , +// }, +// { +// title: 'Seqr Prop Map', +// url: '/billing/seqrPropMap', +// icon: , +// }, +// ], +// } + +interface MenuItemDetails { title: string url: string icon: JSX.Element - submenu?: MenuItem[] + external?: boolean + submenu?: MenuItemDetails[] } + interface MenuItemProps { index: number - item: MenuItem + item: MenuItemDetails } const MenuItem: React.FC = ({ index, item }) => { const theme = React.useContext(ThemeContext) const isDarkMode = theme.theme === 'dark-mode' - const dropdown = (item: MenuItem) => ( - + const dropdown = (i: MenuItemDetails) => ( + - {item.submenu && - item.submenu.map((subitem, subindex) => ( - - {subitem.title} - - ))} + {i.submenu && + i.submenu.map((subitem, subindex) => { + if (subitem.external) { + return ( + + {subitem.title} + + ) + } + + return ( + + {subitem.title} + + ) + })} ) @@ -110,7 +130,7 @@ interface NavBarProps { } const NavBar: React.FC = ({ fixed }) => { - const [menuItems, setMenuItems] = React.useState([ + const menuItems: MenuItemDetails[] = [ { title: 'Explore', url: '/project', @@ -122,29 +142,128 @@ const NavBar: React.FC = ({ fixed }) => { icon: , }, { - title: 'Swagger', - url: '/swagger', - icon: , + title: 'Cohort Builder', + url: '/cohort-builder', + icon: , + }, + { + title: 'Billing', + url: '/billing', + icon: , + submenu: [ + { + title: 'Home', + url: '/billing', + icon: , + }, + { + title: 'Invoice Month Cost', + url: '/billing/invoiceMonthCost', + icon: , + }, + { + title: 'Cost By Time', + url: '/billing/costByTime', + icon: , + }, + { + title: 'Seqr Prop Map', + url: '/billing/seqrPropMap', + icon: , + }, + ], + }, + { + title: 'API', + url: '/api', + icon: , + submenu: [ + { + title: 'Swagger', + url: '/swagger', + icon: , + }, + { + title: 'GraphQL', + url: '/graphql', + icon: , + external: true, + }, + ], }, { title: 'Docs', url: '/documentation', icon: , }, - { - title: 'GraphQL', - url: '/graphql', - icon: , - }, - ]) - - React.useEffect(() => { - new BillingApi().getTopics().then((response) => { - if (response.status === 200) { - setMenuItems([...menuItems.slice(0, 2), billingPages, ...menuItems.slice(2)]) - } - }) - }, []) + ] + // const [menuItems, setMenuItems] = React.useState([ + // { + // title: 'Explore', + // url: '/project', + // icon: , + // }, + // { + // title: 'Analysis Runner', + // url: '/analysis-runner', + // icon: , + // }, + // { + // title: 'Cohort Builder', + // url: '/cohort-builder', + // icon: , + // }, + // { + // title: 'Billing', + // url: '/billing', + // icon: , + // submenu: [ + // { + // title: 'Home', + // url: '/billing', + // icon: , + // }, + // { + // title: 'Invoice Month Cost', + // url: '/billing/invoiceMonthCost', + // icon: , + // }, + // { + // title: 'Cost By Time', + // url: '/billing/costByTime', + // icon: , + // }, + // { + // title: 'Seqr Prop Map', + // url: '/billing/seqrPropMap', + // icon: , + // }, + // ], + // }, + // { + // title: 'Swagger', + // url: '/swagger', + // icon: , + // }, + // { + // title: 'Docs', + // url: '/documentation', + // icon: , + // }, + // { + // title: 'GraphQL', + // url: '/graphql', + // icon: , + // }, + // ]) + + // React.useEffect(() => { + // new BillingApi().getTopics().then((response) => { + // if (response.status === 200) { + // setMenuItems([...menuItems.slice(0, 2), billingPages, ...menuItems.slice(2)]) + // } + // }) + // }, []) return (
From 32fac2b24ae9826909f6be99ae2bd21bd97445f9 Mon Sep 17 00:00:00 2001 From: Daniel Esposito <7043686+daniaki@users.noreply.github.com> Date: Wed, 31 Jan 2024 14:00:03 +1100 Subject: [PATCH 058/161] Custom cohorts SG query optimisation (#630) * add assay meta array to type * Fix rendering issues; add column customisation * add additional SG filters to GQL schema * Mod SG SQL query to filter on assay meta and sg timestamps * Add timestamp and assay field to SG model * Add assayMeta to GQL fetch * Use new optimised SG GQL query * Lint fix * Change dict value type to `Any` * Formatting * Only allow meta which is not `None` * White space trim * Add check that new sg is not archived * remove `assay_meta` in favour of nested assay object * Proceduraly add query joins as required * remove `assay_meta` in favour of nested assay object; add more filters * Options to convert/ignore specific fields in `to_sql` * Handle case when `archived` is an `int`; removed fields * Updated queries * mypy/pylint fixes * Add form inputs for remaining fields * npm audit * Remove log * Trim and remove empty values * Simplify cram/gvcf query * Remove merging - not required * Test addtional filters * test new sg filters * lint fix * Change to optional bool * mypy fix * change to `assertEqual` for better debugging * Fail fast if wrong type for `archived` * Add assay meta to detail view --- api/graphql/schema.py | 41 +- db/python/tables/sequencing_group.py | 114 ++- db/python/utils.py | 10 +- models/models/sequencing_group.py | 10 +- test/test_generic_filters.py | 16 + test/test_sequencing_groups.py | 176 +++- web/package-lock.json | 947 +++++++++++++++--- web/src/pages/cohort/AddFromIdListForm.tsx | 15 +- web/src/pages/cohort/AddFromProjectForm.tsx | 146 +-- web/src/pages/cohort/CohortDetailView.tsx | 4 + web/src/pages/cohort/SequencingGroupTable.tsx | 75 +- web/src/pages/cohort/types.ts | 1 + 12 files changed, 1300 insertions(+), 255 deletions(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 6d3d95261..8c040d316 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -738,6 +738,7 @@ async def sample( samples = await slayer.query(filter_) return [GraphQLSample.from_internal(sample) for sample in samples] + # pylint: disable=too-many-arguments @strawberry.field async def sequencing_groups( self, @@ -749,6 +750,10 @@ async def sequencing_groups( technology: GraphQLFilter[str] | None = None, platform: GraphQLFilter[str] | None = None, active_only: GraphQLFilter[bool] | None = None, + created_on: GraphQLFilter[datetime.date] | None = None, + assay_meta: GraphQLMetaFilter | None = None, + has_cram: bool | None = None, + has_gvcf: bool | None = None, ) -> list[GraphQLSequencingGroup]: connection = info.context['connection'] sglayer = SequencingGroupLayer(connection) @@ -766,21 +771,33 @@ async def sequencing_groups( project_id_map = {p.name: p.id for p in projects} filter_ = SequencingGroupFilter( - project=project.to_internal_filter(lambda val: project_id_map[val]) - if project - else None, - sample_id=sample_id.to_internal_filter(sample_id_transform_to_raw) - if sample_id - else None, - id=id.to_internal_filter(sequencing_group_id_transform_to_raw) - if id - else None, + project=( + project.to_internal_filter(lambda val: project_id_map[val]) + if project + else None + ), + sample_id=( + sample_id.to_internal_filter(sample_id_transform_to_raw) + if sample_id + else None + ), + id=( + id.to_internal_filter(sequencing_group_id_transform_to_raw) + if id + else None + ), type=type.to_internal_filter() if type else None, technology=technology.to_internal_filter() if technology else None, platform=platform.to_internal_filter() if platform else None, - active_only=active_only.to_internal_filter() - if active_only - else GenericFilter(eq=True), + active_only=( + active_only.to_internal_filter() + if active_only + else GenericFilter(eq=True) + ), + created_on=created_on.to_internal_filter() if created_on else None, + assay_meta=assay_meta, + has_cram=has_cram, + has_gvcf=has_gvcf, ) sgs = await sglayer.query(filter_) return [GraphQLSequencingGroup.from_internal(sg) for sg in sgs] diff --git a/db/python/tables/sequencing_group.py b/db/python/tables/sequencing_group.py index 1be71e29a..0f315b191 100644 --- a/db/python/tables/sequencing_group.py +++ b/db/python/tables/sequencing_group.py @@ -32,6 +32,13 @@ class SequencingGroupFilter(GenericFilterModel): active_only: GenericFilter[bool] | None = GenericFilter(eq=True) meta: GenericMetaFilter | None = None + # These fields are manually handled in the query to speed things up, because multiple table + # joins and dynamic computation are required. + created_on: GenericFilter[date] | None = None + assay_meta: GenericMetaFilter | None = None + has_cram: bool | None = None + has_gvcf: bool | None = None + def __hash__(self): # pylint: disable=useless-super-delegation return super().__hash__() @@ -69,17 +76,108 @@ async def query( 'platform': 'sg.platform', 'active_only': 'NOT sg.archived', 'external_id': 'sgexid.external_id', + 'created_on': 'DATE(row_start)', + 'assay_meta': 'meta', } - wheres, values = filter_.to_sql(sql_overrides) - _query = f""" - SELECT {self.common_get_keys_str} - FROM sequencing_group sg + # Progressively build up the query and query values based on the filters provided to + # avoid uneccessary joins and improve performance. + _query: list[str] = [] + query_values: dict[str, Any] = {} + # These fields are manually handled in the query + exclude_fields: list[str] = [] + + # Base query + _query.append( + f""" + SELECT + {self.common_get_keys_str} + FROM sequencing_group AS sg LEFT JOIN sample s ON s.id = sg.sample_id - LEFT JOIN sequencing_group_external_id sgexid ON sg.id = sgexid.sequencing_group_id - WHERE {wheres} - """ - rows = await self.connection.fetch_all(_query, values) + LEFT JOIN sequencing_group_external_id sgexid ON sg.id = sgexid.sequencing_group_id""" + ) + + if filter_.assay_meta is not None: + exclude_fields.append('assay_meta') + wheres, values = filter_.to_sql(sql_overrides, only=['assay_meta']) + query_values.update(values) + _query.append( + f""" + INNER JOIN ( + SELECT DISTINCT + sequencing_group_id + FROM + sequencing_group_assay + INNER JOIN ( + SELECT + id + FROM + assay + WHERE + {wheres} + ) AS assay_subquery ON sequencing_group_assay.assay_id = assay_subquery.id + ) AS sga_subquery ON sg.id = sga_subquery.sequencing_group_id + """ + ) + + if filter_.created_on is not None: + exclude_fields.append('created_on') + wheres, values = filter_.to_sql(sql_overrides, only=['created_on']) + query_values.update(values) + _query.append( + f""" + INNER JOIN ( + SELECT + id, + TIMESTAMP(min(row_start)) AS created_on + FROM + sequencing_group FOR SYSTEM_TIME ALL + WHERE + {wheres} + GROUP BY + id + ) AS sg_timequery ON sg.id = sg_timequery.id + """ + ) + + if filter_.has_cram is not None or filter_.has_gvcf is not None: + exclude_fields.extend(['has_cram', 'has_gvcf']) + wheres, values = filter_.to_sql( + sql_overrides, only=['has_cram', 'has_gvcf'] + ) + query_values.update(values) + _query.append( + f""" + INNER JOIN ( + SELECT + sequencing_group_id, + FIND_IN_SET('cram', GROUP_CONCAT(LOWER(anlysis_query.type))) > 0 AS has_cram, + FIND_IN_SET('gvcf', GROUP_CONCAT(LOWER(anlysis_query.type))) > 0 AS has_gvcf + FROM + analysis_sequencing_group + INNER JOIN ( + SELECT + id, type + FROM + analysis + ) AS anlysis_query ON analysis_sequencing_group.analysis_id = anlysis_query.id + GROUP BY + sequencing_group_id + HAVING + {wheres} + ) AS sg_filequery ON sg.id = sg_filequery.sequencing_group_id + """ + ) + + # Add the rest of the filters + wheres, values = filter_.to_sql(sql_overrides, exclude=exclude_fields) + _query.append( + f""" + WHERE {wheres}""" + ) + query_values.update(values) + + rows = await self.connection.fetch_all('\n'.join(_query), query_values) sgs = [SequencingGroupInternal.from_db(**dict(r)) for r in rows] projects = set(sg.project for sg in sgs) return projects, sgs diff --git a/db/python/utils.py b/db/python/utils.py index 1f1d18308..45979c9c1 100644 --- a/db/python/utils.py +++ b/db/python/utils.py @@ -273,7 +273,10 @@ def __post_init__(self): setattr(self, field.name, GenericFilter(eq=value)) def to_sql( - self, field_overrides: dict[str, str] = None + self, + field_overrides: dict[str, str] = None, + only: list[str] | None = None, + exclude: list[str] | None = None, ) -> tuple[str, dict[str, Any]]: """Convert the model to SQL, and avoid SQL injection""" _foverrides = field_overrides or {} @@ -290,6 +293,11 @@ def to_sql( fields = dataclasses.fields(self) conditionals, values = [], {} for field in fields: + if only and field.name not in only: + continue + if exclude and field.name in exclude: + continue + fcolumn = _foverrides.get(field.name, field.name) if filter_ := getattr(self, field.name): if isinstance(filter_, dict): diff --git a/models/models/sequencing_group.py b/models/models/sequencing_group.py index b1b493c8c..382b26ee9 100644 --- a/models/models/sequencing_group.py +++ b/models/models/sequencing_group.py @@ -53,7 +53,15 @@ def from_db(cls, **kwargs): _archived = kwargs.pop('archived', None) if _archived is not None: - _archived = _archived != b'\x00' + if isinstance(_archived, int): + _archived = _archived != 0 + elif isinstance(_archived, bytes): + _archived = _archived != b'\x00' + else: + raise TypeError( + f"Received type '{type(_archived)}' for SequencingGroup column 'archived'. " + + "Allowed types are either 'int' or 'bytes'." + ) return SequencingGroupInternal(**kwargs, archived=_archived, meta=meta) diff --git a/test/test_generic_filters.py b/test/test_generic_filters.py index 2c1348076..a050cce1b 100644 --- a/test/test_generic_filters.py +++ b/test/test_generic_filters.py @@ -24,6 +24,22 @@ def test_basic_no_override(self): self.assertEqual('test_string = :test_string_eq', sql) self.assertDictEqual({'test_string_eq': 'test'}, values) + def test_contains_case_sensitive(self): + """Test that the basic filter converts to SQL as expected""" + filter_ = GenericFilterTest(test_string=GenericFilter(contains='test')) + sql, values = filter_.to_sql() + + self.assertEqual('test_string LIKE :test_string_contains', sql) + self.assertDictEqual({'test_string_contains': '%test%'}, values) + + def test_icontains_is_not_case_sensitive(self): + """Test that the basic filter converts to SQL as expected""" + filter_ = GenericFilterTest(test_string=GenericFilter(icontains='test')) + sql, values = filter_.to_sql() + + self.assertEqual('LOWER(test_string) LIKE LOWER(:test_string_icontains)', sql) + self.assertDictEqual({'test_string_icontains': '%test%'}, values) + def test_basic_override(self): """Test that the basic filter with an override converts to SQL as expected""" filter_ = GenericFilterTest(test_string=GenericFilter(eq='test')) diff --git a/test/test_sequencing_groups.py b/test/test_sequencing_groups.py index d1006b98b..02d03f67e 100644 --- a/test/test_sequencing_groups.py +++ b/test/test_sequencing_groups.py @@ -1,11 +1,15 @@ +from datetime import date + from test.testbase import DbIsolatedTest, run_as_sync from db.python.utils import GenericFilter from db.python.tables.sequencing_group import SequencingGroupFilter -from db.python.layers import SequencingGroupLayer, SampleLayer +from db.python.layers import SequencingGroupLayer, SampleLayer, AnalysisLayer +from models.enums.analysis import AnalysisStatus from models.models import ( SequencingGroupUpsertInternal, AssayUpsertInternal, SampleUpsertInternal, + AnalysisInternal, ) @@ -50,6 +54,7 @@ async def setUp(self) -> None: super().setUp() self.sglayer = SequencingGroupLayer(self.connection) self.slayer = SampleLayer(self.connection) + self.alayer = AnalysisLayer(self.connection) @run_as_sync async def test_insert_sequencing_group(self): @@ -136,5 +141,174 @@ async def test_auto_deprecation_of_old_sequencing_group(self): active_sgs = await self.sglayer.query( SequencingGroupFilter(sample_id=GenericFilter(sample.id)) ) + + self.assertTrue(all(not sg.archived for sg in active_sgs)) self.assertEqual(len(active_sgs), 1) self.assertEqual(updated_sample.sequencing_groups[0].id, active_sgs[0].id) + + @run_as_sync + async def test_query_with_assay_metadata(self): + """Test searching with an assay metadata filter""" + sample_to_insert = get_sample_model() + + # Add extra sequencing group + sample_to_insert.sequencing_groups.append( + SequencingGroupUpsertInternal( + type='exome', + technology='short-read', + platform='ILLUMINA', + meta={ + 'meta-key': 'meta-value', + }, + external_ids={}, + assays=[ + AssayUpsertInternal( + type='sequencing', + external_ids={}, + meta={ + 'sequencing_type': 'exome', + 'sequencing_platform': 'short-read', + 'sequencing_technology': 'illumina', + }, + ) + ], + ) + ) + + # Create in database + sample = await self.slayer.upsert_sample(sample_to_insert) + + # Query for genome assay metadata + sgs = await self.sglayer.query( + SequencingGroupFilter( + assay_meta={'sequencing_type': GenericFilter(eq='genome')} + ) + ) + self.assertEqual(len(sgs), 1) + self.assertEqual(sgs[0].id, sample.sequencing_groups[0].id) + + # Query for exome assay metadata + sgs = await self.sglayer.query( + SequencingGroupFilter( + assay_meta={'sequencing_type': GenericFilter(eq='exome')} + ) + ) + self.assertEqual(len(sgs), 1) + self.assertEqual(sgs[0].id, sample.sequencing_groups[1].id) + + @run_as_sync + async def test_query_with_creation_date(self): + """Test fetching using a creation date filter""" + sample_to_insert = get_sample_model() + await self.slayer.upsert_sample(sample_to_insert) + + # Query for sequencing group with creation date before today + sgs = await self.sglayer.query( + SequencingGroupFilter(created_on=GenericFilter(lt=date.today())) + ) + self.assertEqual(len(sgs), 0) + + # Query for sequencing group with creation date today + sgs = await self.sglayer.query( + SequencingGroupFilter(created_on=GenericFilter(eq=date.today())) + ) + self.assertEqual(len(sgs), 1) + + sgs = await self.sglayer.query( + SequencingGroupFilter(created_on=GenericFilter(lte=date.today())) + ) + self.assertEqual(len(sgs), 1) + + sgs = await self.sglayer.query( + SequencingGroupFilter(created_on=GenericFilter(gte=date.today())) + ) + self.assertEqual(len(sgs), 1) + + # Query for sequencing group with creation date today + sgs = await self.sglayer.query( + SequencingGroupFilter(created_on=GenericFilter(gt=date.today())) + ) + self.assertEqual(len(sgs), 0) + + @run_as_sync + async def test_query_finds_sgs_which_have_cram_analysis(self): + """Test querying for sequencing groups which have a cram or gvcf analysis""" + sample_to_insert = get_sample_model() + + # Add extra sequencing group + sample_to_insert.sequencing_groups.append( + SequencingGroupUpsertInternal( + type='exome', + technology='short-read', + platform='ILLUMINA', + meta={ + 'meta-key': 'meta-value', + }, + external_ids={}, + assays=[ + AssayUpsertInternal( + type='sequencing', + external_ids={}, + meta={ + 'sequencing_type': 'exome', + 'sequencing_platform': 'short-read', + 'sequencing_technology': 'illumina', + }, + ) + ], + ) + ) + + # Create in database + sample = await self.slayer.upsert_sample(sample_to_insert) + + # Create analysis for cram and gvcf + await self.alayer.create_analysis( + AnalysisInternal( + type='cram', + status=AnalysisStatus.COMPLETED, + sequencing_group_ids=[sample.sequencing_groups[0].id], + meta={}, + ) + ) + await self.alayer.create_analysis( + AnalysisInternal( + type='gvcf', + status=AnalysisStatus.COMPLETED, + sequencing_group_ids=[sample.sequencing_groups[1].id], + meta={}, + ) + ) + + # Query for cram analysis + sgs = await self.sglayer.query(SequencingGroupFilter(has_cram=True)) + self.assertEqual(len(sgs), 1) + self.assertEqual(sgs[0].id, sample.sequencing_groups[0].id) + + # Query for gvcf analysis + sgs = await self.sglayer.query(SequencingGroupFilter(has_gvcf=True)) + self.assertEqual(len(sgs), 1) + self.assertEqual(sgs[0].id, sample.sequencing_groups[1].id) + + # Query for both cram AND gvcf analysis + sgs = await self.sglayer.query( + SequencingGroupFilter(has_gvcf=True, has_cram=True) + ) + self.assertEqual(len(sgs), 0) + + # Add first SG to gvcf analysis + await self.alayer.create_analysis( + AnalysisInternal( + type='gvcf', + status=AnalysisStatus.COMPLETED, + sequencing_group_ids=[sample.sequencing_groups[0].id], + meta={}, + ) + ) + + # Query for both cram AND gvcf analysis now that first SG has gvcf analysis + sgs = await self.sglayer.query( + SequencingGroupFilter(has_gvcf=True, has_cram=True) + ) + self.assertEqual(len(sgs), 1) + self.assertEqual(sgs[0].id, sample.sequencing_groups[0].id) diff --git a/web/package-lock.json b/web/package-lock.json index fa3a52c0a..4e8f55872 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -75,8 +75,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.2.0", - "license": "MIT" + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.2.tgz", + "integrity": "sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==" }, "node_modules/@ampproject/remapping": { "version": "2.2.1", @@ -1103,10 +1104,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.21.0", - "license": "MIT", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz", + "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==", "dependencies": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" @@ -1123,6 +1125,11 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime/node_modules/regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, "node_modules/@babel/template": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", @@ -2365,10 +2372,11 @@ } }, "node_modules/@mui/types": { - "version": "7.2.4", - "license": "MIT", + "version": "7.2.10", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.10.tgz", + "integrity": "sha512-wX1vbDC+lzF7FlhT6A3ffRZgEoKWPF8VqRoTu4lZwouFX2t90KyCMsgepMw5DxLak1BSp/KP86CmtZttikb/gQ==", "peerDependencies": { - "@types/react": "*" + "@types/react": "^17.0.0 || ^18.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -2377,12 +2385,12 @@ } }, "node_modules/@mui/utils": { - "version": "5.12.0", - "license": "MIT", + "version": "5.14.20", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.14.20.tgz", + "integrity": "sha512-Y6yL5MoFmtQml20DZnaaK1znrCEwG6/vRSzW8PKOTrzhyqKIql0FazZRUR7sA5EPASgiyKZfq0FPwISRXm5NdA==", "dependencies": { - "@babel/runtime": "^7.21.0", - "@types/prop-types": "^15.7.5", - "@types/react-is": "^16.7.1 || ^17.0.0", + "@babel/runtime": "^7.23.4", + "@types/prop-types": "^15.7.11", "prop-types": "^15.8.1", "react-is": "^18.2.0" }, @@ -2391,10 +2399,16 @@ }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/mui" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@nodelib/fs.scandir": { @@ -2485,8 +2499,9 @@ } }, "node_modules/@popperjs/core": { - "version": "2.11.7", - "license": "MIT", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -2728,114 +2743,407 @@ } }, "node_modules/@swagger-api/apidom-ast": { - "version": "0.69.0", - "license": "Apache-2.0", + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-0.86.0.tgz", + "integrity": "sha512-Q1c5bciMCIGvOx1uZWh567qql2Ef0pCoZOKfhpQ+vKIevfTO85fRBmixyjxv2zETq2UZ1XwsW8q8k0feu1yBjw==", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@types/ramda": "=0.28.23", - "ramda": "=0.28.0", - "ramda-adjunct": "=3.4.0", - "stampit": "=4.3.2", - "unraw": "=2.0.1" + "@swagger-api/apidom-error": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "stampit": "^4.3.2", + "unraw": "^3.0.0" } }, "node_modules/@swagger-api/apidom-core": { - "version": "0.69.2", - "license": "Apache-2.0", + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-0.86.0.tgz", + "integrity": "sha512-HsM6Y5hEDlm8gwO5dSH9QOdtU3H18oVuEZJ/hmC7YCsqrG3EfCD3Y0V1uskuQraaUnyxVGKtgDqUrrWfoWH/sw==", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-ast": "^0.69.0", - "@types/ramda": "=0.28.23", - "minim": "=0.23.8", - "ramda": "=0.28.0", - "ramda-adjunct": "=3.4.0", - "short-unique-id": "=4.4.4", - "stampit": "=4.3.2" + "@swagger-api/apidom-ast": "^0.86.0", + "@swagger-api/apidom-error": "^0.86.0", + "@types/ramda": "~0.29.6", + "minim": "~0.23.8", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "short-unique-id": "^5.0.2", + "stampit": "^4.3.2" + } + }, + "node_modules/@swagger-api/apidom-error": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-0.86.0.tgz", + "integrity": "sha512-nUV91SDdiZ0nzk8o/D7ILToAYRpLNHsXKXnse8yMXmgaDYnQ5cBKQnuOcDOH9PG3HfDfE+MDy/aM8WKvKUzxMg==", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7" } }, "node_modules/@swagger-api/apidom-json-pointer": { - "version": "0.69.2", - "license": "Apache-2.0", + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-0.86.0.tgz", + "integrity": "sha512-iEY16JZeNWFBxy9YimDwGoJ+LL4dvZndd7KLrtT3SN1q/oSbLPc4mc5PsqVQwV3pplYVorGwlL5sZ5BMRRuxEQ==", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-error": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.0.0" + } + }, + "node_modules/@swagger-api/apidom-ns-api-design-systems": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-0.86.0.tgz", + "integrity": "sha512-/oSrDO5YqI4b8a5DbPGV0a5mss3Rdi72vIMlEzElhuX9NkeOI0foEyzhIL/lpjrI0iUmzLk30H0puQU3aspNZA==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-error": "^0.86.0", + "@swagger-api/apidom-ns-openapi-3-1": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "stampit": "^4.3.2" + } + }, + "node_modules/@swagger-api/apidom-ns-asyncapi-2": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-0.86.0.tgz", + "integrity": "sha512-q7ZGjAv1oD8Cs/cJA/jkVgVysrU5T72ItO4LcUiyd6VqfK5f13CjXw5nADPW3ETPwz1uOQ0GO6SEDNlGCsEE3A==", + "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.69.2", - "@types/ramda": "=0.28.23", - "ramda": "=0.28.0", - "ramda-adjunct": "=3.4.0" + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-ns-json-schema-draft-7": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "stampit": "^4.3.2" } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-4": { - "version": "0.69.2", - "license": "Apache-2.0", + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-0.86.0.tgz", + "integrity": "sha512-NELX5IeCYErvTc/rJTkud8YySsaEYY4g7FwnCze8u6VnypVQLD9GPbpSR7rpm/lugx0phoAfcGvHM+mOqt14yQ==", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.69.2", - "@types/ramda": "=0.28.23", - "ramda": "=0.28.0", - "ramda-adjunct": "=3.4.0", - "stampit": "=4.3.2" + "@swagger-api/apidom-ast": "^0.86.0", + "@swagger-api/apidom-core": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "stampit": "^4.3.2" + } + }, + "node_modules/@swagger-api/apidom-ns-json-schema-draft-6": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-0.86.0.tgz", + "integrity": "sha512-ZYfgawZHDtsztiKIFxpTX78ajZWkyNp9+psXv7l91r0TFiuRVJRERmfvtpHE9m0sGHkJEfRcxL3RlZceQ9fohw==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-error": "^0.86.0", + "@swagger-api/apidom-ns-json-schema-draft-4": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "stampit": "^4.3.2" + } + }, + "node_modules/@swagger-api/apidom-ns-json-schema-draft-7": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-0.86.0.tgz", + "integrity": "sha512-EcPCeS/mcgZnZJvHNrqQrdQ1V4miBx55xEcmUpfDebacexlLV9A/OpeL8ttIVJRmuhv4ATiq2/eOKaN7wETB4w==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-error": "^0.86.0", + "@swagger-api/apidom-ns-json-schema-draft-6": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "stampit": "^4.3.2" + } + }, + "node_modules/@swagger-api/apidom-ns-openapi-2": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-0.86.0.tgz", + "integrity": "sha512-IkORhlU8E5VoIYYJ2O+Oe/9JLcI/MLGl6yAsaReK1TZxyK/7tLghbIu6sBfJCAr7Jt1WY6lwWtvJg0ptTZ2zTw==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-error": "^0.86.0", + "@swagger-api/apidom-ns-json-schema-draft-4": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "stampit": "^4.3.2" } }, "node_modules/@swagger-api/apidom-ns-openapi-3-0": { - "version": "0.69.2", - "license": "Apache-2.0", + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-0.86.0.tgz", + "integrity": "sha512-u489LR/E+5q1Hh3fzex4j6wpCBQwmcNy52dF3YSQbz5PTUOIfU4QGR6fh4/3sgublS7eQ84Z6G77Mg/vzZjeCQ==", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.69.2", - "@swagger-api/apidom-ns-json-schema-draft-4": "^0.69.2", - "@types/ramda": "=0.28.23", - "ramda": "=0.28.0", - "ramda-adjunct": "=3.4.0", - "stampit": "=4.3.2" + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-error": "^0.86.0", + "@swagger-api/apidom-ns-json-schema-draft-4": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "stampit": "^4.3.2" } }, "node_modules/@swagger-api/apidom-ns-openapi-3-1": { - "version": "0.69.2", - "license": "Apache-2.0", + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-0.86.0.tgz", + "integrity": "sha512-oYXd0qHxisPh5/SNHWtlAl/g1GtDl+OPrZUp4y6tTHHLc1M4HQ/q0iTcHHdvg+t+m3l7z9wwN8KtvKtwD6EnTw==", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-ast": "^0.86.0", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-ns-openapi-3-0": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "stampit": "^4.3.2" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-json": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-0.86.0.tgz", + "integrity": "sha512-6+dhrsqAm56Vr6rhmheOPQZxQd1Zw9HXD9+JC83sMJUOstH0q73ApdKbwU8ksGYPxIeANUdjQ3oIz0Nj2tBMvw==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-ns-api-design-systems": "^0.86.0", + "@swagger-api/apidom-parser-adapter-json": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-yaml": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-0.86.0.tgz", + "integrity": "sha512-mQTKwIorT1VSa75nsclSUCp5EaovWkuaewZfrOGDUWFhY+++vcnScBdcJv7TBtO2ttTge4UOSu9qgpoSrztXZg==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-ns-api-design-systems": "^0.86.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-2": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-0.86.0.tgz", + "integrity": "sha512-jNtvUJoiI++P3FAQf7X03se+Qx0sUhA5bBSINGMuhjPcSyOAWj9oiPjpB9SYltaqvEb9ek7iPObrt/dx9zj6Ag==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-ns-asyncapi-2": "^0.86.0", + "@swagger-api/apidom-parser-adapter-json": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-0.86.0.tgz", + "integrity": "sha512-A0GTtD6gYPEA3tQQ1A6yw+SceKdDEea3slISVx5bpeDREk8wAl/886EGJICcgFrPO57dUD3HoLqmPn/uUl26mA==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-ns-asyncapi-2": "^0.86.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-json": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-0.86.0.tgz", + "integrity": "sha512-bh5fndjX7JwgkZ0z3tEDknCEFysAs2oSoYiHN8iSLl/MKXBE001tJeJrOdnP9BnrPQSyXAbdT1c1dG3oTnxUgw==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-ast": "^0.86.0", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-error": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "stampit": "^4.3.2", + "tree-sitter": "=0.20.4", + "tree-sitter-json": "=0.20.1", + "web-tree-sitter": "=0.20.3" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-2": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-0.86.0.tgz", + "integrity": "sha512-BULmOvcLnf4QpZ2QFOCrpZnNKLf8sZfzpDPXJm6QwyoZQqAMmeHmEzAY9dE9RrCwNx9lVjumAEoyNf7Hy4qrWw==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-ns-openapi-2": "^0.86.0", + "@swagger-api/apidom-parser-adapter-json": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-0": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-0.86.0.tgz", + "integrity": "sha512-zo/fNkWe9A2AL+cqzt+Z3OiTE5oLEWpLY+Y0tuLWh8YME0ZY7BmR2HYNdWquIhOy5b279QeD19Kv15aY24obxA==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-ns-openapi-3-0": "^0.86.0", + "@swagger-api/apidom-parser-adapter-json": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-1": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-0.86.0.tgz", + "integrity": "sha512-NkFrAyr27Ubwkacv2YolxSN/NciKqJyIEXtAg4SfP/ejTy1Gl+PcT5pZSjQ3doRx1BPp3CF+a2Hsi5HJI6wEzA==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-ns-openapi-3-1": "^0.86.0", + "@swagger-api/apidom-parser-adapter-json": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-2": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-0.86.0.tgz", + "integrity": "sha512-flAGqElCSrVN9XXdA00NWmctOPuqzc+8r15omRvVFZ+Qfzca+FWpyFvzUFr92TKX87XUBALvnu7VA5+g1PftGg==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-ns-openapi-2": "^0.86.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-0.86.0.tgz", + "integrity": "sha512-TT93vbdj6GWhNHU4cTih/93kWJ5l6ZeEyaEQWyd+MhDxgoy6/rCOeblwyMQCgaXL6AmG5qSKTu48Y+GTCqURng==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-ns-openapi-3-0": "^0.86.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-0.86.0.tgz", + "integrity": "sha512-BPNzUdbQbd29YrotIhg/pPZkVXZ8PZOEy9Wy/Aornv9gFZwhzzWE9uOo/HGBDXJqqq5Va1RJkxuYXjIX7BVKBw==", + "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.69.2", - "@swagger-api/apidom-ns-openapi-3-0": "^0.69.2", - "@types/ramda": "=0.28.23", - "ramda": "=0.28.0", - "ramda-adjunct": "=3.4.0", - "stampit": "=4.3.2" + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-ns-openapi-3-1": "^0.86.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-0.86.0.tgz", + "integrity": "sha512-wtvEJFk4uxQbDQH23mjVIeOJJ6IEpiorBNfW/6foPfJbUU7zDE/a0VTEo/wKPxumLe9eLNHuTZSSOvy2y0BmTw==", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-ast": "^0.86.0", + "@swagger-api/apidom-core": "^0.86.0", + "@swagger-api/apidom-error": "^0.86.0", + "@types/ramda": "~0.29.6", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "stampit": "^4.3.2", + "tree-sitter": "=0.20.4", + "tree-sitter-yaml": "=0.5.0", + "web-tree-sitter": "=0.20.3" } }, "node_modules/@swagger-api/apidom-reference": { - "version": "0.69.2", - "license": "Apache-2.0", + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-0.86.0.tgz", + "integrity": "sha512-YjlocO/JkuK1SwGs8ke7AAHecR5w2GyKjWRAGZ06+2ZO8cqV3/0uuuL+laRbYchrFWERqJCUEQre0qJ3BPY7xA==", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.69.2", - "@types/ramda": "=0.28.23", - "axios": "=1.3.4", - "minimatch": "=7.4.3", - "process": "=0.11.10", - "ramda": "=0.28.0", - "ramda-adjunct": "=3.4.0", - "stampit": "=4.3.2" + "@swagger-api/apidom-core": "^0.86.0", + "@types/ramda": "~0.29.6", + "axios": "^1.4.0", + "minimatch": "^7.4.3", + "process": "^0.11.10", + "ramda": "~0.29.1", + "ramda-adjunct": "^4.1.1", + "stampit": "^4.3.2" }, "optionalDependencies": { - "@swagger-api/apidom-json-pointer": "^0.69.2", - "@swagger-api/apidom-ns-asyncapi-2": "^0.69.2", - "@swagger-api/apidom-ns-openapi-3-0": "^0.69.2", - "@swagger-api/apidom-ns-openapi-3-1": "^0.69.2", - "@swagger-api/apidom-parser-adapter-api-design-systems-json": "^0.69.2", - "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "^0.69.2", - "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "^0.69.2", - "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "^0.69.2", - "@swagger-api/apidom-parser-adapter-json": "^0.69.2", - "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "^0.69.2", - "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "^0.69.2", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "^0.69.2", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "^0.69.2", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.69.2" + "@swagger-api/apidom-error": "^0.86.0", + "@swagger-api/apidom-json-pointer": "^0.86.0", + "@swagger-api/apidom-ns-asyncapi-2": "^0.86.0", + "@swagger-api/apidom-ns-openapi-2": "^0.86.0", + "@swagger-api/apidom-ns-openapi-3-0": "^0.86.0", + "@swagger-api/apidom-ns-openapi-3-1": "^0.86.0", + "@swagger-api/apidom-parser-adapter-api-design-systems-json": "^0.86.0", + "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "^0.86.0", + "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "^0.86.0", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "^0.86.0", + "@swagger-api/apidom-parser-adapter-json": "^0.86.0", + "@swagger-api/apidom-parser-adapter-openapi-json-2": "^0.86.0", + "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "^0.86.0", + "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "^0.86.0", + "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "^0.86.0", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "^0.86.0", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "^0.86.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.86.0" } }, "node_modules/@swagger-api/apidom-reference/node_modules/axios": { - "version": "1.3.4", - "license": "MIT", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", + "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -3366,14 +3674,16 @@ "license": "MIT" }, "node_modules/@types/prop-types": { - "version": "15.7.5", - "license": "MIT" + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/ramda": { - "version": "0.28.23", - "license": "MIT", + "version": "0.29.9", + "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.29.9.tgz", + "integrity": "sha512-X3yEG6tQCWBcUAql+RPC/O1Hm9BSU+MXu2wJnCETuAgUlrEDwTA1kIOdEEE4YXDtf0zfQLHa9CCE7WYp9kqPIQ==", "dependencies": { - "ts-toolbelt": "^6.15.1" + "types-ramda": "^0.29.6" } }, "node_modules/@types/react": { @@ -3400,13 +3710,6 @@ "@types/react": "*" } }, - "node_modules/@types/react-is": { - "version": "17.0.3", - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/react-router": { "version": "5.1.20", "license": "MIT", @@ -3433,8 +3736,9 @@ } }, "node_modules/@types/react-transition-group": { - "version": "4.4.5", - "license": "MIT", + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", + "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", "dependencies": { "@types/react": "*" } @@ -3535,9 +3839,10 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { - "version": "7.5.0", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, - "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -3671,9 +3976,10 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.5.0", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, - "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -3726,9 +4032,10 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/semver": { - "version": "7.5.0", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, - "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -4086,7 +4393,8 @@ }, "node_modules/asynckit": { "version": "0.4.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -4233,7 +4541,7 @@ }, "node_modules/bl": { "version": "4.1.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -4300,7 +4608,7 @@ }, "node_modules/buffer": { "version": "5.7.1", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -4489,6 +4797,12 @@ "dev": true, "license": "MIT" }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "optional": true + }, "node_modules/ci-info": { "version": "2.0.0", "license": "MIT" @@ -4618,7 +4932,8 @@ }, "node_modules/combined-stream": { "version": "1.0.8", - "license": "MIT", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -5209,6 +5524,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-equal": { "version": "2.2.0", "license": "MIT", @@ -5288,7 +5618,8 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "engines": { "node": ">=0.4.0" } @@ -5316,6 +5647,15 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "4.0.2", "dev": true, @@ -5483,6 +5823,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enquirer": { "version": "2.3.6", "dev": true, @@ -6109,9 +6458,10 @@ } }, "node_modules/eslint/node_modules/semver": { - "version": "7.5.0", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, - "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -6222,6 +6572,15 @@ "version": "1.2.2", "license": "BSD-3-Clause" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/extend": { "version": "3.0.2", "license": "MIT" @@ -6512,7 +6871,8 @@ }, "node_modules/form-data": { "version": "4.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -6550,6 +6910,12 @@ "node": ">= 14" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "optional": true + }, "node_modules/fs-extra": { "version": "9.1.0", "license": "MIT", @@ -6653,6 +7019,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "optional": true + }, "node_modules/github-slugger": { "version": "2.0.0", "license": "ISC" @@ -6746,8 +7118,9 @@ "license": "MIT" }, "node_modules/graphql": { - "version": "16.6.0", - "license": "MIT", + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", + "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -7171,6 +7544,12 @@ "version": "2.0.4", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "optional": true + }, "node_modules/inline-style-parser": { "version": "0.1.1", "license": "MIT" @@ -8892,14 +9271,16 @@ }, "node_modules/mime-db": { "version": "1.52.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { "version": "2.1.35", - "license": "MIT", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dependencies": { "mime-db": "1.52.0" }, @@ -8915,6 +9296,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "license": "MIT", @@ -8949,6 +9342,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "optional": true + }, "node_modules/mri": { "version": "1.2.0", "license": "MIT", @@ -8965,15 +9364,22 @@ "dev": true, "license": "ISC" }, + "node_modules/nan": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz", + "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==", + "optional": true + }, "node_modules/nanoid": { - "version": "3.3.6", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -8981,6 +9387,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "dev": true, @@ -9004,6 +9416,51 @@ "tslib": "^2.0.3" } }, + "node_modules/node-abi": { + "version": "3.52.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.52.0.tgz", + "integrity": "sha512-JJ98b02z16ILv7859irtXn4oUaFWADtvkzy2c0IAatNVX2Mc9Yoh8z6hZInn3QwvMEYhHuQloYi+TTQy67SIdQ==", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "optional": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "optional": true + }, "node_modules/node-addon-api": { "version": "3.2.1", "dev": true, @@ -9489,8 +9946,9 @@ } }, "node_modules/patch-package/node_modules/semver": { - "version": "5.7.1", - "license": "ISC", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "bin": { "semver": "bin/semver" } @@ -9611,7 +10069,9 @@ } }, "node_modules/postcss": { - "version": "8.4.23", + "version": "8.4.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", + "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", "funding": [ { "type": "opencollective", @@ -9626,9 +10086,8 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "nanoid": "^3.3.6", + "nanoid": "^3.3.7", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -9641,6 +10100,32 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" }, + "node_modules/prebuild-install": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", + "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -9757,7 +10242,18 @@ }, "node_modules/proxy-from-env": { "version": "1.1.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } }, "node_modules/punycode": { "version": "1.4.1", @@ -9823,16 +10319,18 @@ "license": "MIT" }, "node_modules/ramda": { - "version": "0.28.0", - "license": "MIT", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz", + "integrity": "sha512-OfxIeWzd4xdUNxlWhgFazxsA/nl3mS4/jGZI5n00uWOoSSFRhC1b6gl6xvmzUamgmqELraWp0J/qqVlXYPDPyA==", "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" } }, "node_modules/ramda-adjunct": { - "version": "3.4.0", - "license": "BSD-3-Clause", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-4.1.1.tgz", + "integrity": "sha512-BnCGsZybQZMDGram9y7RiryoRHS5uwx8YeGuUeDKuZuvK38XO6JJfmK85BwRWAKFA6pZ5nZBO/HBFtExVaf31w==", "engines": { "node": ">=0.10.3" }, @@ -9841,7 +10339,7 @@ "url": "https://opencollective.com/ramda-adjunct" }, "peerDependencies": { - "ramda": ">= 0.28.0 <= 0.28.0" + "ramda": ">= 0.29.0" } }, "node_modules/randexp": { @@ -9862,6 +10360,30 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "17.0.2", "license": "MIT", @@ -10173,7 +10695,7 @@ }, "node_modules/readable-stream": { "version": "3.6.2", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -10671,8 +11193,9 @@ } }, "node_modules/semver": { - "version": "6.3.0", - "license": "ISC", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "bin": { "semver": "bin/semver.js" } @@ -10767,8 +11290,9 @@ } }, "node_modules/short-unique-id": { - "version": "4.4.4", - "license": "Apache-2.0", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/short-unique-id/-/short-unique-id-5.0.3.tgz", + "integrity": "sha512-yhniEILouC0s4lpH0h7rJsfylZdca10W9mDJRAFh3EpcSUanCHGb0R7kcFOIUCZYSAPo0PUD5ZxWQdW0T4xaug==", "bin": { "short-unique-id": "bin/short-unique-id", "suid": "bin/short-unique-id" @@ -10796,6 +11320,51 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slash": { "version": "3.0.0", "dev": true, @@ -10900,7 +11469,7 @@ }, "node_modules/string_decoder": { "version": "1.3.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -11248,6 +11817,34 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/terser": { "version": "5.17.1", "devOptional": true, @@ -11341,6 +11938,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tree-sitter": { + "version": "0.20.4", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.20.4.tgz", + "integrity": "sha512-rjfR5dc4knG3jnJNN/giJ9WOoN1zL/kZyrS0ILh+eqq8RNcIbiXA63JsMEgluug0aNvfQvK4BfCErN1vIzvKog==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "nan": "^2.17.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/tree-sitter-json": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.20.1.tgz", + "integrity": "sha512-482hf7J+aBwhksSw8yWaqI8nyP1DrSwnS4IMBShsnkFWD3SE8oalHnsEik59fEVi3orcTCUtMzSjZx+0Tpa6Vw==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "nan": "^2.18.0" + } + }, + "node_modules/tree-sitter-yaml": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/tree-sitter-yaml/-/tree-sitter-yaml-0.5.0.tgz", + "integrity": "sha512-POJ4ZNXXSWIG/W4Rjuyg36MkUD4d769YRUGKRqN+sVaj/VCo6Dh6Pkssn1Rtewd5kybx+jT1BWMyWN0CijXnMA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "nan": "^2.14.0" + } + }, "node_modules/trough": { "version": "2.1.0", "license": "MIT", @@ -11418,8 +12046,9 @@ } }, "node_modules/ts-toolbelt": { - "version": "6.15.5", - "license": "Apache-2.0" + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==" }, "node_modules/tsconfig-paths": { "version": "3.14.2", @@ -11466,6 +12095,18 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -11501,6 +12142,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/types-ramda": { + "version": "0.29.6", + "resolved": "https://registry.npmjs.org/types-ramda/-/types-ramda-0.29.6.tgz", + "integrity": "sha512-VJoOk1uYNh9ZguGd3eZvqkdhD4hTGtnjRBUx5Zc0U9ftmnCgiWcSj/lsahzKunbiwRje1MxxNkEy1UdcXRCpYw==", + "dependencies": { + "ts-toolbelt": "^9.6.0" + } + }, "node_modules/typescript": { "version": "4.9.5", "dev": true, @@ -11666,8 +12315,9 @@ } }, "node_modules/unraw": { - "version": "2.0.1", - "license": "MIT" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz", + "integrity": "sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==" }, "node_modules/update-browserslist-db": { "version": "1.0.11", @@ -11766,7 +12416,7 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/uvu": { @@ -11993,6 +12643,12 @@ "node": ">= 8" } }, + "node_modules/web-tree-sitter": { + "version": "0.20.3", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.20.3.tgz", + "integrity": "sha512-zKGJW9r23y3BcJusbgvnOH2OYAW40MXAOi9bi3Gcc7T4Gms9WWgXF8m6adsJWpGJEhgOzCrfiz1IzKowJWrtYw==", + "optional": true + }, "node_modules/web-vitals": { "version": "1.1.2", "license": "Apache-2.0" @@ -12086,9 +12742,10 @@ } }, "node_modules/word-wrap": { - "version": "1.2.3", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } diff --git a/web/src/pages/cohort/AddFromIdListForm.tsx b/web/src/pages/cohort/AddFromIdListForm.tsx index d1ea79188..805b7bcf3 100644 --- a/web/src/pages/cohort/AddFromIdListForm.tsx +++ b/web/src/pages/cohort/AddFromIdListForm.tsx @@ -16,6 +16,9 @@ query FetchSequencingGroupsById($ids: [String!]!) { type technology platform + assays { + meta + } sample { project { id @@ -50,7 +53,16 @@ const AddFromIdListForm: React.FC = ({ onAdd }) => { return } - const ids = element.value.trim().split(',') + const ids = element.value + .trim() + .split(',') + .map((id) => id.trim()) + .filter((id) => !!id) + + if (ids.length === 0) { + return + } + fetchSequencingGroups({ variables: { ids }, onError: () => { @@ -60,6 +72,7 @@ const AddFromIdListForm: React.FC = ({ onAdd }) => { setSequencingGroups( hits.sequencingGroups.map((sg) => ({ ...sg, + assayMeta: (sg?.assays ?? []).map((a) => a.meta), project: sg.sample.project, })) ), diff --git a/web/src/pages/cohort/AddFromProjectForm.tsx b/web/src/pages/cohort/AddFromProjectForm.tsx index f13b59e3d..1da3578d4 100644 --- a/web/src/pages/cohort/AddFromProjectForm.tsx +++ b/web/src/pages/cohort/AddFromProjectForm.tsx @@ -1,7 +1,6 @@ import React, { useState, useContext } from 'react' import { Container, Form, Message } from 'semantic-ui-react' import { useLazyQuery } from '@apollo/client' -import { uniq } from 'lodash' import { gql } from '../../__generated__/gql' import { ThemeContext } from '../../shared/components/ThemeProvider' @@ -18,27 +17,29 @@ query FetchSequencingGroups( $technology: String, $seqType: String, $assayMeta: JSON, + $createdOn: DateGraphQLFilter, + $hasCram: Boolean, + $hasGvcf: Boolean, $excludeIds: [String!] - ) { - project(name: $project) { - participants { - samples { - assays(meta: $assayMeta) { - sample { - sequencingGroups( - id: {nin: $excludeIds} - platform: {icontains: $platform} - technology: {icontains: $technology} - type: {contains: $seqType} - ) { - id - type - technology - platform - } - } - } - } +) { + sequencingGroups( + id: {nin: $excludeIds} + project: {eq: $project} + platform: {icontains: $platform} + technology: {icontains: $technology} + type: {contains: $seqType} + assayMeta: $assayMeta, + createdOn: $createdOn, + hasCram: $hasCram, + hasGvcf: $hasGvcf, + activeOnly: {eq: true} + ) { + id + type + technology + platform + assays { + meta } } } @@ -47,7 +48,7 @@ query FetchSequencingGroups( // NOTE: Put additional objects here to add more search fields for assay metadata const assayMetaSearchFields = [ { label: 'Batch', id: 'batch', searchVariable: 'batch' }, - // { label: 'Emoji', id: 'emoji', searchVariable: 'emoji' }, + { label: 'Coverage', id: 'coverage', searchVariable: 'coverage' }, ] interface IAddFromProjectForm { @@ -66,11 +67,6 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) const [searchSquencingGroups, { loading, error }] = useLazyQuery(GET_SEQUENCING_GROUPS_QUERY) const search = () => { - const seqTypeInput = document.getElementById('seq_type') as HTMLInputElement - const technologyInput = document.getElementById('technology') as HTMLInputElement - const platformInput = document.getElementById('platform') as HTMLInputElement - const excludeInput = document.getElementById('exclude') as HTMLInputElement - if (!selectedProject?.name) { return } @@ -80,22 +76,29 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) seqType: null, technology: null, platform: null, + createdOn: null, + hasCram: null, + hasGvcf: null, excludeIds: [], assayMeta: {}, } + const seqTypeInput = document.getElementById('seq_type') as HTMLInputElement if (seqTypeInput?.value) { searchParams.seqType = seqTypeInput.value } + const technologyInput = document.getElementById('technology') as HTMLInputElement if (technologyInput?.value) { searchParams.technology = technologyInput.value } + const platformInput = document.getElementById('platform') as HTMLInputElement if (platformInput?.value) { searchParams.platform = platformInput.value } + const excludeInput = document.getElementById('exclude') as HTMLInputElement if (excludeInput?.value && excludeInput.value.trim().length > 0) { searchParams.excludeIds = excludeInput.value .split(',') @@ -110,12 +113,34 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) } }) + const createdAfterInput = document.getElementById('created_after') as HTMLInputElement + const createdBeforeInput = document.getElementById('created_before') as HTMLInputElement + if (createdAfterInput?.value) { + searchParams.createdOn = { gte: createdAfterInput.value } + } + if (createdBeforeInput?.value) { + searchParams.createdOn = { ...searchParams.createdOn, lte: createdBeforeInput.value } + } + + const hasCramInput = document.getElementById('has_cram') as HTMLInputElement + if (hasCramInput?.checked) { + searchParams.hasCram = true + } + + const hasGvcfInput = document.getElementById('has_gvcf') as HTMLInputElement + if (hasGvcfInput?.checked) { + searchParams.hasGvcf = true + } + searchSquencingGroups({ variables: { project: selectedProject.name, seqType: searchParams.seqType, technology: searchParams.technology, platform: searchParams.platform, + createdOn: searchParams.createdOn, + hasCram: searchParams.hasCram, + hasGvcf: searchParams.hasGvcf, assayMeta: searchParams.assayMeta, excludeIds: searchParams.excludeIds, }, @@ -123,44 +148,15 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) setSearchHits(null) }, onCompleted: (hits) => { - const samples = hits.project.participants.flatMap((p) => p.samples) - const assays = samples.flatMap((s) => s.assays) - - const sgs = assays.flatMap((a) => - a.sample.sequencingGroups.map((sg) => ({ - id: sg.id, - type: sg.type, - technology: sg.technology, - platform: sg.platform, - project: { id: selectedProject.id, name: selectedProject.name }, - })) - ) - - // Remove duplicates by merging string fields with same id - const merged: SequencingGroup[] = [] - const seen = new Set() - sgs.forEach((sg) => { - if (!seen.has(sg.id)) { - merged.push(sg) - seen.add(sg.id) - } else { - const existing = merged.find((e) => e.id === sg.id) - if (existing) { - existing.type = `${existing.type}|${sg.type}` - existing.technology = `${existing.technology}|${sg.technology}` - existing.platform = `${existing.platform}|${sg.platform}` - } - } - }) - - setSearchHits( - merged.map((sg) => ({ - ...sg, - type: uniq(sg.type.split('|')).sort().join(' | '), - technology: uniq(sg.technology.split('|')).sort().join(' | '), - platform: uniq(sg.platform.split('|')).sort().join(' | '), - })) - ) + const sgs = hits.sequencingGroups.map((sg) => ({ + id: sg.id, + type: sg.type, + technology: sg.technology, + platform: sg.platform, + assayMeta: (sg?.assays ?? []).map((a) => a.meta), + project: { id: selectedProject.id, name: selectedProject.name }, + })) + setSearchHits(sgs) }, }) } @@ -219,8 +215,26 @@ const AddFromProjectForm: React.FC = ({ projects, onAdd }) label="Exclude Sequencing Group IDs" id="exclude" /> + + + + + + + + + -
{ technology: sg.technology, platform: sg.platform, project: sg.sample.project, + assayMeta: (sg.assays ?? []).map((a) => a.meta), }))} editable={false} /> diff --git a/web/src/pages/cohort/SequencingGroupTable.tsx b/web/src/pages/cohort/SequencingGroupTable.tsx index 6bfcc6649..86ba06027 100644 --- a/web/src/pages/cohort/SequencingGroupTable.tsx +++ b/web/src/pages/cohort/SequencingGroupTable.tsx @@ -26,8 +26,12 @@ const SequencingGroupTable: React.FC = ({ const [searchTerms, setSearchTerms] = useState<{ column: string; term: string }[]>([]) const setSortInformation = (column: string) => { + if (column === sortColumn) { + setSortDirection(sortDirection === 'ascending' ? 'descending' : 'ascending') + return + } + setSortDirection('descending') setSortColumn(column) - setSortDirection(sortDirection === 'ascending' ? 'descending' : 'ascending') } const setSearchInformation = (column: string, term: string) => { @@ -48,7 +52,7 @@ const SequencingGroupTable: React.FC = ({ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - const value: any = sg[column] + const value: any = column === 'assayMeta' ? JSON.stringify(sg[column]) : sg[column] return value.toString().toLowerCase().includes(term.toLowerCase()) }) }) @@ -57,11 +61,12 @@ const SequencingGroupTable: React.FC = ({ sortedRows = sortDirection === 'ascending' ? sortedRows : sortedRows.reverse() const tableColumns = [ - { key: 'id', name: 'ID' }, - { key: 'project', name: 'Project' }, - { key: 'type', name: 'Type' }, - { key: 'technology', name: 'Technology' }, - { key: 'platform', name: 'Platform' }, + { key: 'id', name: 'ID', filterable: true, sortable: true, minWidth: 100 }, + { key: 'project', name: 'Project', filterable: true, sortable: true, minWidth: 100 }, + { key: 'type', name: 'Type', filterable: true, sortable: true, minWidth: 50 }, + { key: 'technology', name: 'Technology', filterable: true, sortable: true, minWidth: 100 }, + { key: 'platform', name: 'Platform', filterable: true, sortable: true, minWidth: 150 }, + { key: 'assayMeta', name: 'Assay Meta', filterable: true, sortable: false, minWidth: 300 }, ] const renderTableBody = () => { @@ -76,7 +81,7 @@ const SequencingGroupTable: React.FC = ({ if (!sequencingGroups.length) { return ( - {emptyMessage} + {emptyMessage} ) } @@ -95,13 +100,18 @@ const SequencingGroupTable: React.FC = ({ {sg.type} {sg.technology} {sg.platform} + +
+
{JSON.stringify(sg.assayMeta, null, 2)}
+
+
)) } return (
- +
= ({ {tableColumns.map((column) => ( + e.target.tagName !== 'INPUT' && + column.sortable && + setSortInformation(column.key) + } > -
setSortInformation(column.key)}> - {column.name} -
- - setSearchInformation(column.key, e.target.value) - } - placeholder="search..." - /> + +
+ {column.name} + {column.sortable && sortColumn === column.key && ( + + )} +
+
+ {column.filterable && ( + { + setSearchInformation(column.key, e.target.value) + }} + placeholder="search..." + /> + )} +
+
))} diff --git a/web/src/pages/cohort/types.ts b/web/src/pages/cohort/types.ts index 3ebc2a1e6..0005e4dee 100644 --- a/web/src/pages/cohort/types.ts +++ b/web/src/pages/cohort/types.ts @@ -4,6 +4,7 @@ export interface SequencingGroup { technology: string platform: string project: { id: number; name: string } + assayMeta: object[] } export interface Project { From dcd5e68e1ba449584357cc8aa4181ab6a60fbdc1 Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 31 Jan 2024 15:09:01 +1100 Subject: [PATCH 059/161] update dbbase import --- db/python/tables/cohort.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 94ef9bc95..e16809227 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -2,7 +2,7 @@ import dataclasses import datetime -from db.python.connect import DbBase +from db.python.tables.base import DbBase from db.python.tables.project import ProjectId from db.python.utils import GenericFilter, GenericFilterModel from models.models.cohort import Cohort From 9ef10ef721b8e0fb3dae192a5330b629edf9299c Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 5 Feb 2024 11:33:11 +1100 Subject: [PATCH 060/161] Update to_sql signature to match superclass. An attempt at pleasing the linter --- db/python/tables/bq/generic_bq_filter_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/python/tables/bq/generic_bq_filter_model.py b/db/python/tables/bq/generic_bq_filter_model.py index c2736cc3a..da70d2e79 100644 --- a/db/python/tables/bq/generic_bq_filter_model.py +++ b/db/python/tables/bq/generic_bq_filter_model.py @@ -70,7 +70,7 @@ def __post_init__(self): setattr(self, field.name, GenericBQFilter(eq=value)) def to_sql( - self, field_overrides: dict[str, Any] = None + self, field_overrides: dict[str, str] = None, only: list[str] | None = None, exclude: list[str] | None = None, ) -> tuple[str, dict[str, Any]]: """Convert the model to SQL, and avoid SQL injection""" _foverrides = field_overrides or {} From f82f8169ff2379dd229eebecb7450a67c83fd43c Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 9 Feb 2024 08:22:50 +1100 Subject: [PATCH 061/161] Fix click overwriting param to None when not specified --- scripts/parse_existing_cohort.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/parse_existing_cohort.py b/scripts/parse_existing_cohort.py index 2603a8d60..8e5a3a807 100644 --- a/scripts/parse_existing_cohort.py +++ b/scripts/parse_existing_cohort.py @@ -216,6 +216,7 @@ def get_existing_external_sequence_ids(self, participant_map: dict[str, dict]): '--sequencing-type', type=click.Choice(['genome', 'exome']), help='Sequencing type: genome or exome', + default='genome', ) @click.option('--search-location', 'search_locations', multiple=True) @click.option( @@ -239,11 +240,11 @@ async def main( project: str, search_locations: List[str], batch_number: Optional[str], + sequencing_type:str, confirm=True, dry_run=False, include_participant_column=False, allow_missing_files=False, - sequencing_type: str = 'genome', ): """Run script from CLI arguments""" From 9e33d7fd28673c9d70cb03ba911cfbde8847a0cb Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 16 Feb 2024 14:38:50 +1100 Subject: [PATCH 062/161] Create endpoint to create cohort from criteria - Project/s --- api/routes/cohort.py | 40 ++++++++++++++++++++++++++++++++++++- db/python/layers/cohort.py | 41 ++++++++++++++++++++++++++++++++++---- db/python/tables/cohort.py | 5 ++--- 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 6cfc3ebfe..48a6af50d 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -6,6 +6,8 @@ from api.utils.db import Connection, get_project_write_connection from db.python.layers.cohort import CohortLayer +from db.python.tables.project import ProjectPermissionsTable + router = APIRouter(prefix='/cohort', tags=['cohort']) @@ -14,10 +16,15 @@ class CohortBody(BaseModel): name: str description: str - sequencing_group_ids: list[str] derived_from: int | None = None +class CohortCriteria(BaseModel): + """Represents the expected JSON body of the create cohort request""" + + projects: list[str] + + @router.post('/{project}/', operation_id='createCohort') async def create_cohort( cohort: CohortBody, @@ -41,3 +48,34 @@ async def create_cohort( ) return {'cohort_id': cohort_id} + + +@router.post('/{project}/cohort', operation_id='createCohortFromCriteria') +async def create_cohort_from_criteria( + cohort_spec: CohortBody, + cohort_criteria: CohortCriteria, + connection: Connection = get_project_write_connection, +) -> dict[str, Any]: + """ + Create a cohort with the given name and sample/sequencing group IDs. + """ + cohortlayer = CohortLayer(connection) + + if not connection.project: + raise ValueError('A cohort must belong to a project') + + pt = ProjectPermissionsTable(connection) + projects_to_pull = await pt.get_and_check_access_to_projects_for_names( + user=connection.author, project_names=cohort_criteria.projects, readonly=True + ) + projects_to_pull = [p.id for p in projects_to_pull] + + cohort_id = await cohortlayer.create_cohort_from_criteria( + project_to_write=connection.project, + projects_to_pull=projects_to_pull, + description=cohort_spec.description, + author=connection.author, + cohort_name=cohort_spec.name, + ) + + return {'cohort_id': cohort_id} diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 56aac77c5..9dbc0e04b 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -4,8 +4,12 @@ from db.python.tables.cohort import CohortFilter, CohortTable from db.python.tables.project import ProjectId from db.python.tables.sample import SampleTable +from db.python.tables.sequencing_group import SequencingGroupTable from db.python.utils import get_logger from models.models.cohort import Cohort +from db.python.tables.sequencing_group import SequencingGroupFilter +from db.python.layers.sequencing_group import SequencingGroupLayer +from db.python.utils import GenericFilter logger = get_logger() @@ -19,8 +23,8 @@ def __init__(self, connection: Connection): self.sampt = SampleTable(connection) self.at = AnalysisTable(connection) self.ct = CohortTable(connection) - - # GETS + self.sgt = SequencingGroupTable(connection) + self.sglayer = SequencingGroupLayer(self.connection) async def query(self, filter_: CohortFilter) -> list[Cohort]: """Query Cohorts""" @@ -33,8 +37,6 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: """ return await self.ct.get_cohort_sequencing_group_ids(cohort_id) - # PUTS - async def create_cohort( self, project: ProjectId, @@ -58,3 +60,34 @@ async def create_cohort( ) return cohort_id + + async def create_cohort_from_criteria( + self, + project_to_write: ProjectId, + projects_to_pull: list[ProjectId], + author: str, + description: str, + cohort_name: str, + ): + """ + Create a new cohort from the given parameters. Returns the newly created cohort_id. + """ + + # 1. Pull SG's based on criteria + sgs = await self.sglayer.query( + SequencingGroupFilter( + project=GenericFilter(in_=projects_to_pull) + ) + ) + print(sgs) + + # 2. Create Cohort + cohort_id = await self.ct.create_cohort( + project=project_to_write, + cohort_name=cohort_name, + sequencing_group_ids=[sg.id for sg in sgs], + description=description, + author=author, + ) + + return cohort_id diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index e16809227..d4aed6bbf 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -6,7 +6,6 @@ from db.python.tables.project import ProjectId from db.python.utils import GenericFilter, GenericFilterModel from models.models.cohort import Cohort -from models.utils.sequencing_group_id_format import sequencing_group_id_transform_to_raw @dataclasses.dataclass(kw_only=True) @@ -70,7 +69,7 @@ async def create_cohort( self, project: int, cohort_name: str, - sequencing_group_ids: list[str], + sequencing_group_ids: list[int], author: str, description: str, derived_from: int | None = None, @@ -108,7 +107,7 @@ async def create_cohort( _query, { 'cohort_id': cohort_id, - 'sequencing_group_id': sequencing_group_id_transform_to_raw(sg), + 'sequencing_group_id': sg, }, ) From 3fdc803679863bb55686a502e3db990e1b3d2890 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 16 Feb 2024 14:43:09 +1100 Subject: [PATCH 063/161] Fix query cohort, call connection directly --- api/graphql/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index ad7236220..baa20f3c3 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -697,7 +697,7 @@ async def cohort( connection = info.context['connection'] clayer = CohortLayer(connection) - ptable = ProjectPermissionsTable(connection.connection) + ptable = ProjectPermissionsTable(connection) project_name_map: dict[str, int] = {} project_filter = None if project: From 558b073f434058997a9d9bc30573e55d804ef555 Mon Sep 17 00:00:00 2001 From: vivbak Date: Thu, 22 Feb 2024 11:43:24 +1100 Subject: [PATCH 064/161] Add further criteria to create_cohort_from_criteria endpoint * Also deletes redundant create_cohort endpoint --- api/routes/cohort.py | 37 ++++++++++++------------------------- db/python/layers/cohort.py | 35 ++++++++++------------------------- 2 files changed, 22 insertions(+), 50 deletions(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 48a6af50d..50cdaa2ed 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -8,6 +8,10 @@ from db.python.tables.project import ProjectPermissionsTable +from models.utils.sequencing_group_id_format import ( + sequencing_group_id_transform_to_raw_list, +) + router = APIRouter(prefix='/cohort', tags=['cohort']) @@ -23,31 +27,10 @@ class CohortCriteria(BaseModel): """Represents the expected JSON body of the create cohort request""" projects: list[str] - - -@router.post('/{project}/', operation_id='createCohort') -async def create_cohort( - cohort: CohortBody, - connection: Connection = get_project_write_connection, -) -> dict[str, Any]: - """ - Create a cohort with the given name and sample/sequencing group IDs. - """ - cohortlayer = CohortLayer(connection) - - if not connection.project: - raise ValueError('A cohort must belong to a project') - - cohort_id = await cohortlayer.create_cohort( - project=connection.project, - cohort_name=cohort.name, - derived_from=cohort.derived_from, - description=cohort.description, - author=connection.author, - sequencing_group_ids=cohort.sequencing_group_ids, - ) - - return {'cohort_id': cohort_id} + sg_ids_internal: list[str] | None = None + sg_technology: list[str] | None = None + sg_platform: list[str] | None = None + sg_type: list[str] | None = None @router.post('/{project}/cohort', operation_id='createCohortFromCriteria') @@ -76,6 +59,10 @@ async def create_cohort_from_criteria( description=cohort_spec.description, author=connection.author, cohort_name=cohort_spec.name, + sg_ids_internal=sequencing_group_id_transform_to_raw_list(cohort_criteria.sg_ids_internal), + sg_technology=cohort_criteria.sg_technology, + sg_platform=cohort_criteria.sg_platform, + sg_type=cohort_criteria.sg_type, ) return {'cohort_id': cohort_id} diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 9dbc0e04b..08bb1286a 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -37,30 +37,6 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: """ return await self.ct.get_cohort_sequencing_group_ids(cohort_id) - async def create_cohort( - self, - project: ProjectId, - cohort_name: str, - sequencing_group_ids: list[str], - author: str, - description: str, - derived_from: int | None = None, - ) -> int: - """ - Create a new cohort from the given parameters. Returns the newly created cohort_id. - """ - - cohort_id = await self.ct.create_cohort( - project=project, - cohort_name=cohort_name, - sequencing_group_ids=sequencing_group_ids, - description=description, - author=author, - derived_from=derived_from, - ) - - return cohort_id - async def create_cohort_from_criteria( self, project_to_write: ProjectId, @@ -68,15 +44,24 @@ async def create_cohort_from_criteria( author: str, description: str, cohort_name: str, + sg_ids_internal: list[int] | None = None, + sg_technology: list[str] | None = None, + sg_platform: list[str] | None = None, + sg_type: list[str] | None = None, ): """ Create a new cohort from the given parameters. Returns the newly created cohort_id. """ # 1. Pull SG's based on criteria + sgs = await self.sglayer.query( SequencingGroupFilter( - project=GenericFilter(in_=projects_to_pull) + project=GenericFilter(in_=projects_to_pull), + id=GenericFilter(in_=sg_ids_internal) if sg_ids_internal else None, + technology=GenericFilter(in_=sg_technology) if sg_technology else None, + platform=GenericFilter(in_=sg_platform) if sg_platform else None, + type=GenericFilter(in_=sg_type) if sg_type else None, ) ) print(sgs) From 3b00f6c9df327f1880a360fa3c14ece0e864be25 Mon Sep 17 00:00:00 2001 From: vivbak Date: Thu, 22 Feb 2024 11:59:19 +1100 Subject: [PATCH 065/161] Fix the linter, import order, spaces, etc --- db/python/layers/cohort.py | 13 +++++++------ scripts/parse_existing_cohort.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 08bb1286a..7c501b0b0 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -1,15 +1,16 @@ +from models.models.cohort import Cohort from db.python.connect import Connection + from db.python.layers.base import BaseLayer +from db.python.layers.sequencing_group import SequencingGroupLayer + from db.python.tables.analysis import AnalysisTable from db.python.tables.cohort import CohortFilter, CohortTable from db.python.tables.project import ProjectId from db.python.tables.sample import SampleTable -from db.python.tables.sequencing_group import SequencingGroupTable -from db.python.utils import get_logger -from models.models.cohort import Cohort -from db.python.tables.sequencing_group import SequencingGroupFilter -from db.python.layers.sequencing_group import SequencingGroupLayer -from db.python.utils import GenericFilter +from db.python.tables.sequencing_group import SequencingGroupTable, SequencingGroupFilter + +from db.python.utils import GenericFilter, get_logger logger = get_logger() diff --git a/scripts/parse_existing_cohort.py b/scripts/parse_existing_cohort.py index 8e5a3a807..1df35063c 100644 --- a/scripts/parse_existing_cohort.py +++ b/scripts/parse_existing_cohort.py @@ -240,7 +240,7 @@ async def main( project: str, search_locations: List[str], batch_number: Optional[str], - sequencing_type:str, + sequencing_type: str, confirm=True, dry_run=False, include_participant_column=False, From ae3c271cfe71ad477f70486cab145c6cdc1df3a0 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 26 Feb 2024 12:06:06 +1100 Subject: [PATCH 066/161] Remove AddFromIdListForm.tsx --- web/src/pages/cohort/AddFromIdListForm.tsx | 148 --------------------- 1 file changed, 148 deletions(-) delete mode 100644 web/src/pages/cohort/AddFromIdListForm.tsx diff --git a/web/src/pages/cohort/AddFromIdListForm.tsx b/web/src/pages/cohort/AddFromIdListForm.tsx deleted file mode 100644 index 805b7bcf3..000000000 --- a/web/src/pages/cohort/AddFromIdListForm.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, { useContext, useState } from 'react' -import { Form, Message } from 'semantic-ui-react' -import { useLazyQuery } from '@apollo/client' - -import { gql } from '../../__generated__' -import { ThemeContext } from '../../shared/components/ThemeProvider' - -import { SequencingGroup } from './types' -import SequencingGroupTable from './SequencingGroupTable' -import MuckError from '../../shared/components/MuckError' - -const GET_SEQUENCING_GROUPS_QUERY = gql(` -query FetchSequencingGroupsById($ids: [String!]!) { - sequencingGroups(id: {in_: $ids}) { - id - type - technology - platform - assays { - meta - } - sample { - project { - id - name - } - } - } - } -`) - -interface IAddFromIdListForm { - onAdd: (sequencingGroups: SequencingGroup[]) => void -} - -const AddFromIdListForm: React.FC = ({ onAdd }) => { - const { theme } = useContext(ThemeContext) - const inverted = theme === 'dark-mode' - - const [text, setText] = useState('') - const [sequencingGroups, setSequencingGroups] = useState(null) - - const [fetchSequencingGroups, { loading, error }] = useLazyQuery(GET_SEQUENCING_GROUPS_QUERY) - - const search = () => { - const element = document.getElementById('sequencing-group-ids-csv') as HTMLInputElement - - if (!element == null) { - return - } - - if (!element?.value || !element.value.trim()) { - return - } - - const ids = element.value - .trim() - .split(',') - .map((id) => id.trim()) - .filter((id) => !!id) - - if (ids.length === 0) { - return - } - - fetchSequencingGroups({ - variables: { ids }, - onError: () => { - setSequencingGroups(null) - }, - onCompleted: (hits) => - setSequencingGroups( - hits.sequencingGroups.map((sg) => ({ - ...sg, - assayMeta: (sg?.assays ?? []).map((a) => a.meta), - project: sg.sample.project, - })) - ), - }) - } - - const addToCohort = () => { - if (sequencingGroups == null) { - return - } - - onAdd(sequencingGroups) - setSequencingGroups(null) - } - - const renderTable = () => { - if (loading || sequencingGroups == null) { - return null - } - - if (sequencingGroups.length === 0) { - return ( - - No sequencing groups found matching your query - - ) - } - - return - } - - return ( -
-

Add by Sequencing Group ID

-

Input a comma-separated list of valid Sequencing Group IDs

- setText(e.target.value)} - placeholder="Comma separated list of Sequencing Group IDs" - /> - - - - - {error && ( - - - - )} - {renderTable()} - - ) -} - -export default AddFromIdListForm From 9a4668f888a2b5554ccb4e7cd1b0d87dd29f99d4 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 26 Feb 2024 12:22:29 +1100 Subject: [PATCH 067/161] Delete AddFromIDListForm --- web/src/pages/cohort/AddFromProjectForm.tsx | 281 -------------------- 1 file changed, 281 deletions(-) delete mode 100644 web/src/pages/cohort/AddFromProjectForm.tsx diff --git a/web/src/pages/cohort/AddFromProjectForm.tsx b/web/src/pages/cohort/AddFromProjectForm.tsx deleted file mode 100644 index 1da3578d4..000000000 --- a/web/src/pages/cohort/AddFromProjectForm.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import React, { useState, useContext } from 'react' -import { Container, Form, Message } from 'semantic-ui-react' -import { useLazyQuery } from '@apollo/client' - -import { gql } from '../../__generated__/gql' -import { ThemeContext } from '../../shared/components/ThemeProvider' -import MuckError from '../../shared/components/MuckError' - -import SequencingGroupTable from './SequencingGroupTable' -import { Project, SequencingGroup } from './types' -import { FetchSequencingGroupsQueryVariables } from '../../__generated__/graphql' - -const GET_SEQUENCING_GROUPS_QUERY = gql(` -query FetchSequencingGroups( - $project: String!, - $platform: String, - $technology: String, - $seqType: String, - $assayMeta: JSON, - $createdOn: DateGraphQLFilter, - $hasCram: Boolean, - $hasGvcf: Boolean, - $excludeIds: [String!] -) { - sequencingGroups( - id: {nin: $excludeIds} - project: {eq: $project} - platform: {icontains: $platform} - technology: {icontains: $technology} - type: {contains: $seqType} - assayMeta: $assayMeta, - createdOn: $createdOn, - hasCram: $hasCram, - hasGvcf: $hasGvcf, - activeOnly: {eq: true} - ) { - id - type - technology - platform - assays { - meta - } - } - } -`) - -// NOTE: Put additional objects here to add more search fields for assay metadata -const assayMetaSearchFields = [ - { label: 'Batch', id: 'batch', searchVariable: 'batch' }, - { label: 'Coverage', id: 'coverage', searchVariable: 'coverage' }, -] - -interface IAddFromProjectForm { - projects: Project[] - onAdd: (sequencingGroups: SequencingGroup[]) => void -} - -const AddFromProjectForm: React.FC = ({ projects, onAdd }) => { - const [selectedProject, setSelectedProject] = useState() - const [searchHits, setSearchHits] = useState(null) - - const { theme } = useContext(ThemeContext) - const inverted = theme === 'dark-mode' - - // Load all available projects and associated data for this user - const [searchSquencingGroups, { loading, error }] = useLazyQuery(GET_SEQUENCING_GROUPS_QUERY) - - const search = () => { - if (!selectedProject?.name) { - return - } - - const searchParams: FetchSequencingGroupsQueryVariables = { - project: selectedProject.name, - seqType: null, - technology: null, - platform: null, - createdOn: null, - hasCram: null, - hasGvcf: null, - excludeIds: [], - assayMeta: {}, - } - - const seqTypeInput = document.getElementById('seq_type') as HTMLInputElement - if (seqTypeInput?.value) { - searchParams.seqType = seqTypeInput.value - } - - const technologyInput = document.getElementById('technology') as HTMLInputElement - if (technologyInput?.value) { - searchParams.technology = technologyInput.value - } - - const platformInput = document.getElementById('platform') as HTMLInputElement - if (platformInput?.value) { - searchParams.platform = platformInput.value - } - - const excludeInput = document.getElementById('exclude') as HTMLInputElement - if (excludeInput?.value && excludeInput.value.trim().length > 0) { - searchParams.excludeIds = excludeInput.value - .split(',') - .map((id) => id.trim()) - .filter((id) => id.length > 0) - } - - assayMetaSearchFields.forEach((field) => { - const input = document.getElementById(field.id) as HTMLInputElement - if (input?.value) { - searchParams.assayMeta[field.searchVariable] = input.value - } - }) - - const createdAfterInput = document.getElementById('created_after') as HTMLInputElement - const createdBeforeInput = document.getElementById('created_before') as HTMLInputElement - if (createdAfterInput?.value) { - searchParams.createdOn = { gte: createdAfterInput.value } - } - if (createdBeforeInput?.value) { - searchParams.createdOn = { ...searchParams.createdOn, lte: createdBeforeInput.value } - } - - const hasCramInput = document.getElementById('has_cram') as HTMLInputElement - if (hasCramInput?.checked) { - searchParams.hasCram = true - } - - const hasGvcfInput = document.getElementById('has_gvcf') as HTMLInputElement - if (hasGvcfInput?.checked) { - searchParams.hasGvcf = true - } - - searchSquencingGroups({ - variables: { - project: selectedProject.name, - seqType: searchParams.seqType, - technology: searchParams.technology, - platform: searchParams.platform, - createdOn: searchParams.createdOn, - hasCram: searchParams.hasCram, - hasGvcf: searchParams.hasGvcf, - assayMeta: searchParams.assayMeta, - excludeIds: searchParams.excludeIds, - }, - onError: () => { - setSearchHits(null) - }, - onCompleted: (hits) => { - const sgs = hits.sequencingGroups.map((sg) => ({ - id: sg.id, - type: sg.type, - technology: sg.technology, - platform: sg.platform, - assayMeta: (sg?.assays ?? []).map((a) => a.meta), - project: { id: selectedProject.id, name: selectedProject.name }, - })) - setSearchHits(sgs) - }, - }) - } - - const renderTable = () => { - if (loading || searchHits == null) { - return null - } - - if (searchHits.length === 0) { - return ( - - No sequencing groups found matching your query - - ) - } - - return - } - - const projectOptions = projects.map((project) => ({ - key: project.id, - value: project.id, - text: project.name, - })) - - return ( -
-

Project

-

Include sequencing groups from the following project

- { - const project = projects.find((p) => p.id === d.value) - if (!project) return - setSelectedProject({ id: project.id, name: project.name }) - }} - options={projectOptions} - /> - -

- Matching the following search criteria (leave blank to include all Sequencing - Groups) -

- - - - - {assayMetaSearchFields.map((field) => ( - - ))} - - - - - - - - - - - - - - { - if (searchHits == null) return - // eslint-disable-next-line no-alert - const proceed = window.confirm( - 'This will add all sequencing groups in the table to your Cohort. ' + - 'sequencing groups hidden by the interactive table search will ' + - 'also be added. Do you wish to continue?' - ) - if (proceed) { - onAdd(searchHits) - setSearchHits(null) - } - }} - /> - - {error && ( - - - - )} - {renderTable()} - - ) -} - -export default AddFromProjectForm From bcfe23e619fe722b3b8304409e5e90a7a148e015 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 26 Feb 2024 12:36:51 +1100 Subject: [PATCH 068/161] Delete custom cohorts UI elements --- web/src/pages/cohort/CohortBuilderView.tsx | 310 ------------------ web/src/pages/cohort/CohortDetailView.tsx | 106 ------ web/src/pages/cohort/SequencingGroupTable.tsx | 176 ---------- web/src/pages/cohort/types.ts | 19 -- 4 files changed, 611 deletions(-) delete mode 100644 web/src/pages/cohort/CohortBuilderView.tsx delete mode 100644 web/src/pages/cohort/CohortDetailView.tsx delete mode 100644 web/src/pages/cohort/SequencingGroupTable.tsx delete mode 100644 web/src/pages/cohort/types.ts diff --git a/web/src/pages/cohort/CohortBuilderView.tsx b/web/src/pages/cohort/CohortBuilderView.tsx deleted file mode 100644 index 33f57b1e6..000000000 --- a/web/src/pages/cohort/CohortBuilderView.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import React, { useState, useContext } from 'react' -import { Container, Divider, Form, Message, Tab } from 'semantic-ui-react' -import { uniqBy } from 'lodash' -import { useQuery } from '@apollo/client' -import { useNavigate } from 'react-router-dom' - -import { gql } from '../../__generated__' -import { CohortApi, CohortBody } from '../../sm-api' - -import { ThemeContext } from '../../shared/components/ThemeProvider' -import MuckError from '../../shared/components/MuckError' - -import SequencingGroupTable from './SequencingGroupTable' -import AddFromProjectForm from './AddFromProjectForm' -import AddFromIdListForm from './AddFromIdListForm' -import { Project, SequencingGroup, APIError } from './types' - -const ALLOW_STACK_TRACE = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev' - -const GET_PROJECTS_QUERY = gql(` -query GetProjectsForCohortBuilder { - myProjects { - id - name - } -}`) - -const CohortBuilderView = () => { - const { theme } = useContext(ThemeContext) - const inverted = theme === 'dark-mode' - - const navigate = useNavigate() - - // State for new cohort data - const [createCohortError, setCreateCohortError] = useState(null) - const [createCohortSuccess, setCreateCohortSuccess] = useState(null) - const [createCohortLoading, setCreateCohortLoading] = useState(false) - const [selectedProject, setSelectedProject] = useState() - - // Keep the text fields and sequencing groups array separate to prevent re-rendering the - // sequencing group table on every input change, which can be slow if there are many - // sequencing groups - const [sequencingGroups, setSequencingGroups] = useState([]) - const [cohortDetails, setCohortDetails] = useState>({}) - - // Loading projects query for drop-down menu selection - const { loading, error, data } = useQuery(GET_PROJECTS_QUERY) - - const addSequencingGroups = (sgs: SequencingGroup[]) => { - setSequencingGroups(uniqBy([...sequencingGroups, ...sgs], 'id')) - } - - const removeSequencingGroup = (id: string) => { - setSequencingGroups(sequencingGroups.filter((sg) => sg.id !== id)) - } - - const allowSubmission = - !loading && - !createCohortLoading && - selectedProject?.name && - sequencingGroups && - sequencingGroups.length > 0 && - cohortDetails.name && - cohortDetails.name.length > 0 && - cohortDetails.description && - cohortDetails.description.length > 0 - - const createCohort = () => { - if (!allowSubmission) return - - // Add these here because TypeScript cannot infer that if allowSubmission is true, then - // these values are defined - if (!cohortDetails.name) return - if (!cohortDetails.description) return - - // eslint-disable-next-line no-alert - const proceed = window.confirm( - 'Are you sure you want to create this cohort? A cohort cannot be edited once created.' - ) - if (!proceed) return - - setCreateCohortLoading(true) - setCreateCohortError(null) - setCreateCohortSuccess(null) - - const client = new CohortApi() - client - .createCohort(selectedProject.name, { - name: cohortDetails.name, - description: cohortDetails.description, - sequencing_group_ids: sequencingGroups.map((sg) => sg.id), - derived_from: undefined, - }) - .then((response) => { - setCreateCohortSuccess(response.data.cohort_id) - navigate(`/cohort/detail/${response.data.cohort_id}`) - }) - .catch((e) => { - setCreateCohortError(e.response.data) - }) - .finally(() => setCreateCohortLoading(false)) - } - - const tabPanes = [ - { - menuItem: 'From Project', - render: () => ( - - - - ), - }, - { - menuItem: 'By ID(s)', - render: () => ( - - - - ), - }, - ] - - const projectOptions = (data?.myProjects ?? []).map((project) => ({ - key: project.id, - value: project.id, - text: project.name, - })) - - if (error) { - return ( - - return - - ) - } - - if (loading) { - return ( - -
Loading available projects..
-
- ) - } - - return ( - -
-

Cohort Builder

-

- Welcome to the cohort builder! This form will guide you through the process of - creating a new cohort. You can add sequencing groups from any projects available - to you. Once you have created a cohort, you will not be able to edit it. -

-
- -
-
-

Details

- - -

- Select this cohort's parent project. Only those projects - which are accessible to you are displayed in the drop-down menu. -

- - } - placeholder="Select Project" - fluid - selection - required - onChange={(_, d) => { - const project = data?.myProjects?.find((p) => p.id === d.value) - if (!project) return - setSelectedProject({ id: project.id, name: project.name }) - }} - options={projectOptions} - /> - - -

- Please provide a human-readable name for this cohort. Names must - be unique across all projects. -

- - } - placeholder="Cohort Name" - maxLength={255} - required - onChange={(e) => { - setCohortDetails({ - ...cohortDetails, - name: e.target.value.trim(), - }) - }} - /> - - -

- Please provide a short-form description regarding this cohort. -

- - } - placeholder="Cohort Description" - maxLength={255} - required - onChange={(e) => { - setCohortDetails({ - ...cohortDetails, - description: e.target.value.trim(), - }) - }} - /> -
- -
-

Sequencing Groups

-

- Add sequencing groups to this cohort. You can bulk add sequencing groups - from any project available to to you, or add sequencing groups manually by - entering their IDs in a comma-separated list. -

- -
- -
-

Selected Sequencing Groups

-

- The table below displays the sequencing groups that will be added to this - cohort -

- - -
- -
- {createCohortError && ( - <> - -
- - )} - {createCohortSuccess && ( - <> - -
- - )} - - - { - // eslint-disable-next-line no-restricted-globals, no-alert - const proceed = confirm( - "Remove all sequencing groups? This can't be undone." - ) - if (!proceed) return - setSequencingGroups([]) - }} - /> - -
- -
- ) -} - -export default CohortBuilderView diff --git a/web/src/pages/cohort/CohortDetailView.tsx b/web/src/pages/cohort/CohortDetailView.tsx deleted file mode 100644 index 6a83d778f..000000000 --- a/web/src/pages/cohort/CohortDetailView.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React from 'react' -import { useQuery } from '@apollo/client' -import { useParams } from 'react-router-dom' -import { Container, Divider } from 'semantic-ui-react' - -import { gql } from '../../__generated__' -import MuckError from '../../shared/components/MuckError' -import SequencingGroupTable from './SequencingGroupTable' - -const COHORT_DETAIL_VIEW_FRAGMENT = gql(` -query CohortDetailView($id: Int!) { - cohort(id: {eq: $id}) { - id - name - description - project { - id - name - } - sequencingGroups { - id - type - technology - platform - assays { - meta - } - sample { - project { - id - name - } - } - } - } -} -`) - -const CohortDetailView: React.FC = () => { - const { id } = useParams() - - const { loading, error, data } = useQuery(COHORT_DETAIL_VIEW_FRAGMENT, { - variables: { id: id ? parseInt(id, 10) : 0 }, - }) - - if (loading) { - return ( - -

Cohort Information

- -
Loading Cohort...
-
- ) - } - - if (error) { - return ( - -

Cohort Information

- - -
- ) - } - - const cohort = data?.cohort[0] || null - if (!cohort) { - return ( - -

Cohort Information

- - -
- ) - } - - return ( - -

Cohort Information

- -
- Name: {cohort.name} -
- Description: {cohort.description} -
- Project: {cohort.project.name} -
- - ({ - id: sg.id, - type: sg.type, - technology: sg.technology, - platform: sg.platform, - project: sg.sample.project, - assayMeta: (sg.assays ?? []).map((a) => a.meta), - }))} - editable={false} - /> -
- ) -} - -export default CohortDetailView diff --git a/web/src/pages/cohort/SequencingGroupTable.tsx b/web/src/pages/cohort/SequencingGroupTable.tsx deleted file mode 100644 index 86ba06027..000000000 --- a/web/src/pages/cohort/SequencingGroupTable.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import React, { useState } from 'react' -import { Table, Icon, Button, Input } from 'semantic-ui-react' -import { sortBy } from 'lodash' - -import { SequencingGroup } from './types' - -type Direction = 'ascending' | 'descending' | undefined - -interface ISequencingGroupTableProps { - editable?: boolean - height?: number - sequencingGroups?: SequencingGroup[] - emptyMessage?: string - onDelete?: (id: string) => void -} - -const SequencingGroupTable: React.FC = ({ - editable = true, - height = 650, - sequencingGroups = [], - emptyMessage = 'Nothing to display', - onDelete = () => {}, -}) => { - const [sortColumn, setSortColumn] = useState('id') - const [sortDirection, setSortDirection] = useState('ascending') - const [searchTerms, setSearchTerms] = useState<{ column: string; term: string }[]>([]) - - const setSortInformation = (column: string) => { - if (column === sortColumn) { - setSortDirection(sortDirection === 'ascending' ? 'descending' : 'ascending') - return - } - setSortDirection('descending') - setSortColumn(column) - } - - const setSearchInformation = (column: string, term: string) => { - if (searchTerms.find((st) => st.column === column)) { - setSearchTerms( - searchTerms.map((st) => (st.column === column ? { column, term: term.trim() } : st)) - ) - } else { - setSearchTerms([...searchTerms, { column, term: term.trim() }]) - } - } - - const filteredRows = sequencingGroups.filter((sg: SequencingGroup) => { - if (!searchTerms.length) return true - - return searchTerms.every(({ column, term }) => { - if (!term) return true - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const value: any = column === 'assayMeta' ? JSON.stringify(sg[column]) : sg[column] - return value.toString().toLowerCase().includes(term.toLowerCase()) - }) - }) - - let sortedRows = sortBy(filteredRows, [sortColumn]) - sortedRows = sortDirection === 'ascending' ? sortedRows : sortedRows.reverse() - - const tableColumns = [ - { key: 'id', name: 'ID', filterable: true, sortable: true, minWidth: 100 }, - { key: 'project', name: 'Project', filterable: true, sortable: true, minWidth: 100 }, - { key: 'type', name: 'Type', filterable: true, sortable: true, minWidth: 50 }, - { key: 'technology', name: 'Technology', filterable: true, sortable: true, minWidth: 100 }, - { key: 'platform', name: 'Platform', filterable: true, sortable: true, minWidth: 150 }, - { key: 'assayMeta', name: 'Assay Meta', filterable: true, sortable: false, minWidth: 300 }, - ] - - const renderTableBody = () => { - if (sequencingGroups.length && !sortedRows.length) { - return ( - - No rows matching your filters - - ) - } - - if (!sequencingGroups.length) { - return ( - - {emptyMessage} - - ) - } - - return sortedRows.map((sg: SequencingGroup) => ( - - {editable && sortedRows.length ? ( - - - - ) : null} - {sg.id} - {sg.project.name} - {sg.type} - {sg.technology} - {sg.platform} - -
-
{JSON.stringify(sg.assayMeta, null, 2)}
-
-
-
- )) - } - - return ( -
-
- - - {editable && sortedRows.length ? : null} - {tableColumns.map((column) => ( - - e.target.tagName !== 'INPUT' && - column.sortable && - setSortInformation(column.key) - } - > - -
- {column.name} - {column.sortable && sortColumn === column.key && ( - - )} -
-
- {column.filterable && ( - { - setSearchInformation(column.key, e.target.value) - }} - placeholder="search..." - /> - )} -
-
-
- ))} -
-
- {renderTableBody()} -
-
- ) -} - -export default SequencingGroupTable diff --git a/web/src/pages/cohort/types.ts b/web/src/pages/cohort/types.ts deleted file mode 100644 index 0005e4dee..000000000 --- a/web/src/pages/cohort/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface SequencingGroup { - id: string - type: string - technology: string - platform: string - project: { id: number; name: string } - assayMeta: object[] -} - -export interface Project { - id: number - name: string -} - -export interface APIError { - name: string - description: string - stacktrace: string -} From 70673a0080d5c71f6df784cf6729db582f7f5e35 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 26 Feb 2024 13:18:57 +1100 Subject: [PATCH 069/161] Remove link to Cohort Builder from NavBar --- web/src/shared/components/Header/NavBar.tsx | 197 +++----------------- 1 file changed, 27 insertions(+), 170 deletions(-) diff --git a/web/src/shared/components/Header/NavBar.tsx b/web/src/shared/components/Header/NavBar.tsx index 793734e22..dcadc0cdb 100644 --- a/web/src/shared/components/Header/NavBar.tsx +++ b/web/src/shared/components/Header/NavBar.tsx @@ -2,10 +2,13 @@ import * as React from 'react' import { Link } from 'react-router-dom' import { Menu, Dropdown, Popup } from 'semantic-ui-react' +import { BillingApi } from '../../../sm-api' + // this wasn't working, so added import to HTML // import 'bootstrap/dist/css/bootstrap.min.css' - -import ConstructionIcon from '@mui/icons-material/Construction' +import Searchbar from './Search' +import MuckTheDuck from '../MuckTheDuck' +import SwaggerIcon from '../SwaggerIcon' import HomeIcon from '@mui/icons-material/Home' import ExploreIcon from '@mui/icons-material/Explore' import InsightsIcon from '@mui/icons-material/Insights' @@ -13,17 +16,10 @@ import TableRowsIcon from '@mui/icons-material/TableRows' import AttachMoneyIcon from '@mui/icons-material/AttachMoney' import DescriptionIcon from '@mui/icons-material/Description' import TroubleshootIcon from '@mui/icons-material/Troubleshoot' -import CodeIcon from '@mui/icons-material/Code' import DarkModeTriButton from './DarkModeTriButton/DarkModeTriButton' -// import { BillingApi } from '../../../sm-api' - -import MuckTheDuck from '../MuckTheDuck' -import SwaggerIcon from '../SwaggerIcon' import { ThemeContext } from '../ThemeProvider' -import Searchbar from './Search' - import './NavBar.css' const billingPages = { @@ -64,70 +60,30 @@ const billingPages = { ], } -// const billingPages = { -// title: 'Billing', -// url: '/billing', -// icon: , -// submenu: [ -// { -// title: 'Home', -// url: '/billing', -// icon: , -// }, -// { -// title: 'Invoice Month Cost', -// url: '/billing/invoiceMonthCost', -// icon: , -// }, -// { -// title: 'Cost By Time', -// url: '/billing/costByTime', -// icon: , -// }, -// { -// title: 'Seqr Prop Map', -// url: '/billing/seqrPropMap', -// icon: , -// }, -// ], -// } - -interface MenuItemDetails { +interface MenuItem { title: string url: string icon: JSX.Element - external?: boolean - submenu?: MenuItemDetails[] + submenu?: MenuItem[] } - interface MenuItemProps { index: number - item: MenuItemDetails + item: MenuItem } const MenuItem: React.FC = ({ index, item }) => { const theme = React.useContext(ThemeContext) const isDarkMode = theme.theme === 'dark-mode' - const dropdown = (i: MenuItemDetails) => ( - + const dropdown = (item: MenuItem) => ( + - {i.submenu && - i.submenu.map((subitem, subindex) => { - if (subitem.external) { - return ( - - {subitem.title} - - ) - } - - return ( - - {subitem.title} - - ) - })} + {item.submenu && + item.submenu.map((subitem, subindex) => ( + + {subitem.title} + + ))} ) @@ -163,7 +119,7 @@ interface NavBarProps { } const NavBar: React.FC = ({ fixed }) => { - const menuItems: MenuItemDetails[] = [ + const [menuItems, setMenuItems] = React.useState([ { title: 'Explore', url: '/project', @@ -175,120 +131,21 @@ const NavBar: React.FC = ({ fixed }) => { icon: , }, { - title: 'Cohort Builder', - url: '/cohort-builder', - icon: , - }, - { - title: 'Billing', - url: '/billing', - icon: , - submenu: [ - { - title: 'Home', - url: '/billing', - icon: , - }, - { - title: 'Invoice Month Cost', - url: '/billing/invoiceMonthCost', - icon: , - }, - { - title: 'Cost By Time', - url: '/billing/costByTime', - icon: , - }, - { - title: 'Seqr Prop Map', - url: '/billing/seqrPropMap', - icon: , - }, - ], - }, - { - title: 'API', - url: '/api', - icon: , - submenu: [ - { - title: 'Swagger', - url: '/swagger', - icon: , - }, - { - title: 'GraphQL', - url: '/graphql', - icon: , - external: true, - }, - ], + title: 'Swagger', + url: '/swagger', + icon: , }, { title: 'Docs', url: '/documentation', icon: , }, - ] - // const [menuItems, setMenuItems] = React.useState([ - // { - // title: 'Explore', - // url: '/project', - // icon: , - // }, - // { - // title: 'Analysis Runner', - // url: '/analysis-runner', - // icon: , - // }, - // { - // title: 'Cohort Builder', - // url: '/cohort-builder', - // icon: , - // }, - // { - // title: 'Billing', - // url: '/billing', - // icon: , - // submenu: [ - // { - // title: 'Home', - // url: '/billing', - // icon: , - // }, - // { - // title: 'Invoice Month Cost', - // url: '/billing/invoiceMonthCost', - // icon: , - // }, - // { - // title: 'Cost By Time', - // url: '/billing/costByTime', - // icon: , - // }, - // { - // title: 'Seqr Prop Map', - // url: '/billing/seqrPropMap', - // icon: , - // }, - // ], - // }, - // { - // title: 'Swagger', - // url: '/swagger', - // icon: , - // }, - // { - // title: 'Docs', - // url: '/documentation', - // icon: , - // }, - // { - // title: 'GraphQL', - // url: '/graphql', - // icon: , - // }, - // ]) + { + title: 'GraphQL', + url: '/graphql', + icon: , + }, + ]) React.useEffect(() => { new BillingApi().isBillingEnabled().then((response) => { @@ -324,4 +181,4 @@ const NavBar: React.FC = ({ fixed }) => { ) } -export default NavBar +export default NavBar \ No newline at end of file From a2d9fca55bc868ba06e464c2156a5177cd37aaaa Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 26 Feb 2024 13:40:08 +1100 Subject: [PATCH 070/161] Remove link to custom cohorts page in routes --- web/src/Routes.tsx | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index 9ee47b5f9..92919a052 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -17,8 +17,6 @@ import ProjectSummaryView from './pages/project/ProjectSummary' import ProjectsAdmin from './pages/admin/ProjectsAdmin' import ErrorBoundary from './shared/utilities/errorBoundary' import AnalysisRunnerSummary from './pages/project/AnalysisRunnerView/AnalysisRunnerSummary' -import CohortBuilderView from './pages/cohort/CohortBuilderView' -import CohortDetailView from './pages/cohort/CohortDetailView' const Routes: React.FunctionComponent = () => ( @@ -107,23 +105,6 @@ const Routes: React.FunctionComponent = () => ( } /> - - - - } - /> - - - - - } - /> ) From c529a31d2e445abec5a1926701b38acdfd7ee161 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 26 Feb 2024 13:43:24 +1100 Subject: [PATCH 071/161] Revert package-lock to main --- web/package-lock.json | 953 +++++++----------------------------------- web/src/Routes.tsx | 1 - 2 files changed, 148 insertions(+), 806 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 4e8f55872..87e1ffa90 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "metamist", - "version": "6.5.0", + "version": "6.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metamist", - "version": "6.5.0", + "version": "6.4.0", "dependencies": { "@apollo/client": "^3.7.3", "@artsy/fresnel": "^6.2.1", @@ -75,9 +75,8 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.2.tgz", - "integrity": "sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==" + "version": "4.2.0", + "license": "MIT" }, "node_modules/@ampproject/remapping": { "version": "2.2.1", @@ -1104,11 +1103,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz", - "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==", + "version": "7.21.0", + "license": "MIT", "dependencies": { - "regenerator-runtime": "^0.14.0" + "regenerator-runtime": "^0.13.11" }, "engines": { "node": ">=6.9.0" @@ -1125,11 +1123,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/runtime/node_modules/regenerator-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" - }, "node_modules/@babel/template": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", @@ -2372,11 +2365,10 @@ } }, "node_modules/@mui/types": { - "version": "7.2.10", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.10.tgz", - "integrity": "sha512-wX1vbDC+lzF7FlhT6A3ffRZgEoKWPF8VqRoTu4lZwouFX2t90KyCMsgepMw5DxLak1BSp/KP86CmtZttikb/gQ==", + "version": "7.2.4", + "license": "MIT", "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0" + "@types/react": "*" }, "peerDependenciesMeta": { "@types/react": { @@ -2385,12 +2377,12 @@ } }, "node_modules/@mui/utils": { - "version": "5.14.20", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.14.20.tgz", - "integrity": "sha512-Y6yL5MoFmtQml20DZnaaK1znrCEwG6/vRSzW8PKOTrzhyqKIql0FazZRUR7sA5EPASgiyKZfq0FPwISRXm5NdA==", + "version": "5.12.0", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.4", - "@types/prop-types": "^15.7.11", + "@babel/runtime": "^7.21.0", + "@types/prop-types": "^15.7.5", + "@types/react-is": "^16.7.1 || ^17.0.0", "prop-types": "^15.8.1", "react-is": "^18.2.0" }, @@ -2399,16 +2391,10 @@ }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/mui-org" + "url": "https://opencollective.com/mui" }, "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } } }, "node_modules/@nodelib/fs.scandir": { @@ -2499,9 +2485,8 @@ } }, "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "version": "2.11.7", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -2743,407 +2728,114 @@ } }, "node_modules/@swagger-api/apidom-ast": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-0.86.0.tgz", - "integrity": "sha512-Q1c5bciMCIGvOx1uZWh567qql2Ef0pCoZOKfhpQ+vKIevfTO85fRBmixyjxv2zETq2UZ1XwsW8q8k0feu1yBjw==", + "version": "0.69.0", + "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-error": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "stampit": "^4.3.2", - "unraw": "^3.0.0" + "@types/ramda": "=0.28.23", + "ramda": "=0.28.0", + "ramda-adjunct": "=3.4.0", + "stampit": "=4.3.2", + "unraw": "=2.0.1" } }, "node_modules/@swagger-api/apidom-core": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-0.86.0.tgz", - "integrity": "sha512-HsM6Y5hEDlm8gwO5dSH9QOdtU3H18oVuEZJ/hmC7YCsqrG3EfCD3Y0V1uskuQraaUnyxVGKtgDqUrrWfoWH/sw==", + "version": "0.69.2", + "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-ast": "^0.86.0", - "@swagger-api/apidom-error": "^0.86.0", - "@types/ramda": "~0.29.6", - "minim": "~0.23.8", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "short-unique-id": "^5.0.2", - "stampit": "^4.3.2" - } - }, - "node_modules/@swagger-api/apidom-error": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-0.86.0.tgz", - "integrity": "sha512-nUV91SDdiZ0nzk8o/D7ILToAYRpLNHsXKXnse8yMXmgaDYnQ5cBKQnuOcDOH9PG3HfDfE+MDy/aM8WKvKUzxMg==", - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7" + "@swagger-api/apidom-ast": "^0.69.0", + "@types/ramda": "=0.28.23", + "minim": "=0.23.8", + "ramda": "=0.28.0", + "ramda-adjunct": "=3.4.0", + "short-unique-id": "=4.4.4", + "stampit": "=4.3.2" } }, "node_modules/@swagger-api/apidom-json-pointer": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-0.86.0.tgz", - "integrity": "sha512-iEY16JZeNWFBxy9YimDwGoJ+LL4dvZndd7KLrtT3SN1q/oSbLPc4mc5PsqVQwV3pplYVorGwlL5sZ5BMRRuxEQ==", - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-error": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.0.0" - } - }, - "node_modules/@swagger-api/apidom-ns-api-design-systems": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-0.86.0.tgz", - "integrity": "sha512-/oSrDO5YqI4b8a5DbPGV0a5mss3Rdi72vIMlEzElhuX9NkeOI0foEyzhIL/lpjrI0iUmzLk30H0puQU3aspNZA==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-error": "^0.86.0", - "@swagger-api/apidom-ns-openapi-3-1": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "stampit": "^4.3.2" - } - }, - "node_modules/@swagger-api/apidom-ns-asyncapi-2": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-0.86.0.tgz", - "integrity": "sha512-q7ZGjAv1oD8Cs/cJA/jkVgVysrU5T72ItO4LcUiyd6VqfK5f13CjXw5nADPW3ETPwz1uOQ0GO6SEDNlGCsEE3A==", - "optional": true, + "version": "0.69.2", + "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-ns-json-schema-draft-7": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "stampit": "^4.3.2" + "@swagger-api/apidom-core": "^0.69.2", + "@types/ramda": "=0.28.23", + "ramda": "=0.28.0", + "ramda-adjunct": "=3.4.0" } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-4": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-0.86.0.tgz", - "integrity": "sha512-NELX5IeCYErvTc/rJTkud8YySsaEYY4g7FwnCze8u6VnypVQLD9GPbpSR7rpm/lugx0phoAfcGvHM+mOqt14yQ==", - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-ast": "^0.86.0", - "@swagger-api/apidom-core": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "stampit": "^4.3.2" - } - }, - "node_modules/@swagger-api/apidom-ns-json-schema-draft-6": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-0.86.0.tgz", - "integrity": "sha512-ZYfgawZHDtsztiKIFxpTX78ajZWkyNp9+psXv7l91r0TFiuRVJRERmfvtpHE9m0sGHkJEfRcxL3RlZceQ9fohw==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-error": "^0.86.0", - "@swagger-api/apidom-ns-json-schema-draft-4": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "stampit": "^4.3.2" - } - }, - "node_modules/@swagger-api/apidom-ns-json-schema-draft-7": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-0.86.0.tgz", - "integrity": "sha512-EcPCeS/mcgZnZJvHNrqQrdQ1V4miBx55xEcmUpfDebacexlLV9A/OpeL8ttIVJRmuhv4ATiq2/eOKaN7wETB4w==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-error": "^0.86.0", - "@swagger-api/apidom-ns-json-schema-draft-6": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "stampit": "^4.3.2" - } - }, - "node_modules/@swagger-api/apidom-ns-openapi-2": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-0.86.0.tgz", - "integrity": "sha512-IkORhlU8E5VoIYYJ2O+Oe/9JLcI/MLGl6yAsaReK1TZxyK/7tLghbIu6sBfJCAr7Jt1WY6lwWtvJg0ptTZ2zTw==", - "optional": true, + "version": "0.69.2", + "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-error": "^0.86.0", - "@swagger-api/apidom-ns-json-schema-draft-4": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "stampit": "^4.3.2" + "@swagger-api/apidom-core": "^0.69.2", + "@types/ramda": "=0.28.23", + "ramda": "=0.28.0", + "ramda-adjunct": "=3.4.0", + "stampit": "=4.3.2" } }, "node_modules/@swagger-api/apidom-ns-openapi-3-0": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-0.86.0.tgz", - "integrity": "sha512-u489LR/E+5q1Hh3fzex4j6wpCBQwmcNy52dF3YSQbz5PTUOIfU4QGR6fh4/3sgublS7eQ84Z6G77Mg/vzZjeCQ==", + "version": "0.69.2", + "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-error": "^0.86.0", - "@swagger-api/apidom-ns-json-schema-draft-4": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "stampit": "^4.3.2" + "@swagger-api/apidom-core": "^0.69.2", + "@swagger-api/apidom-ns-json-schema-draft-4": "^0.69.2", + "@types/ramda": "=0.28.23", + "ramda": "=0.28.0", + "ramda-adjunct": "=3.4.0", + "stampit": "=4.3.2" } }, "node_modules/@swagger-api/apidom-ns-openapi-3-1": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-0.86.0.tgz", - "integrity": "sha512-oYXd0qHxisPh5/SNHWtlAl/g1GtDl+OPrZUp4y6tTHHLc1M4HQ/q0iTcHHdvg+t+m3l7z9wwN8KtvKtwD6EnTw==", - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-ast": "^0.86.0", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-ns-openapi-3-0": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "stampit": "^4.3.2" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-json": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-0.86.0.tgz", - "integrity": "sha512-6+dhrsqAm56Vr6rhmheOPQZxQd1Zw9HXD9+JC83sMJUOstH0q73ApdKbwU8ksGYPxIeANUdjQ3oIz0Nj2tBMvw==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-ns-api-design-systems": "^0.86.0", - "@swagger-api/apidom-parser-adapter-json": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-yaml": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-0.86.0.tgz", - "integrity": "sha512-mQTKwIorT1VSa75nsclSUCp5EaovWkuaewZfrOGDUWFhY+++vcnScBdcJv7TBtO2ttTge4UOSu9qgpoSrztXZg==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-ns-api-design-systems": "^0.86.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-2": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-0.86.0.tgz", - "integrity": "sha512-jNtvUJoiI++P3FAQf7X03se+Qx0sUhA5bBSINGMuhjPcSyOAWj9oiPjpB9SYltaqvEb9ek7iPObrt/dx9zj6Ag==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-ns-asyncapi-2": "^0.86.0", - "@swagger-api/apidom-parser-adapter-json": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-0.86.0.tgz", - "integrity": "sha512-A0GTtD6gYPEA3tQQ1A6yw+SceKdDEea3slISVx5bpeDREk8wAl/886EGJICcgFrPO57dUD3HoLqmPn/uUl26mA==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-ns-asyncapi-2": "^0.86.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-json": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-0.86.0.tgz", - "integrity": "sha512-bh5fndjX7JwgkZ0z3tEDknCEFysAs2oSoYiHN8iSLl/MKXBE001tJeJrOdnP9BnrPQSyXAbdT1c1dG3oTnxUgw==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-ast": "^0.86.0", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-error": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "stampit": "^4.3.2", - "tree-sitter": "=0.20.4", - "tree-sitter-json": "=0.20.1", - "web-tree-sitter": "=0.20.3" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-2": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-0.86.0.tgz", - "integrity": "sha512-BULmOvcLnf4QpZ2QFOCrpZnNKLf8sZfzpDPXJm6QwyoZQqAMmeHmEzAY9dE9RrCwNx9lVjumAEoyNf7Hy4qrWw==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-ns-openapi-2": "^0.86.0", - "@swagger-api/apidom-parser-adapter-json": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-0": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-0.86.0.tgz", - "integrity": "sha512-zo/fNkWe9A2AL+cqzt+Z3OiTE5oLEWpLY+Y0tuLWh8YME0ZY7BmR2HYNdWquIhOy5b279QeD19Kv15aY24obxA==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-ns-openapi-3-0": "^0.86.0", - "@swagger-api/apidom-parser-adapter-json": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-1": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-0.86.0.tgz", - "integrity": "sha512-NkFrAyr27Ubwkacv2YolxSN/NciKqJyIEXtAg4SfP/ejTy1Gl+PcT5pZSjQ3doRx1BPp3CF+a2Hsi5HJI6wEzA==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-ns-openapi-3-1": "^0.86.0", - "@swagger-api/apidom-parser-adapter-json": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-2": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-0.86.0.tgz", - "integrity": "sha512-flAGqElCSrVN9XXdA00NWmctOPuqzc+8r15omRvVFZ+Qfzca+FWpyFvzUFr92TKX87XUBALvnu7VA5+g1PftGg==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-ns-openapi-2": "^0.86.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-0.86.0.tgz", - "integrity": "sha512-TT93vbdj6GWhNHU4cTih/93kWJ5l6ZeEyaEQWyd+MhDxgoy6/rCOeblwyMQCgaXL6AmG5qSKTu48Y+GTCqURng==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-ns-openapi-3-0": "^0.86.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-0.86.0.tgz", - "integrity": "sha512-BPNzUdbQbd29YrotIhg/pPZkVXZ8PZOEy9Wy/Aornv9gFZwhzzWE9uOo/HGBDXJqqq5Va1RJkxuYXjIX7BVKBw==", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-ns-openapi-3-1": "^0.86.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-0.86.0.tgz", - "integrity": "sha512-wtvEJFk4uxQbDQH23mjVIeOJJ6IEpiorBNfW/6foPfJbUU7zDE/a0VTEo/wKPxumLe9eLNHuTZSSOvy2y0BmTw==", - "optional": true, + "version": "0.69.2", + "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-ast": "^0.86.0", - "@swagger-api/apidom-core": "^0.86.0", - "@swagger-api/apidom-error": "^0.86.0", - "@types/ramda": "~0.29.6", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "stampit": "^4.3.2", - "tree-sitter": "=0.20.4", - "tree-sitter-yaml": "=0.5.0", - "web-tree-sitter": "=0.20.3" + "@swagger-api/apidom-core": "^0.69.2", + "@swagger-api/apidom-ns-openapi-3-0": "^0.69.2", + "@types/ramda": "=0.28.23", + "ramda": "=0.28.0", + "ramda-adjunct": "=3.4.0", + "stampit": "=4.3.2" } }, "node_modules/@swagger-api/apidom-reference": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-0.86.0.tgz", - "integrity": "sha512-YjlocO/JkuK1SwGs8ke7AAHecR5w2GyKjWRAGZ06+2ZO8cqV3/0uuuL+laRbYchrFWERqJCUEQre0qJ3BPY7xA==", + "version": "0.69.2", + "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7", - "@swagger-api/apidom-core": "^0.86.0", - "@types/ramda": "~0.29.6", - "axios": "^1.4.0", - "minimatch": "^7.4.3", - "process": "^0.11.10", - "ramda": "~0.29.1", - "ramda-adjunct": "^4.1.1", - "stampit": "^4.3.2" + "@swagger-api/apidom-core": "^0.69.2", + "@types/ramda": "=0.28.23", + "axios": "=1.3.4", + "minimatch": "=7.4.3", + "process": "=0.11.10", + "ramda": "=0.28.0", + "ramda-adjunct": "=3.4.0", + "stampit": "=4.3.2" }, "optionalDependencies": { - "@swagger-api/apidom-error": "^0.86.0", - "@swagger-api/apidom-json-pointer": "^0.86.0", - "@swagger-api/apidom-ns-asyncapi-2": "^0.86.0", - "@swagger-api/apidom-ns-openapi-2": "^0.86.0", - "@swagger-api/apidom-ns-openapi-3-0": "^0.86.0", - "@swagger-api/apidom-ns-openapi-3-1": "^0.86.0", - "@swagger-api/apidom-parser-adapter-api-design-systems-json": "^0.86.0", - "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "^0.86.0", - "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "^0.86.0", - "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "^0.86.0", - "@swagger-api/apidom-parser-adapter-json": "^0.86.0", - "@swagger-api/apidom-parser-adapter-openapi-json-2": "^0.86.0", - "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "^0.86.0", - "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "^0.86.0", - "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "^0.86.0", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "^0.86.0", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "^0.86.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.86.0" + "@swagger-api/apidom-json-pointer": "^0.69.2", + "@swagger-api/apidom-ns-asyncapi-2": "^0.69.2", + "@swagger-api/apidom-ns-openapi-3-0": "^0.69.2", + "@swagger-api/apidom-ns-openapi-3-1": "^0.69.2", + "@swagger-api/apidom-parser-adapter-api-design-systems-json": "^0.69.2", + "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "^0.69.2", + "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "^0.69.2", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "^0.69.2", + "@swagger-api/apidom-parser-adapter-json": "^0.69.2", + "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "^0.69.2", + "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "^0.69.2", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "^0.69.2", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "^0.69.2", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^0.69.2" } }, "node_modules/@swagger-api/apidom-reference/node_modules/axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.3.4", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -3674,16 +3366,14 @@ "license": "MIT" }, "node_modules/@types/prop-types": { - "version": "15.7.11", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", - "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" + "version": "15.7.5", + "license": "MIT" }, "node_modules/@types/ramda": { - "version": "0.29.9", - "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.29.9.tgz", - "integrity": "sha512-X3yEG6tQCWBcUAql+RPC/O1Hm9BSU+MXu2wJnCETuAgUlrEDwTA1kIOdEEE4YXDtf0zfQLHa9CCE7WYp9kqPIQ==", + "version": "0.28.23", + "license": "MIT", "dependencies": { - "types-ramda": "^0.29.6" + "ts-toolbelt": "^6.15.1" } }, "node_modules/@types/react": { @@ -3710,6 +3400,13 @@ "@types/react": "*" } }, + "node_modules/@types/react-is": { + "version": "17.0.3", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-router": { "version": "5.1.20", "license": "MIT", @@ -3736,9 +3433,8 @@ } }, "node_modules/@types/react-transition-group": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", - "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", + "version": "4.4.5", + "license": "MIT", "dependencies": { "@types/react": "*" } @@ -3839,10 +3535,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.5.0", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -3976,10 +3671,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.5.0", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -4032,10 +3726,9 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.5.0", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -4393,8 +4086,7 @@ }, "node_modules/asynckit": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "license": "MIT" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -4541,7 +4233,7 @@ }, "node_modules/bl": { "version": "4.1.0", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -4608,7 +4300,7 @@ }, "node_modules/buffer": { "version": "5.7.1", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -4797,12 +4489,6 @@ "dev": true, "license": "MIT" }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "optional": true - }, "node_modules/ci-info": { "version": "2.0.0", "license": "MIT" @@ -4932,8 +4618,7 @@ }, "node_modules/combined-stream": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -5524,21 +5209,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "optional": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deep-equal": { "version": "2.2.0", "license": "MIT", @@ -5618,8 +5288,7 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -5647,15 +5316,6 @@ "node": ">=8" } }, - "node_modules/detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/diff": { "version": "4.0.2", "dev": true, @@ -5823,15 +5483,6 @@ "dev": true, "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "optional": true, - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/enquirer": { "version": "2.3.6", "dev": true, @@ -6458,10 +6109,9 @@ } }, "node_modules/eslint/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.5.0", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -6572,15 +6222,6 @@ "version": "1.2.2", "license": "BSD-3-Clause" }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/extend": { "version": "3.0.2", "license": "MIT" @@ -6871,8 +6512,7 @@ }, "node_modules/form-data": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -6910,12 +6550,6 @@ "node": ">= 14" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "optional": true - }, "node_modules/fs-extra": { "version": "9.1.0", "license": "MIT", @@ -7019,12 +6653,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "optional": true - }, "node_modules/github-slugger": { "version": "2.0.0", "license": "ISC" @@ -7118,9 +6746,8 @@ "license": "MIT" }, "node_modules/graphql": { - "version": "16.8.1", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", - "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", + "version": "16.6.0", + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -7544,12 +7171,6 @@ "version": "2.0.4", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "optional": true - }, "node_modules/inline-style-parser": { "version": "0.1.1", "license": "MIT" @@ -9271,16 +8892,14 @@ }, "node_modules/mime-db": { "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, @@ -9296,18 +8915,6 @@ "node": ">=6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "license": "MIT", @@ -9342,12 +8949,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "optional": true - }, "node_modules/mri": { "version": "1.2.0", "license": "MIT", @@ -9364,22 +8965,15 @@ "dev": true, "license": "ISC" }, - "node_modules/nan": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz", - "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==", - "optional": true - }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.6", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -9387,12 +8981,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", - "optional": true - }, "node_modules/natural-compare": { "version": "1.4.0", "dev": true, @@ -9416,51 +9004,6 @@ "tslib": "^2.0.3" } }, - "node_modules/node-abi": { - "version": "3.52.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.52.0.tgz", - "integrity": "sha512-JJ98b02z16ILv7859irtXn4oUaFWADtvkzy2c0IAatNVX2Mc9Yoh8z6hZInn3QwvMEYhHuQloYi+TTQy67SIdQ==", - "optional": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "optional": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true - }, "node_modules/node-addon-api": { "version": "3.2.1", "dev": true, @@ -9946,9 +9489,8 @@ } }, "node_modules/patch-package/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "version": "5.7.1", + "license": "ISC", "bin": { "semver": "bin/semver" } @@ -10069,9 +9611,7 @@ } }, "node_modules/postcss": { - "version": "8.4.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", - "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", + "version": "8.4.23", "funding": [ { "type": "opencollective", @@ -10086,8 +9626,9 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -10100,32 +9641,6 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" }, - "node_modules/prebuild-install": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", - "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -10242,18 +9757,7 @@ }, "node_modules/proxy-from-env": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, - "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "optional": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } + "license": "MIT" }, "node_modules/punycode": { "version": "1.4.1", @@ -10319,18 +9823,16 @@ "license": "MIT" }, "node_modules/ramda": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz", - "integrity": "sha512-OfxIeWzd4xdUNxlWhgFazxsA/nl3mS4/jGZI5n00uWOoSSFRhC1b6gl6xvmzUamgmqELraWp0J/qqVlXYPDPyA==", + "version": "0.28.0", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" } }, "node_modules/ramda-adjunct": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-4.1.1.tgz", - "integrity": "sha512-BnCGsZybQZMDGram9y7RiryoRHS5uwx8YeGuUeDKuZuvK38XO6JJfmK85BwRWAKFA6pZ5nZBO/HBFtExVaf31w==", + "version": "3.4.0", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.3" }, @@ -10339,7 +9841,7 @@ "url": "https://opencollective.com/ramda-adjunct" }, "peerDependencies": { - "ramda": ">= 0.29.0" + "ramda": ">= 0.28.0 <= 0.28.0" } }, "node_modules/randexp": { @@ -10360,30 +9862,6 @@ "safe-buffer": "^5.1.0" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "optional": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { "version": "17.0.2", "license": "MIT", @@ -10695,7 +10173,7 @@ }, "node_modules/readable-stream": { "version": "3.6.2", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -11193,9 +10671,8 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "6.3.0", + "license": "ISC", "bin": { "semver": "bin/semver.js" } @@ -11290,9 +10767,8 @@ } }, "node_modules/short-unique-id": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/short-unique-id/-/short-unique-id-5.0.3.tgz", - "integrity": "sha512-yhniEILouC0s4lpH0h7rJsfylZdca10W9mDJRAFh3EpcSUanCHGb0R7kcFOIUCZYSAPo0PUD5ZxWQdW0T4xaug==", + "version": "4.4.4", + "license": "Apache-2.0", "bin": { "short-unique-id": "bin/short-unique-id", "suid": "bin/short-unique-id" @@ -11320,51 +10796,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "optional": true - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "optional": true, - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/slash": { "version": "3.0.0", "dev": true, @@ -11469,7 +10900,7 @@ }, "node_modules/string_decoder": { "version": "1.3.0", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -11817,34 +11248,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", - "optional": true, - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "optional": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/terser": { "version": "5.17.1", "devOptional": true, @@ -11938,37 +11341,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tree-sitter": { - "version": "0.20.4", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.20.4.tgz", - "integrity": "sha512-rjfR5dc4knG3jnJNN/giJ9WOoN1zL/kZyrS0ILh+eqq8RNcIbiXA63JsMEgluug0aNvfQvK4BfCErN1vIzvKog==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "nan": "^2.17.0", - "prebuild-install": "^7.1.1" - } - }, - "node_modules/tree-sitter-json": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.20.1.tgz", - "integrity": "sha512-482hf7J+aBwhksSw8yWaqI8nyP1DrSwnS4IMBShsnkFWD3SE8oalHnsEik59fEVi3orcTCUtMzSjZx+0Tpa6Vw==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "nan": "^2.18.0" - } - }, - "node_modules/tree-sitter-yaml": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/tree-sitter-yaml/-/tree-sitter-yaml-0.5.0.tgz", - "integrity": "sha512-POJ4ZNXXSWIG/W4Rjuyg36MkUD4d769YRUGKRqN+sVaj/VCo6Dh6Pkssn1Rtewd5kybx+jT1BWMyWN0CijXnMA==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "nan": "^2.14.0" - } - }, "node_modules/trough": { "version": "2.1.0", "license": "MIT", @@ -12046,9 +11418,8 @@ } }, "node_modules/ts-toolbelt": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", - "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==" + "version": "6.15.5", + "license": "Apache-2.0" }, "node_modules/tsconfig-paths": { "version": "3.14.2", @@ -12095,18 +11466,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "optional": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -12142,14 +11501,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/types-ramda": { - "version": "0.29.6", - "resolved": "https://registry.npmjs.org/types-ramda/-/types-ramda-0.29.6.tgz", - "integrity": "sha512-VJoOk1uYNh9ZguGd3eZvqkdhD4hTGtnjRBUx5Zc0U9ftmnCgiWcSj/lsahzKunbiwRje1MxxNkEy1UdcXRCpYw==", - "dependencies": { - "ts-toolbelt": "^9.6.0" - } - }, "node_modules/typescript": { "version": "4.9.5", "dev": true, @@ -12315,9 +11666,8 @@ } }, "node_modules/unraw": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz", - "integrity": "sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==" + "version": "2.0.1", + "license": "MIT" }, "node_modules/update-browserslist-db": { "version": "1.0.11", @@ -12416,7 +11766,7 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/uvu": { @@ -12643,12 +11993,6 @@ "node": ">= 8" } }, - "node_modules/web-tree-sitter": { - "version": "0.20.3", - "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.20.3.tgz", - "integrity": "sha512-zKGJW9r23y3BcJusbgvnOH2OYAW40MXAOi9bi3Gcc7T4Gms9WWgXF8m6adsJWpGJEhgOzCrfiz1IzKowJWrtYw==", - "optional": true - }, "node_modules/web-vitals": { "version": "1.1.2", "license": "Apache-2.0" @@ -12742,10 +12086,9 @@ } }, "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "version": "1.2.3", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -12900,4 +12243,4 @@ } } } -} +} \ No newline at end of file diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index 92919a052..a307355e6 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -104,7 +104,6 @@ const Routes: React.FunctionComponent = () => ( } /> - ) From 10086129279a070a4fbe93315a89c7e4f48f2643 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 26 Feb 2024 15:37:34 +1100 Subject: [PATCH 072/161] Handle SG ID exclusion in cohort creation --- api/routes/cohort.py | 2 ++ db/python/layers/cohort.py | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 50cdaa2ed..5a6682e8b 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -28,6 +28,7 @@ class CohortCriteria(BaseModel): projects: list[str] sg_ids_internal: list[str] | None = None + excluded_sgs_internal: list[str] | None = None sg_technology: list[str] | None = None sg_platform: list[str] | None = None sg_type: list[str] | None = None @@ -60,6 +61,7 @@ async def create_cohort_from_criteria( author=connection.author, cohort_name=cohort_spec.name, sg_ids_internal=sequencing_group_id_transform_to_raw_list(cohort_criteria.sg_ids_internal), + exlude_sg_ids_internal=sequencing_group_id_transform_to_raw_list(cohort_criteria.excluded_sgs_internal), sg_technology=cohort_criteria.sg_technology, sg_platform=cohort_criteria.sg_platform, sg_type=cohort_criteria.sg_type, diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 7c501b0b0..2967a97be 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -46,6 +46,7 @@ async def create_cohort_from_criteria( description: str, cohort_name: str, sg_ids_internal: list[int] | None = None, + excluded_sgs_internal: list[int] | None = None, sg_technology: list[str] | None = None, sg_platform: list[str] | None = None, sg_type: list[str] | None = None, @@ -55,11 +56,19 @@ async def create_cohort_from_criteria( """ # 1. Pull SG's based on criteria + if sg_ids_internal and excluded_sgs_internal: + sg_id_filter = GenericFilter(in_=sg_ids_internal, nin=excluded_sgs_internal) + elif sg_ids_internal: + sg_id_filter = GenericFilter(in_=sg_ids_internal) + elif excluded_sgs_internal: + sg_id_filter = GenericFilter(nin=excluded_sgs_internal) + else: + sg_id_filter = None sgs = await self.sglayer.query( SequencingGroupFilter( project=GenericFilter(in_=projects_to_pull), - id=GenericFilter(in_=sg_ids_internal) if sg_ids_internal else None, + id=sg_id_filter, technology=GenericFilter(in_=sg_technology) if sg_technology else None, platform=GenericFilter(in_=sg_platform) if sg_platform else None, type=GenericFilter(in_=sg_type) if sg_type else None, From 69c8e469cc92cf9111dd5575210b0290d48b0cb2 Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 27 Feb 2024 09:13:12 +1100 Subject: [PATCH 073/161] Add cohort_template table --- db/project.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/db/project.xml b/db/project.xml index d7678e853..336f99924 100644 --- a/db/project.xml +++ b/db/project.xml @@ -1126,4 +1126,20 @@ ALTER TABLE sequencing_group_assay CHANGE author author VARCHAR(255) NULL; ALTER TABLE sequencing_group_external_id CHANGE author author VARCHAR(255) NULL; + + + + + + + + + + + + + + + + From 345348a74f8329e477b247a4a19901ac8e87a74e Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 27 Feb 2024 17:35:32 +1100 Subject: [PATCH 074/161] Add create cohort template endpoint --- api/routes/cohort.py | 41 +++++++++++++++++++------------------- db/python/layers/cohort.py | 18 +++++++++++++++++ db/python/tables/cohort.py | 27 ++++++++++++++++++++++++- models/models/cohort.py | 24 ++++++++++++++++++++++ 4 files changed, 89 insertions(+), 21 deletions(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 5a6682e8b..72d2a1996 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -4,10 +4,11 @@ from pydantic import BaseModel from api.utils.db import Connection, get_project_write_connection -from db.python.layers.cohort import CohortLayer +from db.python.layers.cohort import CohortLayer from db.python.tables.project import ProjectPermissionsTable +from models.models.cohort import CohortBody, CohortCriteria, CohortTemplate from models.utils.sequencing_group_id_format import ( sequencing_group_id_transform_to_raw_list, ) @@ -15,25 +16,6 @@ router = APIRouter(prefix='/cohort', tags=['cohort']) -class CohortBody(BaseModel): - """Represents the expected JSON body of the create cohort request""" - - name: str - description: str - derived_from: int | None = None - - -class CohortCriteria(BaseModel): - """Represents the expected JSON body of the create cohort request""" - - projects: list[str] - sg_ids_internal: list[str] | None = None - excluded_sgs_internal: list[str] | None = None - sg_technology: list[str] | None = None - sg_platform: list[str] | None = None - sg_type: list[str] | None = None - - @router.post('/{project}/cohort', operation_id='createCohortFromCriteria') async def create_cohort_from_criteria( cohort_spec: CohortBody, @@ -68,3 +50,22 @@ async def create_cohort_from_criteria( ) return {'cohort_id': cohort_id} + + +@router.post('/{project}/cohort_template', operation_id='createCohortTemplate') +async def create_cohort_template( + template: CohortTemplate, + connection: Connection = get_project_write_connection, +) -> dict[str, Any]: + """ + Create a cohort template with the given name and sample/sequencing group IDs. + """ + cohortlayer = CohortLayer(connection) + + if not connection.project: + raise ValueError('A cohort template must belong to a project') + + return await cohortlayer.create_cohort_template( + cohort_template=template, + ) + diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 2967a97be..6e2869f75 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -12,6 +12,8 @@ from db.python.utils import GenericFilter, get_logger +from models.models.cohort import CohortTemplate + logger = get_logger() @@ -37,6 +39,22 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: Get the sequencing group IDs for the given cohort. """ return await self.ct.get_cohort_sequencing_group_ids(cohort_id) + + async def create_cohort_template( + self, + cohort_template: CohortTemplate, + + ): + """ + Create new cohort template + """ + + return await self.ct.create_cohort_template( + name=cohort_template.name, + description=cohort_template.description, + criteria=dict(cohort_template.criteria), + ) + async def create_cohort_from_criteria( self, diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index d4aed6bbf..d8b5857d0 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -4,7 +4,7 @@ from db.python.tables.base import DbBase from db.python.tables.project import ProjectId -from db.python.utils import GenericFilter, GenericFilterModel +from db.python.utils import GenericFilter, GenericFilterModel, to_db_json from models.models.cohort import Cohort @@ -64,6 +64,31 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: """ rows = await self.connection.fetch_all(_query, {'cohort_id': cohort_id}) return [row['sequencing_group_id'] for row in rows] + + async def create_cohort_template( + self, + name: str, + description: str, + criteria: dict, + open_transaction: bool = True, + ): + """ + Create new cohort template + """ + _query = """ + INSERT INTO cohort_template (name, description, criteria) + VALUES (:name, :description, :criteria) RETURNING id; + """ + cohort_template_id = await self.connection.fetch_val( + _query, + { + 'name': name, + 'description': description, + 'criteria':to_db_json(criteria), + }, + ) + + return cohort_template_id async def create_cohort( self, diff --git a/models/models/cohort.py b/models/models/cohort.py index 253229729..94bf867d5 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -1,6 +1,7 @@ from models.base import SMBase from models.models.sequencing_group import SequencingGroup, SequencingGroupExternalId +from pydantic import BaseModel class Cohort(SMBase): """Model for Cohort""" @@ -35,3 +36,26 @@ def from_db(d: dict): derived_from=derived_from, sequencing_groups=sequencing_groups, ) + +class CohortBody(BaseModel): + """Represents the expected JSON body of the create cohort request""" + + name: str + description: str + derived_from: int | None = None + + +class CohortCriteria(BaseModel): + """Represents the expected JSON body of the create cohort request""" + + projects: list[str] + sg_ids_internal: list[str] | None = None + excluded_sgs_internal: list[str] | None = None + sg_technology: list[str] | None = None + sg_platform: list[str] | None = None + sg_type: list[str] | None = None + +class CohortTemplate(BaseModel): + name: str + description: str + criteria: CohortCriteria \ No newline at end of file From 04d208c6ccc28ebfacfa3d0ec7d0205e695e7de2 Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 28 Feb 2024 13:56:42 +1100 Subject: [PATCH 075/161] Fix linting issues including; --- api/graphql/schema.py | 7 ++--- api/routes/cohort.py | 15 ++------- db/python/layers/__init__.py | 2 +- db/python/layers/cohort.py | 34 +++++++++++---------- db/python/tables/cohort.py | 9 +++--- models/models/cohort.py | 9 ++++-- test/test_sequencing_groups.py | 8 ++--- web/package-lock.json | 2 +- web/src/shared/components/Header/NavBar.tsx | 2 +- 9 files changed, 39 insertions(+), 49 deletions(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index baa20f3c3..066e4b9b3 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -18,28 +18,25 @@ from api.graphql.filters import GraphQLFilter, GraphQLMetaFilter from api.graphql.loaders import LoaderKeys, get_context from db.python import enum_tables - -from db.python.layers.assay import AssayLayer from db.python.layers.analysis import AnalysisLayer +from db.python.layers.assay import AssayLayer from db.python.layers.cohort import CohortLayer from db.python.layers.family import FamilyLayer from db.python.layers.sample import SampleLayer from db.python.layers.sequencing_group import SequencingGroupLayer - from db.python.tables.analysis import AnalysisFilter from db.python.tables.assay import AssayFilter from db.python.tables.cohort import CohortFilter from db.python.tables.project import ProjectPermissionsTable from db.python.tables.sample import SampleFilter from db.python.tables.sequencing_group import SequencingGroupFilter - from db.python.utils import GenericFilter from models.enums import AnalysisStatus from models.models import ( AnalysisInternal, AssayInternal, - Cohort, AuditLogInternal, + Cohort, FamilyInternal, ParticipantInternal, Project, diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 72d2a1996..78e4f106d 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -1,17 +1,11 @@ from typing import Any from fastapi import APIRouter -from pydantic import BaseModel from api.utils.db import Connection, get_project_write_connection - from db.python.layers.cohort import CohortLayer from db.python.tables.project import ProjectPermissionsTable - from models.models.cohort import CohortBody, CohortCriteria, CohortTemplate -from models.utils.sequencing_group_id_format import ( - sequencing_group_id_transform_to_raw_list, -) router = APIRouter(prefix='/cohort', tags=['cohort']) @@ -42,11 +36,7 @@ async def create_cohort_from_criteria( description=cohort_spec.description, author=connection.author, cohort_name=cohort_spec.name, - sg_ids_internal=sequencing_group_id_transform_to_raw_list(cohort_criteria.sg_ids_internal), - exlude_sg_ids_internal=sequencing_group_id_transform_to_raw_list(cohort_criteria.excluded_sgs_internal), - sg_technology=cohort_criteria.sg_technology, - sg_platform=cohort_criteria.sg_platform, - sg_type=cohort_criteria.sg_type, + cohort_criteria=cohort_criteria ) return {'cohort_id': cohort_id} @@ -64,8 +54,7 @@ async def create_cohort_template( if not connection.project: raise ValueError('A cohort template must belong to a project') - + return await cohortlayer.create_cohort_template( cohort_template=template, ) - diff --git a/db/python/layers/__init__.py b/db/python/layers/__init__.py index 04dff8dfe..8eb00d2a5 100644 --- a/db/python/layers/__init__.py +++ b/db/python/layers/__init__.py @@ -2,8 +2,8 @@ from db.python.layers.assay import AssayLayer from db.python.layers.audit_log import AuditLogLayer from db.python.layers.base import BaseLayer -from db.python.layers.cohort import CohortLayer from db.python.layers.billing import BillingLayer +from db.python.layers.cohort import CohortLayer from db.python.layers.family import FamilyLayer from db.python.layers.participant import ParticipantLayer from db.python.layers.sample import SampleLayer diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 6e2869f75..916732289 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -1,18 +1,19 @@ -from models.models.cohort import Cohort from db.python.connect import Connection - from db.python.layers.base import BaseLayer from db.python.layers.sequencing_group import SequencingGroupLayer - from db.python.tables.analysis import AnalysisTable from db.python.tables.cohort import CohortFilter, CohortTable from db.python.tables.project import ProjectId from db.python.tables.sample import SampleTable -from db.python.tables.sequencing_group import SequencingGroupTable, SequencingGroupFilter - +from db.python.tables.sequencing_group import ( + SequencingGroupFilter, + SequencingGroupTable, +) from db.python.utils import GenericFilter, get_logger - -from models.models.cohort import CohortTemplate +from models.models.cohort import Cohort, CohortCriteria, CohortTemplate +from models.utils.sequencing_group_id_format import ( + sequencing_group_id_transform_to_raw_list, +) logger = get_logger() @@ -39,7 +40,7 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: Get the sequencing group IDs for the given cohort. """ return await self.ct.get_cohort_sequencing_group_ids(cohort_id) - + async def create_cohort_template( self, cohort_template: CohortTemplate, @@ -54,7 +55,6 @@ async def create_cohort_template( description=cohort_template.description, criteria=dict(cohort_template.criteria), ) - async def create_cohort_from_criteria( self, @@ -63,16 +63,19 @@ async def create_cohort_from_criteria( author: str, description: str, cohort_name: str, - sg_ids_internal: list[int] | None = None, - excluded_sgs_internal: list[int] | None = None, - sg_technology: list[str] | None = None, - sg_platform: list[str] | None = None, - sg_type: list[str] | None = None, + cohort_criteria: CohortCriteria, ): """ Create a new cohort from the given parameters. Returns the newly created cohort_id. """ + # Unpack criteria + sg_ids_internal = sequencing_group_id_transform_to_raw_list(cohort_criteria.sg_ids_internal) + excluded_sgs_internal = sequencing_group_id_transform_to_raw_list(cohort_criteria.excluded_sgs_internal) + sg_technology = cohort_criteria.sg_technology + sg_platform = cohort_criteria.sg_platform + sg_type = cohort_criteria.sg_type + # 1. Pull SG's based on criteria if sg_ids_internal and excluded_sgs_internal: sg_id_filter = GenericFilter(in_=sg_ids_internal, nin=excluded_sgs_internal) @@ -81,7 +84,7 @@ async def create_cohort_from_criteria( elif excluded_sgs_internal: sg_id_filter = GenericFilter(nin=excluded_sgs_internal) else: - sg_id_filter = None + sg_id_filter = None sgs = await self.sglayer.query( SequencingGroupFilter( @@ -92,7 +95,6 @@ async def create_cohort_from_criteria( type=GenericFilter(in_=sg_type) if sg_type else None, ) ) - print(sgs) # 2. Create Cohort cohort_id = await self.ct.create_cohort( diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index d8b5857d0..ca892426e 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -64,13 +64,12 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: """ rows = await self.connection.fetch_all(_query, {'cohort_id': cohort_id}) return [row['sequencing_group_id'] for row in rows] - + async def create_cohort_template( - self, + self, name: str, description: str, criteria: dict, - open_transaction: bool = True, ): """ Create new cohort template @@ -84,10 +83,10 @@ async def create_cohort_template( { 'name': name, 'description': description, - 'criteria':to_db_json(criteria), + 'criteria': to_db_json(criteria), }, ) - + return cohort_template_id async def create_cohort( diff --git a/models/models/cohort.py b/models/models/cohort.py index 94bf867d5..fd9a45ae5 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -1,7 +1,8 @@ +from pydantic import BaseModel + from models.base import SMBase from models.models.sequencing_group import SequencingGroup, SequencingGroupExternalId -from pydantic import BaseModel class Cohort(SMBase): """Model for Cohort""" @@ -37,6 +38,7 @@ def from_db(d: dict): sequencing_groups=sequencing_groups, ) + class CohortBody(BaseModel): """Represents the expected JSON body of the create cohort request""" @@ -55,7 +57,10 @@ class CohortCriteria(BaseModel): sg_platform: list[str] | None = None sg_type: list[str] | None = None + class CohortTemplate(BaseModel): + """ Represents a cohort template, to be used to build cohorts. """ + name: str description: str - criteria: CohortCriteria \ No newline at end of file + criteria: CohortCriteria diff --git a/test/test_sequencing_groups.py b/test/test_sequencing_groups.py index a045b7201..fa69f665d 100644 --- a/test/test_sequencing_groups.py +++ b/test/test_sequencing_groups.py @@ -1,16 +1,14 @@ from datetime import date - from test.testbase import DbIsolatedTest, run_as_sync -from db.python.layers import SampleLayer, SequencingGroupLayer +from db.python.layers import AnalysisLayer, SampleLayer, SequencingGroupLayer from db.python.tables.sequencing_group import SequencingGroupFilter -from db.python.layers import SequencingGroupLayer, SampleLayer, AnalysisLayer -from models.enums.analysis import AnalysisStatus from db.python.utils import GenericFilter +from models.enums.analysis import AnalysisStatus from models.models import ( + AnalysisInternal, AssayUpsertInternal, SampleUpsertInternal, - AnalysisInternal, SequencingGroupUpsertInternal, ) diff --git a/web/package-lock.json b/web/package-lock.json index 87e1ffa90..62926084c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12243,4 +12243,4 @@ } } } -} \ No newline at end of file +} diff --git a/web/src/shared/components/Header/NavBar.tsx b/web/src/shared/components/Header/NavBar.tsx index dcadc0cdb..8ee0f52b4 100644 --- a/web/src/shared/components/Header/NavBar.tsx +++ b/web/src/shared/components/Header/NavBar.tsx @@ -181,4 +181,4 @@ const NavBar: React.FC = ({ fixed }) => { ) } -export default NavBar \ No newline at end of file +export default NavBar From 1b9fe458bab356cf48297557f1c1a6025f63274e Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 28 Feb 2024 16:06:51 +1100 Subject: [PATCH 076/161] Add basic support for cohort creation from template --- api/routes/cohort.py | 13 +++---------- db/python/layers/cohort.py | 32 +++++++++++++++++++++++++++----- db/python/tables/cohort.py | 13 +++++++++++++ 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 78e4f106d..6c4b43236 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -4,7 +4,6 @@ from api.utils.db import Connection, get_project_write_connection from db.python.layers.cohort import CohortLayer -from db.python.tables.project import ProjectPermissionsTable from models.models.cohort import CohortBody, CohortCriteria, CohortTemplate router = APIRouter(prefix='/cohort', tags=['cohort']) @@ -13,8 +12,8 @@ @router.post('/{project}/cohort', operation_id='createCohortFromCriteria') async def create_cohort_from_criteria( cohort_spec: CohortBody, - cohort_criteria: CohortCriteria, connection: Connection = get_project_write_connection, + cohort_criteria: CohortCriteria = None, ) -> dict[str, Any]: """ Create a cohort with the given name and sample/sequencing group IDs. @@ -24,19 +23,13 @@ async def create_cohort_from_criteria( if not connection.project: raise ValueError('A cohort must belong to a project') - pt = ProjectPermissionsTable(connection) - projects_to_pull = await pt.get_and_check_access_to_projects_for_names( - user=connection.author, project_names=cohort_criteria.projects, readonly=True - ) - projects_to_pull = [p.id for p in projects_to_pull] - cohort_id = await cohortlayer.create_cohort_from_criteria( project_to_write=connection.project, - projects_to_pull=projects_to_pull, description=cohort_spec.description, author=connection.author, cohort_name=cohort_spec.name, - cohort_criteria=cohort_criteria + cohort_criteria=cohort_criteria, + template_id=cohort_spec.derived_from, ) return {'cohort_id': cohort_id} diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 916732289..675cc2c00 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -1,9 +1,11 @@ +import json + from db.python.connect import Connection from db.python.layers.base import BaseLayer from db.python.layers.sequencing_group import SequencingGroupLayer from db.python.tables.analysis import AnalysisTable from db.python.tables.cohort import CohortFilter, CohortTable -from db.python.tables.project import ProjectId +from db.python.tables.project import ProjectId, ProjectPermissionsTable from db.python.tables.sample import SampleTable from db.python.tables.sequencing_group import ( SequencingGroupFilter, @@ -27,6 +29,7 @@ def __init__(self, connection: Connection): self.sampt = SampleTable(connection) self.at = AnalysisTable(connection) self.ct = CohortTable(connection) + self.pt = ProjectPermissionsTable(connection) self.sgt = SequencingGroupTable(connection) self.sglayer = SequencingGroupLayer(self.connection) @@ -59,19 +62,38 @@ async def create_cohort_template( async def create_cohort_from_criteria( self, project_to_write: ProjectId, - projects_to_pull: list[ProjectId], author: str, description: str, cohort_name: str, - cohort_criteria: CohortCriteria, + cohort_criteria: CohortCriteria = None, + template_id: int = None, ): """ Create a new cohort from the given parameters. Returns the newly created cohort_id. """ + # Get template from ID + template: dict[str, str] = {} + if template_id: + template = await self.ct.get_cohort_template(template_id) + + # Only provide a template id + if template and not cohort_criteria: + criteria_dict = json.loads(template['criteria']) + cohort_criteria = CohortCriteria(**criteria_dict) + + projects_to_pull = await self.pt.get_and_check_access_to_projects_for_names( + user=self.connection.author, project_names=cohort_criteria.projects, readonly=True + ) + projects_to_pull = [p.id for p in projects_to_pull] + # Unpack criteria - sg_ids_internal = sequencing_group_id_transform_to_raw_list(cohort_criteria.sg_ids_internal) - excluded_sgs_internal = sequencing_group_id_transform_to_raw_list(cohort_criteria.excluded_sgs_internal) + sg_ids_internal = [] + excluded_sgs_internal = [] + if cohort_criteria.sg_ids_internal: + sg_ids_internal = sequencing_group_id_transform_to_raw_list(cohort_criteria.sg_ids_internal) + if cohort_criteria.excluded_sgs_internal: + excluded_sgs_internal = sequencing_group_id_transform_to_raw_list(cohort_criteria.excluded_sgs_internal) sg_technology = cohort_criteria.sg_technology sg_platform = cohort_criteria.sg_platform sg_type = cohort_criteria.sg_type diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index ca892426e..4aec1f3ea 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -65,6 +65,19 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: rows = await self.connection.fetch_all(_query, {'cohort_id': cohort_id}) return [row['sequencing_group_id'] for row in rows] + async def get_cohort_template(self, template_id: int): + """ + Get a cohort template by ID + """ + _query = """ + SELECT id as id, criteria as criteria FROM cohort_template WHERE id = :template_id + """ + template = await self.connection.fetch_one(_query, {'template_id': template_id}) + + print(template) + + return {'id': template['id'], 'criteria': template['criteria']} + async def create_cohort_template( self, name: str, From 960f026af86628ff2600137b6360bc500fabf4ea Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 28 Feb 2024 17:25:10 +1100 Subject: [PATCH 077/161] Support query template in graphql, including adding project column --- api/graphql/schema.py | 54 +++++++++++++++++++++++++++++++++++++- api/routes/cohort.py | 1 + db/project.xml | 7 +++++ db/python/layers/cohort.py | 9 ++++++- db/python/tables/cohort.py | 46 +++++++++++++++++++++++++++++--- models/models/__init__.py | 2 +- models/models/cohort.py | 30 +++++++++++++++++++++ 7 files changed, 143 insertions(+), 6 deletions(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 066e4b9b3..9d3c85d1d 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -26,7 +26,7 @@ from db.python.layers.sequencing_group import SequencingGroupLayer from db.python.tables.analysis import AnalysisFilter from db.python.tables.assay import AssayFilter -from db.python.tables.cohort import CohortFilter +from db.python.tables.cohort import CohortFilter, CohortTemplateFilter from db.python.tables.project import ProjectPermissionsTable from db.python.tables.sample import SampleFilter from db.python.tables.sequencing_group import SequencingGroupFilter @@ -37,6 +37,7 @@ AssayInternal, AuditLogInternal, Cohort, + CohortTemplateModel, FamilyInternal, ParticipantInternal, Project, @@ -111,6 +112,26 @@ async def project(self, info: Info, root: 'Cohort') -> 'GraphQLProject': project = await loader.load(root.project) return GraphQLProject.from_internal(project) +# Create cohort template GraphQL model +@strawberry.type +class GraphQLCohortTemplate: + """CohortTemplate GraphQL model""" + + id: int + name: str + description: str + criteria: strawberry.scalars.JSON + + @staticmethod + def from_internal(internal: CohortTemplateModel) -> 'GraphQLCohortTemplate': + return GraphQLCohortTemplate( + id=internal.id, + name=internal.name, + description=internal.description, + criteria=internal.criteria, + ) + + @strawberry.type class GraphQLProject: @@ -681,6 +702,37 @@ class Query: # entry point to graphql. def enum(self, info: Info) -> GraphQLEnum: return GraphQLEnum() + @strawberry.field() + async def cohort_template( + self, + info: Info, + id: GraphQLFilter[int] | None = None, + project: GraphQLFilter[str] | None = None, + ) -> list[GraphQLCohortTemplate]: + connection = info.context['connection'] + clayer = CohortLayer(connection) + + ptable = ProjectPermissionsTable(connection) + project_name_map: dict[str, int] = {} + project_filter = None + if project: + project_names = project.all_values() + projects = await ptable.get_and_check_access_to_projects_for_names( + user=connection.author, project_names=project_names, readonly=True + ) + project_name_map = {p.name: p.id for p in projects} + project_filter = project.to_internal_filter( + lambda pname: project_name_map[pname] + ) + + filter_ = CohortTemplateFilter( + id=id.to_internal_filter() if id else None, + project=project_filter, + ) + + cohort_templates = await clayer.query_cohort_templates(filter_) + return [GraphQLCohortTemplate.from_internal(cohort_template) for cohort_template in cohort_templates] + @strawberry.field() async def cohort( self, diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 6c4b43236..848b4ac3f 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -50,4 +50,5 @@ async def create_cohort_template( return await cohortlayer.create_cohort_template( cohort_template=template, + project=connection.project ) diff --git a/db/project.xml b/db/project.xml index 336f99924..c92b61998 100644 --- a/db/project.xml +++ b/db/project.xml @@ -1142,4 +1142,11 @@ + + + + + + + diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 675cc2c00..a9de3b51f 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -4,7 +4,7 @@ from db.python.layers.base import BaseLayer from db.python.layers.sequencing_group import SequencingGroupLayer from db.python.tables.analysis import AnalysisTable -from db.python.tables.cohort import CohortFilter, CohortTable +from db.python.tables.cohort import CohortFilter, CohortTable, CohortTemplateFilter from db.python.tables.project import ProjectId, ProjectPermissionsTable from db.python.tables.sample import SampleTable from db.python.tables.sequencing_group import ( @@ -38,6 +38,11 @@ async def query(self, filter_: CohortFilter) -> list[Cohort]: cohorts = await self.ct.query(filter_) return cohorts + async def query_cohort_templates(self, filter_: CohortTemplateFilter) -> list[CohortTemplate]: + """Query CohortTemplates""" + cohort_templates = await self.ct.query_cohort_templates(filter_) + return cohort_templates + async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: """ Get the sequencing group IDs for the given cohort. @@ -47,6 +52,7 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: async def create_cohort_template( self, cohort_template: CohortTemplate, + project: ProjectId, ): """ @@ -57,6 +63,7 @@ async def create_cohort_template( name=cohort_template.name, description=cohort_template.description, criteria=dict(cohort_template.criteria), + project=project ) async def create_cohort_from_criteria( diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 4aec1f3ea..03112a8c7 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -5,7 +5,7 @@ from db.python.tables.base import DbBase from db.python.tables.project import ProjectId from db.python.utils import GenericFilter, GenericFilterModel, to_db_json -from models.models.cohort import Cohort +from models.models.cohort import Cohort, CohortTemplateModel @dataclasses.dataclass(kw_only=True) @@ -22,6 +22,19 @@ class CohortFilter(GenericFilterModel): project: GenericFilter[ProjectId] | None = None +@dataclasses.dataclass(kw_only=True) +class CohortTemplateFilter(GenericFilterModel): + """ + Filters for CohortTemplate + """ + + id: GenericFilter[int] | None = None + name: GenericFilter[str] | None = None + description: GenericFilter[str] | None = None + criteria: GenericFilter[dict] | None = None + project: GenericFilter[ProjectId] | None = None + + class CohortTable(DbBase): """ Capture Cohort table operations and queries @@ -37,6 +50,14 @@ class CohortTable(DbBase): 'project', ] + template_keys = [ + 'id', + 'name', + 'description', + 'criteria', + 'project' + ] + async def query(self, filter_: CohortFilter): """Query Cohorts""" wheres, values = filter_.to_sql(field_overrides={}) @@ -65,6 +86,23 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: rows = await self.connection.fetch_all(_query, {'cohort_id': cohort_id}) return [row['sequencing_group_id'] for row in rows] + async def query_cohort_templates(self, filter_: CohortTemplateFilter): # TODO: Move this to its own class? + """Query CohortTemplates""" + wheres, values = filter_.to_sql(field_overrides={}) + if not wheres: + raise ValueError(f'Invalid filter: {filter_}') + + common_get_keys_str = ','.join(self.template_keys) + _query = f""" + SELECT {common_get_keys_str} + FROM cohort_template + WHERE {wheres} + """ + + rows = await self.connection.fetch_all(_query, values) + cohort_templates = [CohortTemplateModel.from_db(dict(row)) for row in rows] + return cohort_templates + async def get_cohort_template(self, template_id: int): """ Get a cohort template by ID @@ -83,13 +121,14 @@ async def create_cohort_template( name: str, description: str, criteria: dict, + project: ProjectId, ): """ Create new cohort template """ _query = """ - INSERT INTO cohort_template (name, description, criteria) - VALUES (:name, :description, :criteria) RETURNING id; + INSERT INTO cohort_template (name, description, criteria, project) + VALUES (:name, :description, :criteria, :project) RETURNING id; """ cohort_template_id = await self.connection.fetch_val( _query, @@ -97,6 +136,7 @@ async def create_cohort_template( 'name': name, 'description': description, 'criteria': to_db_json(criteria), + 'project': project, }, ) diff --git a/models/models/__init__.py b/models/models/__init__.py index 911974169..f9862ae80 100644 --- a/models/models/__init__.py +++ b/models/models/__init__.py @@ -20,7 +20,7 @@ BillingTotalCostQueryModel, BillingTotalCostRecord, ) -from models.models.cohort import Cohort +from models.models.cohort import Cohort, CohortTemplateModel from models.models.family import ( Family, FamilyInternal, diff --git a/models/models/cohort.py b/models/models/cohort.py index fd9a45ae5..bcb13532d 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -1,3 +1,5 @@ +import json + from pydantic import BaseModel from models.base import SMBase @@ -39,6 +41,34 @@ def from_db(d: dict): ) +class CohortTemplateModel(SMBase): + """Model for CohortTemplate""" + + id: int + name: str + description: str + criteria: dict + + @staticmethod + def from_db(d: dict): + """ + Convert from db keys, mainly converting id to id_ + """ + _id = d.pop('id', None) + name = d.pop('name', None) + description = d.pop('description', None) + criteria = d.pop('criteria', None) + if criteria and isinstance(criteria, str): + criteria = json.loads(criteria) + + return CohortTemplateModel( + id=_id, + name=name, + description=description, + criteria=criteria, + ) + + class CohortBody(BaseModel): """Represents the expected JSON body of the create cohort request""" From e9e2bf297f118eee98078fac9c3d642f89492673 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 4 Mar 2024 11:14:20 +1100 Subject: [PATCH 078/161] Handle cohort criteria and cohort template cases --- api/routes/cohort.py | 3 +++ db/python/layers/cohort.py | 8 ++++++++ models/models/cohort.py | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 848b4ac3f..4cbab4ca1 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -23,6 +23,9 @@ async def create_cohort_from_criteria( if not connection.project: raise ValueError('A cohort must belong to a project') + if not cohort_criteria and not cohort_spec.derived_from: + raise ValueError('A cohort must have either criteria or be derived from a template') + cohort_id = await cohortlayer.create_cohort_from_criteria( project_to_write=connection.project, description=cohort_spec.description, diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index a9de3b51f..10a09663c 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -79,6 +79,10 @@ async def create_cohort_from_criteria( Create a new cohort from the given parameters. Returns the newly created cohort_id. """ + # Input validation + if not cohort_criteria and not template_id: + raise ValueError('A cohort must have either criteria or be derived from a template') + # Get template from ID template: dict[str, str] = {} if template_id: @@ -89,6 +93,10 @@ async def create_cohort_from_criteria( criteria_dict = json.loads(template['criteria']) cohort_criteria = CohortCriteria(**criteria_dict) + if template and cohort_criteria: + # TODO: Handle this case. For now, not supported. + raise ValueError('A cohort cannot have both criteria and be derived from a template') + projects_to_pull = await self.pt.get_and_check_access_to_projects_for_names( user=self.connection.author, project_names=cohort_criteria.projects, readonly=True ) diff --git a/models/models/cohort.py b/models/models/cohort.py index bcb13532d..4eff716a1 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -80,7 +80,7 @@ class CohortBody(BaseModel): class CohortCriteria(BaseModel): """Represents the expected JSON body of the create cohort request""" - projects: list[str] + projects: list[str] | None = [] sg_ids_internal: list[str] | None = None excluded_sgs_internal: list[str] | None = None sg_technology: list[str] | None = None From 1e31f43e34d80e6a735c4f25c0280928f4411123 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 4 Mar 2024 11:55:49 +1100 Subject: [PATCH 079/161] Handle Templates in the layer and table --- db/python/layers/cohort.py | 19 +++++++++++++++++++ db/python/tables/cohort.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 10a09663c..e8c7401aa 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -79,6 +79,8 @@ async def create_cohort_from_criteria( Create a new cohort from the given parameters. Returns the newly created cohort_id. """ + create_cohort_template = True + # Input validation if not cohort_criteria and not template_id: raise ValueError('A cohort must have either criteria or be derived from a template') @@ -87,9 +89,12 @@ async def create_cohort_from_criteria( template: dict[str, str] = {} if template_id: template = await self.ct.get_cohort_template(template_id) + if not template: + raise ValueError(f'Cohort template with ID {template_id} not found') # Only provide a template id if template and not cohort_criteria: + create_cohort_template = False criteria_dict = json.loads(template['criteria']) cohort_criteria = CohortCriteria(**criteria_dict) @@ -133,6 +138,19 @@ async def create_cohort_from_criteria( ) ) + if create_cohort_template: + cohort_template = CohortTemplate( + name=cohort_name, + description=description, + criteria=cohort_criteria + ) + template_id = await self.create_cohort_template( + cohort_template=cohort_template, + project=project_to_write + ) + + assert template_id, 'Template ID must be set' + # 2. Create Cohort cohort_id = await self.ct.create_cohort( project=project_to_write, @@ -140,6 +158,7 @@ async def create_cohort_from_criteria( sequencing_group_ids=[sg.id for sg in sgs], description=description, author=author, + derived_from=template_id, ) return cohort_id diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 03112a8c7..bca814a19 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -149,7 +149,7 @@ async def create_cohort( sequencing_group_ids: list[int], author: str, description: str, - derived_from: int | None = None, + derived_from: int, ) -> int: """ Create a new cohort From e5ac19ca5c457425ddcd4ea7233d4f9c4a4cb6f3 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 4 Mar 2024 13:37:16 +1100 Subject: [PATCH 080/161] Add skeleton for create_custom_cohort script --- scripts/create_custom_cohort.py | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 scripts/create_custom_cohort.py diff --git a/scripts/create_custom_cohort.py b/scripts/create_custom_cohort.py new file mode 100644 index 000000000..a25e93bcd --- /dev/null +++ b/scripts/create_custom_cohort.py @@ -0,0 +1,73 @@ +""" A script to create a custom cohort """ +import argparse + +from metamist.apis import CohortApi +from metamist.models import CohortBody, CohortCriteria + + +def main( + project: str, + cohort_name: str, + cohort_description: str, + cohort_template_id: int, + projects: list[str], + sg_ids_internal: list[str], + excluded_sg_ids: list[str], + sg_technologies: list[str], + sg_platforms: list[str], + sg_types: list[str], +): + """ Create a custom cohort""" + + capi = CohortApi() + cohort_criteria = CohortCriteria( + projects=projects or [], + sg_ids_internal=sg_ids_internal or [], + excluded_sgs_internal=excluded_sg_ids or [], + sg_technology=sg_technologies or [], + sg_platform=sg_platforms or [], + sg_type=sg_types or [] + ) + + cohort_body_spec: dict[str, int | str] = {} + + if cohort_name: + cohort_body_spec['name'] = cohort_name + if cohort_description: + cohort_body_spec['description'] = cohort_description + if cohort_template_id: + cohort_body_spec['derived_from'] = cohort_template_id + + cohort_spec = CohortBody(**cohort_body_spec) + + capi.create_cohort_from_criteria(project=project, body_create_cohort_from_criteria={'cohort_spec': cohort_spec, 'cohort_criteria': cohort_criteria}) + + +if __name__ == '__main__': + + parser = argparse.ArgumentParser(description='Create a custom cohort') + parser.add_argument('--project', type=str, help='The project to create the cohort in') + parser.add_argument('--name', type=str, help='The name of the cohort') + parser.add_argument('--description', type=str, help='The description of the cohort') + parser.add_argument('--template_id', required=False, type=int, help='The template id of the cohort') + parser.add_argument('--projects', required=False, type=str, nargs='*', help='Pull sequencing groups from these projects') + parser.add_argument('--sg_ids_internal', required=False, type=list[str], help='Include the following sequencing groups') + parser.add_argument('--excluded_sgs_internal', required=False, type=list[str], help='Exclude the following sequencing groups') + parser.add_argument('--sg_technology', required=False, type=list[str], help='Sequencing group technologies') + parser.add_argument('--sg_platform', required=False, type=list[str], help='Sequencing group platforms') + parser.add_argument('--sg_type', required=False, type=list[str], help='Sequencing group types, e.g. exome, genome') + + args = parser.parse_args() + + main( + project=args.project, + cohort_name=args.name, + cohort_description=args.description, + cohort_template_id=args.template_id, + projects=args.projects, + sg_ids_internal=args.sg_ids_internal, + excluded_sg_ids=args.excluded_sgs_internal, + sg_technologies=args.sg_technology, + sg_platforms=args.sg_platform, + sg_types=args.sg_type + ) From 583004f749bfc1e3fd7409073695de027d964ed2 Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 5 Mar 2024 11:51:13 +1100 Subject: [PATCH 081/161] Add dry-run, refactor script to reduce args, fix bug that doesnt allow template to be specified --- api/routes/cohort.py | 6 ++++-- db/python/layers/cohort.py | 14 ++++++++----- scripts/create_custom_cohort.py | 36 ++++++++++++++++++++++++--------- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 4cbab4ca1..0a9cd66d6 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -14,6 +14,7 @@ async def create_cohort_from_criteria( cohort_spec: CohortBody, connection: Connection = get_project_write_connection, cohort_criteria: CohortCriteria = None, + dry_run: bool = False, ) -> dict[str, Any]: """ Create a cohort with the given name and sample/sequencing group IDs. @@ -26,16 +27,17 @@ async def create_cohort_from_criteria( if not cohort_criteria and not cohort_spec.derived_from: raise ValueError('A cohort must have either criteria or be derived from a template') - cohort_id = await cohortlayer.create_cohort_from_criteria( + cohort_output = await cohortlayer.create_cohort_from_criteria( project_to_write=connection.project, description=cohort_spec.description, author=connection.author, cohort_name=cohort_spec.name, + dry_run=dry_run, cohort_criteria=cohort_criteria, template_id=cohort_spec.derived_from, ) - return {'cohort_id': cohort_id} + return cohort_output @router.post('/{project}/cohort_template', operation_id='createCohortTemplate') diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index e8c7401aa..05b7ae5ad 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -72,6 +72,7 @@ async def create_cohort_from_criteria( author: str, description: str, cohort_name: str, + dry_run: bool, cohort_criteria: CohortCriteria = None, template_id: int = None, ): @@ -92,16 +93,16 @@ async def create_cohort_from_criteria( if not template: raise ValueError(f'Cohort template with ID {template_id} not found') + if template and cohort_criteria: + # TODO: Handle this case. For now, not supported. + raise ValueError('A cohort cannot have both criteria and be derived from a template') + # Only provide a template id if template and not cohort_criteria: create_cohort_template = False criteria_dict = json.loads(template['criteria']) cohort_criteria = CohortCriteria(**criteria_dict) - if template and cohort_criteria: - # TODO: Handle this case. For now, not supported. - raise ValueError('A cohort cannot have both criteria and be derived from a template') - projects_to_pull = await self.pt.get_and_check_access_to_projects_for_names( user=self.connection.author, project_names=cohort_criteria.projects, readonly=True ) @@ -151,6 +152,9 @@ async def create_cohort_from_criteria( assert template_id, 'Template ID must be set' + if dry_run: + return {'template_id': template_id, 'sequencing_group_ids': [sg.id for sg in sgs]} + # 2. Create Cohort cohort_id = await self.ct.create_cohort( project=project_to_write, @@ -161,4 +165,4 @@ async def create_cohort_from_criteria( derived_from=template_id, ) - return cohort_id + return {'cohort_id': cohort_id} diff --git a/scripts/create_custom_cohort.py b/scripts/create_custom_cohort.py index a25e93bcd..7d721396c 100644 --- a/scripts/create_custom_cohort.py +++ b/scripts/create_custom_cohort.py @@ -7,15 +7,14 @@ def main( project: str, - cohort_name: str, - cohort_description: str, - cohort_template_id: int, + cohort_body_spec: CohortBody, projects: list[str], sg_ids_internal: list[str], excluded_sg_ids: list[str], sg_technologies: list[str], sg_platforms: list[str], sg_types: list[str], + dry_run: bool = False ): """ Create a custom cohort""" @@ -29,6 +28,19 @@ def main( sg_type=sg_types or [] ) + capi.create_cohort_from_criteria( + project=project, + body_create_cohort_from_criteria={'cohort_spec': cohort_body_spec, 'cohort_criteria': cohort_criteria, 'dry_run': dry_run} + ) + + +def get_cohort_spec( + cohort_name: str, + cohort_description: str, + cohort_template_id: int +) -> CohortBody: + """ Get the cohort spec """ + cohort_body_spec: dict[str, int | str] = {} if cohort_name: @@ -38,9 +50,7 @@ def main( if cohort_template_id: cohort_body_spec['derived_from'] = cohort_template_id - cohort_spec = CohortBody(**cohort_body_spec) - - capi.create_cohort_from_criteria(project=project, body_create_cohort_from_criteria={'cohort_spec': cohort_spec, 'cohort_criteria': cohort_criteria}) + return CohortBody(**cohort_body_spec) if __name__ == '__main__': @@ -56,18 +66,24 @@ def main( parser.add_argument('--sg_technology', required=False, type=list[str], help='Sequencing group technologies') parser.add_argument('--sg_platform', required=False, type=list[str], help='Sequencing group platforms') parser.add_argument('--sg_type', required=False, type=list[str], help='Sequencing group types, e.g. exome, genome') + parser.add_argument('--dry_run', required=False, type=bool, help='Dry run mode') args = parser.parse_args() - main( - project=args.project, + cohort_spec = get_cohort_spec( cohort_name=args.name, cohort_description=args.description, - cohort_template_id=args.template_id, + cohort_template_id=args.template_id + ) + + main( + project=args.project, + cohort_body_spec=cohort_spec, projects=args.projects, sg_ids_internal=args.sg_ids_internal, excluded_sg_ids=args.excluded_sgs_internal, sg_technologies=args.sg_technology, sg_platforms=args.sg_platform, - sg_types=args.sg_type + sg_types=args.sg_type, + dry_run=args.dry_run ) From a8829b7200110869d32085a519508cc689448ada Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 5 Mar 2024 12:03:03 +1100 Subject: [PATCH 082/161] Return rich ids in dry mode --- db/python/layers/cohort.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 05b7ae5ad..00565df9f 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -14,6 +14,7 @@ from db.python.utils import GenericFilter, get_logger from models.models.cohort import Cohort, CohortCriteria, CohortTemplate from models.utils.sequencing_group_id_format import ( + sequencing_group_id_format_list, sequencing_group_id_transform_to_raw_list, ) @@ -153,7 +154,8 @@ async def create_cohort_from_criteria( assert template_id, 'Template ID must be set' if dry_run: - return {'template_id': template_id, 'sequencing_group_ids': [sg.id for sg in sgs]} + rich_ids = sequencing_group_id_format_list([sg.id for sg in sgs]) + return {'template_id': template_id, 'sequencing_group_ids': rich_ids} # 2. Create Cohort cohort_id = await self.ct.create_cohort( From a54a2569edbcee91f002dfe42491771e2eb3a625 Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 5 Mar 2024 12:08:56 +1100 Subject: [PATCH 083/161] Return rich ids when dry-run is false too --- db/python/layers/cohort.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 00565df9f..3cd1d846a 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -153,9 +153,10 @@ async def create_cohort_from_criteria( assert template_id, 'Template ID must be set' + rich_ids = sequencing_group_id_format_list([sg.id for sg in sgs]) + if dry_run: - rich_ids = sequencing_group_id_format_list([sg.id for sg in sgs]) - return {'template_id': template_id, 'sequencing_group_ids': rich_ids} + return {'dry_run': True, 'template_id': template_id, 'sequencing_group_ids': rich_ids} # 2. Create Cohort cohort_id = await self.ct.create_cohort( @@ -167,4 +168,4 @@ async def create_cohort_from_criteria( derived_from=template_id, ) - return {'cohort_id': cohort_id} + return {'cohort_id': cohort_id, 'sequencing_group_ids': rich_ids} From d95d4e45b5ef53dd77ebcc7419b7376768a1e4af Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 6 Mar 2024 13:54:41 +1100 Subject: [PATCH 084/161] Add support for rich custom cohort IDs i.e. COHXXX --- api/graphql/schema.py | 18 +++---- api/settings.py | 3 ++ db/python/layers/cohort.py | 3 +- models/utils/cohort_id_format.py | 81 ++++++++++++++++++++++++++++++++ scripts/create_custom_cohort.py | 4 +- 5 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 models/utils/cohort_id_format.py diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 9d3c85d1d..ba9258ce9 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -45,6 +45,7 @@ SequencingGroupInternal, ) from models.models.sample import sample_id_transform_to_raw +from models.utils.cohort_id_format import cohort_id_format, cohort_id_transform_to_raw from models.utils.sample_id_format import sample_id_format from models.utils.sequencing_group_id_format import ( sequencing_group_id_format, @@ -77,7 +78,7 @@ async def m(info: Info) -> list[str]: class GraphQLCohort: """Cohort GraphQL model""" - id: int + id: str name: str description: str author: str @@ -86,9 +87,8 @@ class GraphQLCohort: @staticmethod def from_internal(internal: Cohort) -> 'GraphQLCohort': return GraphQLCohort( - id=internal.id, + id=cohort_id_format(internal.id), name=internal.name, - project=internal.project, description=internal.description, author=internal.author, derived_from=internal.derived_from, @@ -100,7 +100,7 @@ async def sequencing_groups( ) -> list['GraphQLSequencingGroup']: connection = info.context['connection'] cohort_layer = CohortLayer(connection) - sg_ids = await cohort_layer.get_cohort_sequencing_group_ids(root.id) + sg_ids = await cohort_layer.get_cohort_sequencing_group_ids(cohort_id_transform_to_raw(root.id)) sg_layer = SequencingGroupLayer(connection) sequencing_groups = await sg_layer.get_sequencing_groups_by_ids(sg_ids) @@ -286,7 +286,7 @@ async def cohort( connection.project = root.id c_filter = CohortFilter( - id=id.to_internal_filter() if id else None, + id=id.to_internal_filter(cohort_id_transform_to_raw) if id else None, name=name.to_internal_filter() if name else None, author=author.to_internal_filter() if author else None, derived_from=derived_from.to_internal_filter() if derived_from else None, @@ -737,7 +737,7 @@ async def cohort_template( async def cohort( self, info: Info, - id: GraphQLFilter[int] | None = None, + id: GraphQLFilter[str] | None = None, project: GraphQLFilter[str] | None = None, name: GraphQLFilter[str] | None = None, author: GraphQLFilter[str] | None = None, @@ -760,15 +760,15 @@ async def cohort( ) filter_ = CohortFilter( - id=id.to_internal_filter() if id else None, + id=id.to_internal_filter(cohort_id_transform_to_raw) if id else None, name=name.to_internal_filter() if name else None, project=project_filter, author=author.to_internal_filter() if author else None, derived_from=derived_from.to_internal_filter() if derived_from else None, ) - cohort = await clayer.query(filter_) - return cohort + cohorts = await clayer.query(filter_) + return [GraphQLCohort.from_internal(cohort) for cohort in cohorts] @strawberry.field() async def project(self, info: Info, name: str) -> GraphQLProject: diff --git a/api/settings.py b/api/settings.py index 98d11062f..b769e901a 100644 --- a/api/settings.py +++ b/api/settings.py @@ -36,6 +36,9 @@ SEQUENCING_GROUP_PREFIX = os.getenv('SM_SEQUENCINGGROUPPREFIX', 'CPGLCL').upper() SEQUENCING_GROUP_CHECKSUM_OFFSET = int(os.getenv('SM_SEQUENCINGGROUPCHECKOFFSET', '9')) +COHORT_PREFIX = os.getenv('SM_COHORTPREFIX', 'COH').upper() +COHORT_CHECKSUM_OFFSET = int(os.getenv('SM_COHORTCHECKOFFSET', '5')) + # billing settings BQ_AGGREG_VIEW = os.getenv('SM_GCP_BQ_AGGREG_VIEW') BQ_AGGREG_RAW = os.getenv('SM_GCP_BQ_AGGREG_RAW') diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 3cd1d846a..d19d8da10 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -13,6 +13,7 @@ ) from db.python.utils import GenericFilter, get_logger from models.models.cohort import Cohort, CohortCriteria, CohortTemplate +from models.utils.cohort_id_format import cohort_id_format from models.utils.sequencing_group_id_format import ( sequencing_group_id_format_list, sequencing_group_id_transform_to_raw_list, @@ -168,4 +169,4 @@ async def create_cohort_from_criteria( derived_from=template_id, ) - return {'cohort_id': cohort_id, 'sequencing_group_ids': rich_ids} + return {'cohort_id': cohort_id_format(cohort_id), 'sequencing_group_ids': rich_ids} diff --git a/models/utils/cohort_id_format.py b/models/utils/cohort_id_format.py new file mode 100644 index 000000000..d67497062 --- /dev/null +++ b/models/utils/cohort_id_format.py @@ -0,0 +1,81 @@ +from typing import Iterable + +from api.settings import COHORT_CHECKSUM_OFFSET, COHORT_PREFIX +from models.utils.luhn import luhn_compute, luhn_is_valid + + +def cohort_id_format(cohort_id: int | str) -> str: + """ + Transform raw (int) cohort identifier to format (COHXXX) where: + - COH is the prefix + - XXX is the original identifier + - H is the Luhn checksum + """ + # Validate input + if isinstance(cohort_id, str) and not cohort_id.isdigit(): + if cohort_id.startswith(COHORT_PREFIX): + return cohort_id + raise ValueError(f'Unexpected format for cohort identifier {cohort_id!r}') + + cohort_id = int(cohort_id) + + checksum = luhn_compute( + cohort_id, offset=COHORT_CHECKSUM_OFFSET + ) + + return f'{COHORT_PREFIX}{cohort_id}{checksum}' + + +def cohort_id_format_list(cohort_ids: Iterable[int | str]) -> list[str]: + """ + Transform LIST of raw (int) cohort identifier to format (COHXXX) where: + - COH is the prefix + - XXX is the original identifier + - H is the Luhn checksum + """ + return [cohort_id_format(s) for s in cohort_ids] + + +def cohort_id_transform_to_raw(cohort_id: int | str) -> int: + """ + Transform STRING cohort identifier (COHXXXH) to XXX by: + - validating prefix + - validating checksum + """ + expected_type = str + if not isinstance(cohort_id, expected_type): # type: ignore + raise TypeError( + f'Expected identifier type to be {expected_type!r}, received {type(cohort_id)!r}' + ) + + if isinstance(cohort_id, int): + return cohort_id + + if not isinstance(cohort_id, str): + raise ValueError('Programming error related to cohort checks') + + if not cohort_id.startswith(COHORT_PREFIX): + raise ValueError( + f'Invalid prefix found for {COHORT_PREFIX} cohort identifier {cohort_id!r}' + ) + + stripped_identifier = cohort_id.lstrip(COHORT_PREFIX) + if not stripped_identifier.isdigit(): + raise ValueError(f'Invalid cohort identifier {cohort_id!r}') + + cohort_id_with_checksum = int(stripped_identifier) + if not luhn_is_valid(cohort_id_with_checksum, offset=COHORT_CHECKSUM_OFFSET): + raise ValueError(f'The provided cohort ID was not valid: {cohort_id!r}') + + return int(stripped_identifier[:-1]) + + +def cohort_id_transform_to_raw_list( + identifier: Iterable[int | str] +) -> list[int]: + """ + Transform LIST of STRING cohort identifier (COHXXXH) to XXX by: + - validating prefix + - validating checksum + """ + return [cohort_id_transform_to_raw(s) for s in identifier] diff --git a/scripts/create_custom_cohort.py b/scripts/create_custom_cohort.py index 7d721396c..612ebc85d 100644 --- a/scripts/create_custom_cohort.py +++ b/scripts/create_custom_cohort.py @@ -28,11 +28,13 @@ def main( sg_type=sg_types or [] ) - capi.create_cohort_from_criteria( + cohort = capi.create_cohort_from_criteria( project=project, body_create_cohort_from_criteria={'cohort_spec': cohort_body_spec, 'cohort_criteria': cohort_criteria, 'dry_run': dry_run} ) + print(f'Awesome! You have created a custom cohort with id {cohort["cohort_id"]}') + def get_cohort_spec( cohort_name: str, From 81f4a5b5a7788e48fd20066a1115c165658acb0d Mon Sep 17 00:00:00 2001 From: John Marshall Date: Mon, 4 Mar 2024 14:32:05 +1300 Subject: [PATCH 085/161] Add initial basic create_cohort_from_criteria() tests --- test/test_cohort.py | 75 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 test/test_cohort.py diff --git a/test/test_cohort.py b/test/test_cohort.py new file mode 100644 index 000000000..a3dbd3dba --- /dev/null +++ b/test/test_cohort.py @@ -0,0 +1,75 @@ +from test.testbase import DbIsolatedTest, run_as_sync + +from pymysql.err import IntegrityError + +from db.python.layers import CohortLayer +from models.models.cohort import CohortCriteria + + +class TestCohort(DbIsolatedTest): + """Test custom cohort endpoints""" + + @run_as_sync + async def setUp(self): + super().setUp() + self.cohortl = CohortLayer(self.connection) + + @run_as_sync + async def test_create_cohort_missing_args(self): + """Can't create cohort with neither criteria nor template""" + with self.assertRaises(ValueError): + _ = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='No criteria or template', + cohort_name='Borken cohort', + dry_run=False, + ) + + @run_as_sync + async def test_create_empty_cohort(self): + """Create cohort from empty criteria""" + result = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Cohort with no entries', + cohort_name='Empty cohort', + dry_run=False, + cohort_criteria=CohortCriteria( + projects=['test'] + ) + ) + self.assertIsInstance(result, dict) + self.assertIsInstance(result['cohort_id'], str) + self.assertEqual([], result['sequencing_group_ids']) + + @run_as_sync + async def test_create_duplicate_cohort(self): + """Can't create cohorts with duplicate names""" + _ = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Cohort with no entries', + cohort_name='Trial duplicate cohort', + dry_run=False, + cohort_criteria=CohortCriteria(projects=['test']) + ) + + _ = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Cohort with no entries', + cohort_name='Trial duplicate cohort', + dry_run=True, + cohort_criteria=CohortCriteria(projects=['test']) + ) + + with self.assertRaises(IntegrityError): + _ = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Cohort with no entries', + cohort_name='Trial duplicate cohort', + dry_run=False, + cohort_criteria=CohortCriteria(projects=['test']) + ) From 04149404f8f72163f6cbbacd90adc9a34863b09e Mon Sep 17 00:00:00 2001 From: John Marshall Date: Wed, 13 Mar 2024 16:29:15 +1300 Subject: [PATCH 086/161] Surely incorrect fix for `Field 'timestamp' doesn't have a default value` --- db/python/tables/cohort.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index bca814a19..471bfdea6 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -159,8 +159,8 @@ async def create_cohort( # left in an incomplete state if the query fails part way through. async with self.connection.transaction(): _query = """ - INSERT INTO cohort (name, derived_from, author, description, project) - VALUES (:name, :derived_from, :author, :description, :project) RETURNING id + INSERT INTO cohort (name, derived_from, author, description, project, timestamp) + VALUES (:name, :derived_from, :author, :description, :project, NULL) RETURNING id """ cohort_id = await self.connection.fetch_val( From 646740f84053c9814608489ec5a988cc32d2008f Mon Sep 17 00:00:00 2001 From: vivbak Date: Thu, 14 Mar 2024 11:13:01 +1100 Subject: [PATCH 087/161] Fix failing tests, move cohort creation to after dry run exit --- db/python/layers/cohort.py | 13 +++++++------ test/test_cohort.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index d19d8da10..787c51169 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -141,6 +141,12 @@ async def create_cohort_from_criteria( ) ) + rich_ids = sequencing_group_id_format_list([sg.id for sg in sgs]) + + if dry_run: + return {'dry_run': True, 'template_id': template_id or 'CREATE NEW', 'sequencing_group_ids': rich_ids} + + # 2. Create cohort template, if required. if create_cohort_template: cohort_template = CohortTemplate( name=cohort_name, @@ -154,12 +160,7 @@ async def create_cohort_from_criteria( assert template_id, 'Template ID must be set' - rich_ids = sequencing_group_id_format_list([sg.id for sg in sgs]) - - if dry_run: - return {'dry_run': True, 'template_id': template_id, 'sequencing_group_ids': rich_ids} - - # 2. Create Cohort + # 3. Create Cohort cohort_id = await self.ct.create_cohort( project=project_to_write, cohort_name=cohort_name, diff --git a/test/test_cohort.py b/test/test_cohort.py index a3dbd3dba..02b384d58 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -22,7 +22,7 @@ async def test_create_cohort_missing_args(self): project_to_write=self.project_id, author='bob@example.org', description='No criteria or template', - cohort_name='Borken cohort', + cohort_name='Broken cohort', dry_run=False, ) From 78df334117c83727679fefcc70effe0ee425d8c4 Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 19 Mar 2024 15:02:10 +1100 Subject: [PATCH 088/161] Rename derived_from to template_id, in line with user feedback, to add clarity --- api/graphql/schema.py | 12 ++++++------ api/routes/cohort.py | 4 ++-- db/project.xml | 9 +++++++++ db/python/layers/cohort.py | 2 +- db/python/tables/cohort.py | 12 ++++++------ models/models/cohort.py | 9 +++++---- scripts/create_custom_cohort.py | 2 +- 7 files changed, 30 insertions(+), 20 deletions(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index ba9258ce9..0fa10354c 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -82,7 +82,7 @@ class GraphQLCohort: name: str description: str author: str - derived_from: int | None = None + template_id: int | None = None @staticmethod def from_internal(internal: Cohort) -> 'GraphQLCohort': @@ -91,7 +91,7 @@ def from_internal(internal: Cohort) -> 'GraphQLCohort': name=internal.name, description=internal.description, author=internal.author, - derived_from=internal.derived_from, + template_id=internal.template_id, ) @strawberry.field() @@ -279,7 +279,7 @@ async def cohort( id: GraphQLFilter[int] | None = None, name: GraphQLFilter[str] | None = None, author: GraphQLFilter[str] | None = None, - derived_from: GraphQLFilter[int] | None = None, + template_id: GraphQLFilter[int] | None = None, timestamp: GraphQLFilter[datetime.datetime] | None = None, ) -> list['GraphQLCohort']: connection = info.context['connection'] @@ -289,7 +289,7 @@ async def cohort( id=id.to_internal_filter(cohort_id_transform_to_raw) if id else None, name=name.to_internal_filter() if name else None, author=author.to_internal_filter() if author else None, - derived_from=derived_from.to_internal_filter() if derived_from else None, + template_id=template_id.to_internal_filter() if template_id else None, timestamp=timestamp.to_internal_filter() if timestamp else None, ) @@ -741,7 +741,7 @@ async def cohort( project: GraphQLFilter[str] | None = None, name: GraphQLFilter[str] | None = None, author: GraphQLFilter[str] | None = None, - derived_from: GraphQLFilter[int] | None = None, + template_id: GraphQLFilter[int] | None = None, ) -> list[GraphQLCohort]: connection = info.context['connection'] clayer = CohortLayer(connection) @@ -764,7 +764,7 @@ async def cohort( name=name.to_internal_filter() if name else None, project=project_filter, author=author.to_internal_filter() if author else None, - derived_from=derived_from.to_internal_filter() if derived_from else None, + template_id=template_id.to_internal_filter() if template_id else None, ) cohorts = await clayer.query(filter_) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 0a9cd66d6..7ada8b974 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -24,7 +24,7 @@ async def create_cohort_from_criteria( if not connection.project: raise ValueError('A cohort must belong to a project') - if not cohort_criteria and not cohort_spec.derived_from: + if not cohort_criteria and not cohort_spec.template_id: raise ValueError('A cohort must have either criteria or be derived from a template') cohort_output = await cohortlayer.create_cohort_from_criteria( @@ -34,7 +34,7 @@ async def create_cohort_from_criteria( cohort_name=cohort_spec.name, dry_run=dry_run, cohort_criteria=cohort_criteria, - template_id=cohort_spec.derived_from, + template_id=cohort_spec.template_id, ) return cohort_output diff --git a/db/project.xml b/db/project.xml index c92b61998..35e7141a4 100644 --- a/db/project.xml +++ b/db/project.xml @@ -1149,4 +1149,13 @@ + + + diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 787c51169..0406a7cef 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -167,7 +167,7 @@ async def create_cohort_from_criteria( sequencing_group_ids=[sg.id for sg in sgs], description=description, author=author, - derived_from=template_id, + template_id=template_id, ) return {'cohort_id': cohort_id_format(cohort_id), 'sequencing_group_ids': rich_ids} diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 471bfdea6..da01d9f57 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -17,7 +17,7 @@ class CohortFilter(GenericFilterModel): id: GenericFilter[int] | None = None name: GenericFilter[str] | None = None author: GenericFilter[str] | None = None - derived_from: GenericFilter[int] | None = None + template_id: GenericFilter[int] | None = None timestamp: GenericFilter[datetime.datetime] | None = None project: GenericFilter[ProjectId] | None = None @@ -44,7 +44,7 @@ class CohortTable(DbBase): common_get_keys = [ 'id', 'name', - 'derived_from', + 'template_id', 'description', 'author', 'project', @@ -149,7 +149,7 @@ async def create_cohort( sequencing_group_ids: list[int], author: str, description: str, - derived_from: int, + template_id: int, ) -> int: """ Create a new cohort @@ -159,14 +159,14 @@ async def create_cohort( # left in an incomplete state if the query fails part way through. async with self.connection.transaction(): _query = """ - INSERT INTO cohort (name, derived_from, author, description, project, timestamp) - VALUES (:name, :derived_from, :author, :description, :project, NULL) RETURNING id + INSERT INTO cohort (name, template_id, author, description, project, timestamp) + VALUES (:name, :template_id, :author, :description, :project, NULL) RETURNING id """ cohort_id = await self.connection.fetch_val( _query, { - 'derived_from': derived_from, + 'template_id': template_id, 'author': author, 'description': description, 'project': project, diff --git a/models/models/cohort.py b/models/models/cohort.py index 4eff716a1..91e01fa26 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -14,7 +14,7 @@ class Cohort(SMBase): author: str project: str description: str - derived_from: int | None + template_id: int | None sequencing_groups: list[SequencingGroup | SequencingGroupExternalId] @staticmethod @@ -27,7 +27,7 @@ def from_db(d: dict): description = d.pop('description', None) name = d.pop('name', None) author = d.pop('author', None) - derived_from = d.pop('derived_from', None) + template_id = d.pop('template_id', None) sequencing_groups = d.pop('sequencing_groups', []) return Cohort( @@ -36,7 +36,7 @@ def from_db(d: dict): author=author, project=project, description=description, - derived_from=derived_from, + template_id=template_id, sequencing_groups=sequencing_groups, ) @@ -74,7 +74,7 @@ class CohortBody(BaseModel): name: str description: str - derived_from: int | None = None + template_id: int | None = None class CohortCriteria(BaseModel): @@ -86,6 +86,7 @@ class CohortCriteria(BaseModel): sg_technology: list[str] | None = None sg_platform: list[str] | None = None sg_type: list[str] | None = None + # TODO: Sample type as well. class CohortTemplate(BaseModel): diff --git a/scripts/create_custom_cohort.py b/scripts/create_custom_cohort.py index 612ebc85d..2adb20b97 100644 --- a/scripts/create_custom_cohort.py +++ b/scripts/create_custom_cohort.py @@ -50,7 +50,7 @@ def get_cohort_spec( if cohort_description: cohort_body_spec['description'] = cohort_description if cohort_template_id: - cohort_body_spec['derived_from'] = cohort_template_id + cohort_body_spec['template_id'] = cohort_template_id return CohortBody(**cohort_body_spec) From 8ca7d355565021598a39a35be4416a83fb32a209 Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 19 Mar 2024 15:42:33 +1100 Subject: [PATCH 089/161] Refactor generating sg_filter, to make room for supporting more inputs --- db/python/layers/cohort.py | 69 ++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 0406a7cef..5d544be42 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -22,6 +22,37 @@ logger = get_logger() +def get_sg_filter(projects, sg_ids_internal_rich, excluded_sgs_internal_rich, sg_technology, sg_platform, sg_type): + """ Get the sequencing group filter for cohort attributes""" + + # Format inputs for filter + sg_ids_internal_raw = [] + excluded_sgs_internal_raw = [] + if sg_ids_internal_rich: + sg_ids_internal_raw = sequencing_group_id_transform_to_raw_list(sg_ids_internal_rich) + if excluded_sgs_internal_rich: + excluded_sgs_internal_raw = sequencing_group_id_transform_to_raw_list(excluded_sgs_internal_rich) + + if sg_ids_internal_raw and excluded_sgs_internal_raw: + sg_id_filter = GenericFilter(in_=sg_ids_internal_raw, nin=excluded_sgs_internal_raw) + elif sg_ids_internal_raw: + sg_id_filter = GenericFilter(in_=sg_ids_internal_raw) + elif excluded_sgs_internal_raw: + sg_id_filter = GenericFilter(nin=excluded_sgs_internal_raw) + else: + sg_id_filter = None + + sg_filter = SequencingGroupFilter( + project=GenericFilter(in_=projects), + id=sg_id_filter, + technology=GenericFilter(in_=sg_technology) if sg_technology else None, + platform=GenericFilter(in_=sg_platform) if sg_platform else None, + type=GenericFilter(in_=sg_type) if sg_type else None, + ) + + return sg_filter + + class CohortLayer(BaseLayer): """Layer for cohort logic""" @@ -110,37 +141,17 @@ async def create_cohort_from_criteria( ) projects_to_pull = [p.id for p in projects_to_pull] - # Unpack criteria - sg_ids_internal = [] - excluded_sgs_internal = [] - if cohort_criteria.sg_ids_internal: - sg_ids_internal = sequencing_group_id_transform_to_raw_list(cohort_criteria.sg_ids_internal) - if cohort_criteria.excluded_sgs_internal: - excluded_sgs_internal = sequencing_group_id_transform_to_raw_list(cohort_criteria.excluded_sgs_internal) - sg_technology = cohort_criteria.sg_technology - sg_platform = cohort_criteria.sg_platform - sg_type = cohort_criteria.sg_type - - # 1. Pull SG's based on criteria - if sg_ids_internal and excluded_sgs_internal: - sg_id_filter = GenericFilter(in_=sg_ids_internal, nin=excluded_sgs_internal) - elif sg_ids_internal: - sg_id_filter = GenericFilter(in_=sg_ids_internal) - elif excluded_sgs_internal: - sg_id_filter = GenericFilter(nin=excluded_sgs_internal) - else: - sg_id_filter = None - - sgs = await self.sglayer.query( - SequencingGroupFilter( - project=GenericFilter(in_=projects_to_pull), - id=sg_id_filter, - technology=GenericFilter(in_=sg_technology) if sg_technology else None, - platform=GenericFilter(in_=sg_platform) if sg_platform else None, - type=GenericFilter(in_=sg_type) if sg_type else None, - ) + sg_filter = get_sg_filter( + projects=projects_to_pull, + sg_ids_internal_rich=cohort_criteria.sg_ids_internal, + excluded_sgs_internal_rich=cohort_criteria.excluded_sgs_internal, + sg_technology=cohort_criteria.sg_technology, + sg_platform=cohort_criteria.sg_platform, + sg_type=cohort_criteria.sg_type ) + sgs = await self.sglayer.query(sg_filter) + rich_ids = sequencing_group_id_format_list([sg.id for sg in sgs]) if dry_run: From e265981d5ed37f9fcf9147d480afcf0d75757f02 Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 20 Mar 2024 08:38:09 +1100 Subject: [PATCH 090/161] Fix FK issue in project.xml, delete old, add new --- db/project.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/db/project.xml b/db/project.xml index 35e7141a4..b1a1ca0f2 100644 --- a/db/project.xml +++ b/db/project.xml @@ -1158,4 +1158,20 @@ tableName="cohort" /> + + + + + + + From d2fa3275f0d10b295dfe9d50ba87af238ce37a21 Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 20 Mar 2024 09:13:06 +1100 Subject: [PATCH 091/161] Handle Sample Type as Cohort Criteria --- db/python/layers/cohort.py | 24 +++++++++++++++++++++--- models/models/cohort.py | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 5d544be42..cdea60737 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -6,7 +6,7 @@ from db.python.tables.analysis import AnalysisTable from db.python.tables.cohort import CohortFilter, CohortTable, CohortTemplateFilter from db.python.tables.project import ProjectId, ProjectPermissionsTable -from db.python.tables.sample import SampleTable +from db.python.tables.sample import SampleFilter, SampleTable from db.python.tables.sequencing_group import ( SequencingGroupFilter, SequencingGroupTable, @@ -22,7 +22,15 @@ logger = get_logger() -def get_sg_filter(projects, sg_ids_internal_rich, excluded_sgs_internal_rich, sg_technology, sg_platform, sg_type): +def get_sg_filter( + projects, + sg_ids_internal_rich, + excluded_sgs_internal_rich, + sg_technology, + sg_platform, + sg_type, + sample_ids +): """ Get the sequencing group filter for cohort attributes""" # Format inputs for filter @@ -48,6 +56,7 @@ def get_sg_filter(projects, sg_ids_internal_rich, excluded_sgs_internal_rich, sg technology=GenericFilter(in_=sg_technology) if sg_technology else None, platform=GenericFilter(in_=sg_platform) if sg_platform else None, type=GenericFilter(in_=sg_type) if sg_type else None, + sample_id=GenericFilter(in_=sample_ids) if sample_ids else None, ) return sg_filter @@ -141,13 +150,22 @@ async def create_cohort_from_criteria( ) projects_to_pull = [p.id for p in projects_to_pull] + # Get sample IDs with sample type + sample_filter = SampleFilter( + project=GenericFilter(in_=projects_to_pull), + type=GenericFilter(in_=cohort_criteria.sample_type) if cohort_criteria.sample_type else None, + ) + + _, samples = await self.sampt.query(sample_filter) + sg_filter = get_sg_filter( projects=projects_to_pull, sg_ids_internal_rich=cohort_criteria.sg_ids_internal, excluded_sgs_internal_rich=cohort_criteria.excluded_sgs_internal, sg_technology=cohort_criteria.sg_technology, sg_platform=cohort_criteria.sg_platform, - sg_type=cohort_criteria.sg_type + sg_type=cohort_criteria.sg_type, + sample_ids=[s.id for s in samples] ) sgs = await self.sglayer.query(sg_filter) diff --git a/models/models/cohort.py b/models/models/cohort.py index 91e01fa26..164ae6348 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -86,7 +86,7 @@ class CohortCriteria(BaseModel): sg_technology: list[str] | None = None sg_platform: list[str] | None = None sg_type: list[str] | None = None - # TODO: Sample type as well. + sample_type: list[str] | None = None class CohortTemplate(BaseModel): From 672d751d987c2ef23a33c608b0d2382b0b9fe8cf Mon Sep 17 00:00:00 2001 From: John Marshall Date: Fri, 15 Mar 2024 09:34:24 +1300 Subject: [PATCH 092/161] D'oh, blackify those parentheses --- test/test_cohort.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/test/test_cohort.py b/test/test_cohort.py index 02b384d58..f245f6db7 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -24,7 +24,7 @@ async def test_create_cohort_missing_args(self): description='No criteria or template', cohort_name='Broken cohort', dry_run=False, - ) + ) @run_as_sync async def test_create_empty_cohort(self): @@ -35,10 +35,8 @@ async def test_create_empty_cohort(self): description='Cohort with no entries', cohort_name='Empty cohort', dry_run=False, - cohort_criteria=CohortCriteria( - projects=['test'] - ) - ) + cohort_criteria=CohortCriteria(projects=['test']), + ) self.assertIsInstance(result, dict) self.assertIsInstance(result['cohort_id'], str) self.assertEqual([], result['sequencing_group_ids']) @@ -52,8 +50,8 @@ async def test_create_duplicate_cohort(self): description='Cohort with no entries', cohort_name='Trial duplicate cohort', dry_run=False, - cohort_criteria=CohortCriteria(projects=['test']) - ) + cohort_criteria=CohortCriteria(projects=['test']), + ) _ = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, @@ -61,8 +59,8 @@ async def test_create_duplicate_cohort(self): description='Cohort with no entries', cohort_name='Trial duplicate cohort', dry_run=True, - cohort_criteria=CohortCriteria(projects=['test']) - ) + cohort_criteria=CohortCriteria(projects=['test']), + ) with self.assertRaises(IntegrityError): _ = await self.cohortl.create_cohort_from_criteria( @@ -71,5 +69,5 @@ async def test_create_duplicate_cohort(self): description='Cohort with no entries', cohort_name='Trial duplicate cohort', dry_run=False, - cohort_criteria=CohortCriteria(projects=['test']) - ) + cohort_criteria=CohortCriteria(projects=['test']), + ) From ab9cdb4e3886b13d67f2a103ee15c239d3d13005 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Mon, 18 Mar 2024 15:43:08 +1300 Subject: [PATCH 093/161] Add test case exercising template_id foreign key --- test/test_cohort.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/test_cohort.py b/test/test_cohort.py index f245f6db7..892f6f914 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -3,7 +3,7 @@ from pymysql.err import IntegrityError from db.python.layers import CohortLayer -from models.models.cohort import CohortCriteria +from models.models.cohort import CohortCriteria, CohortTemplate class TestCohort(DbIsolatedTest): @@ -71,3 +71,33 @@ async def test_create_duplicate_cohort(self): dry_run=False, cohort_criteria=CohortCriteria(projects=['test']), ) + + @run_as_sync + async def test_create_template_then_cohorts(self): + """Test with template and cohort IDs out of sync, and creating from template""" + tid = await self.cohortl.create_cohort_template( + project=self.project_id, + cohort_template=CohortTemplate( + name='Empty template', + description='Template with no entries', + criteria=CohortCriteria(projects=['test']), + ), + ) + + _ = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Cohort with no entries', + cohort_name='Another empty cohort', + dry_run=False, + cohort_criteria=CohortCriteria(projects=['test']), + ) + + _ = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Cohort from template', + cohort_name='Cohort from empty template', + dry_run=False, + template_id=tid, + ) From f1e67801fabe5301bfab94dfad4f7e8c7e7a47cf Mon Sep 17 00:00:00 2001 From: John Marshall Date: Wed, 20 Mar 2024 11:28:44 +1300 Subject: [PATCH 094/161] Remove debugging print --- db/python/tables/cohort.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index da01d9f57..71e1b8a47 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -112,8 +112,6 @@ async def get_cohort_template(self, template_id: int): """ template = await self.connection.fetch_one(_query, {'template_id': template_id}) - print(template) - return {'id': template['id'], 'criteria': template['criteria']} async def create_cohort_template( From 9829c2215fbd47c47b114081d489ce88d1b9c842 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Tue, 19 Mar 2024 11:03:47 +1300 Subject: [PATCH 095/161] Add tranche of tests that need some sample data --- test/test_cohort.py | 77 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/test/test_cohort.py b/test/test_cohort.py index 892f6f914..032c4c983 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -2,11 +2,13 @@ from pymysql.err import IntegrityError -from db.python.layers import CohortLayer +from db.python.layers import CohortLayer, SampleLayer +from models.models import SampleUpsertInternal, SequencingGroupUpsertInternal from models.models.cohort import CohortCriteria, CohortTemplate +from models.utils.sequencing_group_id_format import sequencing_group_id_format -class TestCohort(DbIsolatedTest): +class TestCohortBasic(DbIsolatedTest): """Test custom cohort endpoints""" @run_as_sync @@ -101,3 +103,74 @@ async def test_create_template_then_cohorts(self): dry_run=False, template_id=tid, ) + + +def get_sample_model(eid): + """Create a minimal sample""" + return SampleUpsertInternal( + meta={}, + external_id=f'EXID{eid}', + sequencing_groups=[ + SequencingGroupUpsertInternal( + type='genome', + technology='short-read', + platform='illumina', + meta={}, + assays=[], + ), + ], + ) + + +class TestCohortData(DbIsolatedTest): + """Test custom cohort endpoints that need some sequencing groups already set up""" + + @run_as_sync + async def setUp(self): + super().setUp() + self.cohortl = CohortLayer(self.connection) + self.samplel = SampleLayer(self.connection) + + self.sA = await self.samplel.upsert_sample(get_sample_model('A')) + self.sB = await self.samplel.upsert_sample(get_sample_model('B')) + self.sC = await self.samplel.upsert_sample(get_sample_model('C')) + + @run_as_sync + async def test_create_cohort_by_sgs(self): + """Create cohort by selecting sequencing groups""" + sgB = sequencing_group_id_format(self.sB.sequencing_groups[0].id) + result = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Cohort with 1 SG', + cohort_name='SG cohort 1', + dry_run=False, + cohort_criteria=CohortCriteria( + projects=['test'], + sg_ids_internal=[sgB], + ), + ) + self.assertIsInstance(result['cohort_id'], str) + self.assertEqual([sgB], result['sequencing_group_ids']) + + @run_as_sync + async def test_create_cohort_by_excluded_sgs(self): + """Create cohort by excluding sequencing groups""" + sgA = sequencing_group_id_format(self.sA.sequencing_groups[0].id) + sgB = sequencing_group_id_format(self.sB.sequencing_groups[0].id) + sgC = sequencing_group_id_format(self.sC.sequencing_groups[0].id) + result = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Cohort without 1 SG', + cohort_name='SG cohort 2', + dry_run=False, + cohort_criteria=CohortCriteria( + projects=['test'], + excluded_sgs_internal=[sgA], + ), + ) + self.assertIsInstance(result['cohort_id'], str) + self.assertEqual(2, len(result['sequencing_group_ids'])) + self.assertIn(sgB, result['sequencing_group_ids']) + self.assertIn(sgC, result['sequencing_group_ids']) From 69014cabd1bb5257e6dae85928eaff5ea00337e3 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Tue, 19 Mar 2024 15:19:30 +1300 Subject: [PATCH 096/161] Add cohort-related tables to SYSTEM VERSIONING and TABLES_ORDERED_BY_FK_DEPS This is required so that testbase.py can clear them out successfully. --- db/project.xml | 6 ++++++ db/python/connect.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/db/project.xml b/db/project.xml index b1a1ca0f2..498f4c3f3 100644 --- a/db/project.xml +++ b/db/project.xml @@ -1174,4 +1174,10 @@ referencedColumnNames="id" /> + + ALTER TABLE cohort_template ADD SYSTEM VERSIONING; + ALTER TABLE cohort ADD SYSTEM VERSIONING; + ALTER TABLE cohort_sequencing_group ADD SYSTEM VERSIONING; + ALTER TABLE analysis_cohort ADD SYSTEM VERSIONING; + diff --git a/db/python/connect.py b/db/python/connect.py index 4a5abcba4..46485def7 100644 --- a/db/python/connect.py +++ b/db/python/connect.py @@ -34,6 +34,10 @@ 'family_participant', 'participant_phenotypes', 'group_member', + 'cohort_template', + 'cohort', + 'cohort_sequencing_group', + 'analysis_cohort', ][::-1] From 903314dabdf07fc502e316f5a01d9c1c72babec5 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Thu, 21 Mar 2024 16:22:02 +1300 Subject: [PATCH 097/161] Set audit_log_id when INSERTing into cohort tables Add audit_log_id field to cohort_template; the others already have it. --- db/project.xml | 11 +++++++++++ db/python/tables/cohort.py | 17 +++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/db/project.xml b/db/project.xml index 8f927ae95..4a49190cb 100644 --- a/db/project.xml +++ b/db/project.xml @@ -1277,4 +1277,15 @@ ALTER TABLE cohort_sequencing_group ADD SYSTEM VERSIONING; ALTER TABLE analysis_cohort ADD SYSTEM VERSIONING; + + SET @@system_versioning_alter_history = 1; + + + + + + diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 71e1b8a47..cf6b5befb 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -125,8 +125,8 @@ async def create_cohort_template( Create new cohort template """ _query = """ - INSERT INTO cohort_template (name, description, criteria, project) - VALUES (:name, :description, :criteria, :project) RETURNING id; + INSERT INTO cohort_template (name, description, criteria, project, audit_log_id) + VALUES (:name, :description, :criteria, :project, :audit_log_id) RETURNING id; """ cohort_template_id = await self.connection.fetch_val( _query, @@ -135,6 +135,7 @@ async def create_cohort_template( 'description': description, 'criteria': to_db_json(criteria), 'project': project, + 'audit_log_id': await self.audit_log_id(), }, ) @@ -156,9 +157,11 @@ async def create_cohort( # Use an atomic transaction for a mult-part insert query to prevent the database being # left in an incomplete state if the query fails part way through. async with self.connection.transaction(): + audit_log_id = await self.audit_log_id() + _query = """ - INSERT INTO cohort (name, template_id, author, description, project, timestamp) - VALUES (:name, :template_id, :author, :description, :project, NULL) RETURNING id + INSERT INTO cohort (name, template_id, author, description, project, timestamp, audit_log_id) + VALUES (:name, :template_id, :author, :description, :project, NULL, :audit_log_id) RETURNING id """ cohort_id = await self.connection.fetch_val( @@ -169,12 +172,13 @@ async def create_cohort( 'description': description, 'project': project, 'name': cohort_name, + 'audit_log_id': audit_log_id, }, ) _query = """ - INSERT INTO cohort_sequencing_group (cohort_id, sequencing_group_id) - VALUES (:cohort_id, :sequencing_group_id) + INSERT INTO cohort_sequencing_group (cohort_id, sequencing_group_id, audit_log_id) + VALUES (:cohort_id, :sequencing_group_id, :audit_log_id) """ for sg in sequencing_group_ids: @@ -183,6 +187,7 @@ async def create_cohort( { 'cohort_id': cohort_id, 'sequencing_group_id': sg, + 'audit_log_id': audit_log_id, }, ) From e8f05945a9b9b502fccce9072eac7cee9669212c Mon Sep 17 00:00:00 2001 From: John Marshall Date: Thu, 21 Mar 2024 16:28:46 +1300 Subject: [PATCH 098/161] Create cohort rows with timestamp set to (localtime) now --- db/python/tables/cohort.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index cf6b5befb..f26075887 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -161,7 +161,7 @@ async def create_cohort( _query = """ INSERT INTO cohort (name, template_id, author, description, project, timestamp, audit_log_id) - VALUES (:name, :template_id, :author, :description, :project, NULL, :audit_log_id) RETURNING id + VALUES (:name, :template_id, :author, :description, :project, :timestamp, :audit_log_id) RETURNING id """ cohort_id = await self.connection.fetch_val( @@ -172,6 +172,7 @@ async def create_cohort( 'description': description, 'project': project, 'name': cohort_name, + 'timestamp': datetime.datetime.now(), 'audit_log_id': audit_log_id, }, ) From ffcb24b4c3f2afd1cf87ca84cb286ba1b8d660fb Mon Sep 17 00:00:00 2001 From: John Marshall Date: Fri, 22 Mar 2024 09:41:48 +1300 Subject: [PATCH 099/161] Further tests for individual CohortCriteria fields --- test/test_cohort.py | 85 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/test/test_cohort.py b/test/test_cohort.py index 032c4c983..55912a4bf 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -105,16 +105,16 @@ async def test_create_template_then_cohorts(self): ) -def get_sample_model(eid): +def get_sample_model(eid, ty='genome', tech='short-read', plat='illumina'): """Create a minimal sample""" return SampleUpsertInternal( meta={}, external_id=f'EXID{eid}', sequencing_groups=[ SequencingGroupUpsertInternal( - type='genome', - technology='short-read', - platform='illumina', + type=ty, + technology=tech, + platform=plat, meta={}, assays=[], ), @@ -125,6 +125,8 @@ def get_sample_model(eid): class TestCohortData(DbIsolatedTest): """Test custom cohort endpoints that need some sequencing groups already set up""" + # pylint: disable=too-many-instance-attributes + @run_as_sync async def setUp(self): super().setUp() @@ -133,12 +135,15 @@ async def setUp(self): self.sA = await self.samplel.upsert_sample(get_sample_model('A')) self.sB = await self.samplel.upsert_sample(get_sample_model('B')) - self.sC = await self.samplel.upsert_sample(get_sample_model('C')) + self.sC = await self.samplel.upsert_sample(get_sample_model('C', 'exome', 'long-read', 'ONT')) + + self.sgA = sequencing_group_id_format(self.sA.sequencing_groups[0].id) + self.sgB = sequencing_group_id_format(self.sB.sequencing_groups[0].id) + self.sgC = sequencing_group_id_format(self.sC.sequencing_groups[0].id) @run_as_sync async def test_create_cohort_by_sgs(self): """Create cohort by selecting sequencing groups""" - sgB = sequencing_group_id_format(self.sB.sequencing_groups[0].id) result = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, author='bob@example.org', @@ -147,18 +152,15 @@ async def test_create_cohort_by_sgs(self): dry_run=False, cohort_criteria=CohortCriteria( projects=['test'], - sg_ids_internal=[sgB], + sg_ids_internal=[self.sgB], ), ) self.assertIsInstance(result['cohort_id'], str) - self.assertEqual([sgB], result['sequencing_group_ids']) + self.assertEqual([self.sgB], result['sequencing_group_ids']) @run_as_sync async def test_create_cohort_by_excluded_sgs(self): """Create cohort by excluding sequencing groups""" - sgA = sequencing_group_id_format(self.sA.sequencing_groups[0].id) - sgB = sequencing_group_id_format(self.sB.sequencing_groups[0].id) - sgC = sequencing_group_id_format(self.sC.sequencing_groups[0].id) result = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, author='bob@example.org', @@ -167,10 +169,65 @@ async def test_create_cohort_by_excluded_sgs(self): dry_run=False, cohort_criteria=CohortCriteria( projects=['test'], - excluded_sgs_internal=[sgA], + excluded_sgs_internal=[self.sgA], + ), + ) + self.assertIsInstance(result['cohort_id'], str) + self.assertEqual(2, len(result['sequencing_group_ids'])) + self.assertIn(self.sgB, result['sequencing_group_ids']) + self.assertIn(self.sgC, result['sequencing_group_ids']) + + @run_as_sync + async def test_create_cohort_by_technology(self): + """Create cohort by selecting a technology""" + result = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Short-read cohort', + cohort_name='Tech cohort 1', + dry_run=False, + cohort_criteria=CohortCriteria( + projects=['test'], + sg_technology=['short-read'], + ), + ) + self.assertIsInstance(result['cohort_id'], str) + self.assertEqual(2, len(result['sequencing_group_ids'])) + self.assertIn(self.sgA, result['sequencing_group_ids']) + self.assertIn(self.sgB, result['sequencing_group_ids']) + + @run_as_sync + async def test_create_cohort_by_platform(self): + """Create cohort by selecting a platform""" + result = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='ONT cohort', + cohort_name='Platform cohort 1', + dry_run=False, + cohort_criteria=CohortCriteria( + projects=['test'], + sg_platform=['ONT'], + ), + ) + self.assertIsInstance(result['cohort_id'], str) + self.assertEqual([self.sgC], result['sequencing_group_ids']) + + @run_as_sync + async def test_create_cohort_by_type(self): + """Create cohort by selecting types""" + result = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Genome cohort', + cohort_name='Type cohort 1', + dry_run=False, + cohort_criteria=CohortCriteria( + projects=['test'], + sg_type=['genome'], ), ) self.assertIsInstance(result['cohort_id'], str) self.assertEqual(2, len(result['sequencing_group_ids'])) - self.assertIn(sgB, result['sequencing_group_ids']) - self.assertIn(sgC, result['sequencing_group_ids']) + self.assertIn(self.sgA, result['sequencing_group_ids']) + self.assertIn(self.sgB, result['sequencing_group_ids']) From f989ae73218c540d8f47b80c515cbd2ce056ac2f Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 25 Mar 2024 09:48:11 +1100 Subject: [PATCH 100/161] Validate projects on input for create_template --- db/python/layers/cohort.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index cdea60737..774d299f5 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -101,6 +101,11 @@ async def create_cohort_template( Create new cohort template """ + # Validate projects specified in criteria are valid + _ = await self.pt.get_and_check_access_to_projects_for_names( + user=self.connection.author, project_names=cohort_template.criteria.projects, readonly=False + ) + return await self.ct.create_cohort_template( name=cohort_template.name, description=cohort_template.description, From bec2fddbc75522f90cd0a3683df8fc8cbbacd37f Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 25 Mar 2024 09:57:56 +1100 Subject: [PATCH 101/161] Remove what I assume is an artefact from a merge mistake --- README.md | 398 ------------------------------------------------------ 1 file changed, 398 deletions(-) diff --git a/README.md b/README.md index 3c464a2d2..5916d3282 100644 --- a/README.md +++ b/README.md @@ -59,402 +59,4 @@ And metamist maintains two clients: ## License -The recommended way to develop the metamist system is to run a local copy of SM. - -> There have been some reported issues of running a local SM environment on an M1 mac. - -You can run MariaDB with a locally installed docker, or from within a docker container. -You can configure the MariaDB connection with environment variables. - -### Creating the environment - -Python dependencies for the `metamist` API package are listed in `setup.py`. -Additional dev requirements are listed in `requirements-dev.txt`, and packages for -the sever-side code are listed in `requirements.txt`. - -We _STRONGLY_ encourage the use of `pyenv` for managing Python versions. -Debugging and the server will run on a minimum python version of 3.10. - -To setup the python environment, you can run: - -```shell -virtualenv venv -source venv/bin/activate -pip install -r requirements.txt -pip install -r requirements-dev.txt -pip install --editable . -``` - -### Extra software - -You'll need to install the following software to develop metamist: - -- Node / NPM (recommend using nvm) -- MariaDB (using MariaDB in docker is also good) -- Java (for liquibase / openapi-generator) -- Liquibase -- OpenAPI generator -- wget (optional) - -Our recommendation is in the following code block: - -```shell -brew install wget -brew install java -brew install liquibase -``` - -Add the following to your `.zshrc` file: - -```shell - -# homebrew should export this on an M1 Mac -# the intel default is /usr/local -export HB_PREFIX=${HOMEBREW_PREFIX-/usr/local} - -# installing Java through brew recommendation -export CPPFLAGS="-I$HB_PREFIX/opt/openjdk/include" - -# installing liquibase through brew recommendation -export LIQUIBASE_HOME=$(brew --prefix)/opt/liquibase/libexec - -export PATH="$HB_PREFIX/bin:$PATH:$HB_PREFIX/opt/openjdk/bin" -``` - -#### Node through node-version manager (nvm) - -We aren't using too many node-specific features, anything from 16 should work fine, -this will install the LTS version: - -```shell -brew install nvm - -# you may need to add the the following to your .zshrc -# export NVM_DIR="$HOME/.nvm" -# [ -s "$HB_PREFIX/opt/nvm/nvm.sh" ] && \. "$HB_PREFIX/opt/nvm/nvm.sh" # This loads nvm -# [ -s "$HB_PREFIX/opt/nvm/etc/bash_completion.d/nvm" ] && \. "$HB_PREFIX/opt/nvm/etc/bash_completion.d/nvm" # This loads nvm bash_completion - -# install latest version of node + npm -nvm install --lts -``` - -#### OpenAPI generator - -You'll need this to generate the Python and Typescript API. - -```shell -npm install @openapitools/openapi-generator-cli -g -openapi-generator-cli version-manager set 5.3.0 - -# put these in your .zshrc -export OPENAPI_COMMAND="npx @openapitools/openapi-generator-cli" -alias openapi-generator="npx @openapitools/openapi-generator-cli" -``` - -#### MariaDB install - -If you're planning to install MariaDB locally, brew is the easiest: - -```shell - -brew install mariadb@10.8 -# start mariadb on computer start -brew services start mariadb@10.8 - -# make mariadb command available on path -export PATH="$HB_PREFIX/opt/mariadb@10.8/bin:$PATH" -``` - -#### Your .zshrc file - -If you installed all the software through brew and npm -like this guide suggests, your `.zshrc` may look like this: - -```shell -alias openapi-generator="npx @openapitools/openapi-generator-cli" - -# homebrew should export this on an M1 Mac -# the intel default is /usr/local -export HB_PREFIX=${HOMEBREW_PREFIX-/usr/local} - -# metamist -export SM_ENVIRONMENT=LOCAL # good default to have -export SM_DEV_DB_USER=sm_api # makes it easier to copy liquibase update command -export OPENAPI_COMMAND="npx @openapitools/openapi-generator-cli" - -export PATH="$HB_PREFIX/bin:$HB_PREFIX/opt/mariadb@10.8/bin:$PATH:$HB_PREFIX/opt/openjdk/bin" - -export CPPFLAGS="-I$HB_PREFIX/opt/openjdk/include" -export LIQUIBASE_HOME=$(brew --prefix)/opt/liquibase/libexec - -# node -export NVM_DIR="$HOME/.nvm" -[ -s "$HB_PREFIX/opt/nvm/nvm.sh" ] && \. "$HB_PREFIX/opt/nvm/nvm.sh" # This loads nvm -[ -s "$HB_PREFIX/opt/nvm/etc/bash_completion.d/nvm" ] && \. "$HB_PREFIX/opt/nvm/etc/bash_completion.d/nvm" # This loads nvm bash_completion -``` - -### Database setup - -These are the default values for the SM database connection. -Please alter them if you use any different values when setting up the database. - -```shell -export SM_DEV_DB_USER=root # this is the default, but we now recommend sm_api -export SM_DEV_DB_PASSWORD= # empty password -export SM_DEV_DB_HOST=127.0.0.1 -export SM_DEV_DB_PORT=3306 # default mariadb port -export SM_DEV_DB_NAME=sm_dev; -``` - -Create the database in MariaDB (by default, we call it `sm_dev`): - -> In newer installs of MariaDB, the root user is protected by default. - -We'll setup a user called `sm_api`, and setup permissions - -```shell -sudo mysql -u root --execute " - CREATE DATABASE sm_dev; - CREATE USER sm_api@'%'; - CREATE USER sm_api@localhost; - CREATE ROLE sm_api_role; - GRANT sm_api_role TO sm_api@'%'; - GRANT sm_api_role TO sm_api@localhost; - SET DEFAULT ROLE sm_api_role FOR sm_api@'%'; - SET DEFAULT ROLE sm_api_role FOR sm_api@localhost; - GRANT ALL PRIVILEGES ON sm_dev.* TO sm_api_role; -" -``` - -Then, before you run you'll need to export the varied: - -```shell -# also put this in your .zshrc -export SM_DEV_DB_USER=sm_api -``` - -Download the `mariadb-java-client` and create the schema using liquibase: - -```shell -pushd db/ -wget https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/3.0.3/mariadb-java-client-3.0.3.jar -liquibase \ - --changeLogFile project.xml \ - --url jdbc:mariadb://localhost/sm_dev \ - --driver org.mariadb.jdbc.Driver \ - --classpath mariadb-java-client-3.0.3.jar \ - --username ${SM_DEV_DB_USER:-root} \ - update -popd -``` - -#### Using Maria DB docker image - -Pull mariadb image - -```bash -docker pull mariadb:10.8.3 -``` - -If you wish, install the mysql-client using homebrew (or an equivalent linux command) so you can connect to the MariaDB server running via Docker: - -```bash -brew install mysql-client -``` - -Run a mariadb container that will server your database. `-p 3307:3306` remaps the port to 3307 in case if you local MySQL is already using 3306 - -```bash -docker stop mysql-p3307 # stop and remove if the container already exists -docker rm mysql-p3307 -# run with an empty root password -docker run -p 3307:3306 --name mysql-p3307 -e MYSQL_ALLOW_EMPTY_PASSWORD=true -d mariadb:10.8.3 -``` - -```bash -mysql --host=127.0.0.1 --port=3307 -u root -e 'CREATE DATABASE sm_dev;' -mysql --host=127.0.0.1 --port=3307 -u root -e 'show databases;' -``` - -Similar to the previous section, we need to create the `sm_api` user, and set the corect roles and privileges: - -```bash -mysql --host=127.0.0.1 --port=3307 -u root --execute " - CREATE USER sm_api@'%'; - CREATE USER sm_api@localhost; - CREATE ROLE sm_api_role; - GRANT sm_api_role TO sm_api@'%'; - GRANT sm_api_role TO sm_api@localhost; - SET DEFAULT ROLE sm_api_role FOR sm_api@'%'; - SET DEFAULT ROLE sm_api_role FOR sm_api@localhost; - GRANT ALL PRIVILEGES ON sm_dev.* TO sm_api_role; -" -``` - -Go into the `db/` subdirectory, download the `mariadb-java-client` and create the schema using liquibase: - -```bash -pushd db/ -wget https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/3.0.3/mariadb-java-client-3.0.3.jar -liquibase \ - --changeLogFile project.xml \ - --url jdbc:mariadb://127.0.0.1:3307/sm_dev \ - --driver org.mariadb.jdbc.Driver \ - --classpath mariadb-java-client-3.0.3.jar \ - --username root \ - update -popd -``` - -Finally, make sure you configure the server (making use of the environment variables) to point it to your local Maria DB server - -```bash -export SM_DEV_DB_PORT=3307 -``` - -### Running the server - -You'll want to set the following environment variables (permanently) in your -local development environment. - -The `SM_LOCALONLY_DEFAULTUSER` environment variable along with `ALLOWALLACCESS` to allow access to a local metamist server without providing a bearer token. This will allow you to test the front-end components that access data. This happens automatically on the production instance through the Google identity-aware-proxy. - -```shell -export SM_ALLOWALLACCESS=1 -export SM_LOCALONLY_DEFAULTUSER=$(whoami) -``` - -```shell -# ensures the SWAGGER page points to your local: (localhost:8000/docs) -# and ensures if you use the PythonAPI, it also points to your local -export SM_ENVIRONMENT=LOCAL -# skips permission checks in your local environment -export SM_ALLOWALLACCESS=true -# uses your username as the "author" in requests -export SM_LOCALONLY_DEFAULTUSER=$(whoami) - -# probably need this - - -# start the server -python3 -m api.server -# OR -# uvicorn --port 8000 --host 0.0.0.0 api.server:app -``` - -#### Running + debugging in VSCode - -The following `launch.json` is a good base to debug the web server in VSCode: - -```json -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Run API", - "type": "python", - "request": "launch", - "module": "api.server", - "justMyCode": false, - "env": { - "SM_ALLOWALLACCESS": "true", - "SM_LOCALONLY_DEFAULTUSER": "-local", - "SM_ENVIRONMENT": "local", - "SM_DEV_DB_USER": "sm_api" - } - } - ] -} -``` - -We could now place breakpoints on the sample route (ie: `api/routes/sample.py`), and debug requests as they come in. - -Then in VSCode under the _Run and Debug_ tab (⌘⇧D), you can "Run API": - -![Run API](resources/debug-api.png) - -#### Quickstart: Generate and install the python installable API - -Generating the installable APIs (Python + Typescript) involves running -the server, getting the `/openapi.json`, and running `openapi-generator`. - -The `regenerate_api.py` script does this in a few ways: - -1. Uses a running server on `localhost:8000` -2. Runs a docker container from the `SM_DOCKER` environment variable -3. Spins up the server itself - -Most of the time, you'll use 1 or 3: - -```bash -# this will start the api.server, so make sure you have the dependencies installed, -python regenerate_api.py \ - && pip install . -``` - -If you'd prefer to use the Docker approach (eg: on CI), this command -will build the docker container and supply it to regenerate_api.py. - -```bash -# SM_DOCKER is a known env variable to regenerate_api.py -export SM_DOCKER="cpg/metamist-server:dev" -docker build --build-arg SM_ENVIRONMENT=local -t $SM_DOCKER -f deploy/api/Dockerfile . -python regenerate_api.py -``` - -#### Generating example data - -> You'll need to generate the installable API before running this step - -You can run the `generate_data.py` script to generate some -random data to look at. - -```shell -export SM_ENVIRONMENT=local # important -python test/data/generate_data.py -``` - -#### Developing the UI - -```shell -# Ensure you have started sm locally on your computer already, then in another tab open the UI. -# This will automatically proxy request to the server. -cd web -npm install -npm run compile -npm start -``` - -This will start a web server using Vite, running on [localhost:5173](http://localhost:5173). - -### OpenAPI and Swagger - -The Web API uses `apispec` with OpenAPI3 annotations on each route to describe interactions with the server. We can generate a swagger UI and an installable -python module based on these annotations. - -Some handy links: - -- [OpenAPI specification](https://swagger.io/specification/) -- [Describing parameters](https://swagger.io/docs/specification/describing-parameters/) -- [Describing request body](https://swagger.io/docs/specification/describing-request-body/) -- [Media types](https://swagger.io/docs/specification/media-types/) - -The web API exposes this schema in two ways: - -- Swagger UI: `http://localhost:8000/docs` - - You can use this to construct requests to the server - - Make sure you fill in the Bearer token (at the top right ) -- OpenAPI schema: `http://localhost:8000/schema.json` - - Returns a JSON with the full OpenAPI 3 compliant schema. - - You could put this into the [Swagger editor](https://editor.swagger.io/) to see the same "Swagger UI" that `/api/docs` exposes. - - We generate the metamist installable Python API based on this schema. - -## Deployment - -The CPG deploy is managed through Cloud Run on the Google Cloud Platform. -The deploy github action builds the container, and is deployed. - -Additionally you can access metamist through the identity-aware proxy (IAP), -which handles the authentication through OAuth, allowing you to access the -front-end. This project is licensed under the MIT License. You can see it in the [LICENSE](LICENSE) file in the root directory of this source tree. From e576d1fb4262695c40d5e8ede2f1aab6660a63fb Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 25 Mar 2024 18:15:37 +1100 Subject: [PATCH 102/161] Add cohorts to analysis objects Co-authored-by: John Marshall <70921+jmarshall@users.noreply.github.com> --- api/routes/analysis.py | 3 ++ db/python/layers/analysis.py | 15 +++++++ db/python/tables/analysis.py | 87 ++++++++++++++++++++++++++++-------- models/models/analysis.py | 17 ++++++- 4 files changed, 102 insertions(+), 20 deletions(-) diff --git a/api/routes/analysis.py b/api/routes/analysis.py index b3298d2c2..218326774 100644 --- a/api/routes/analysis.py +++ b/api/routes/analysis.py @@ -114,6 +114,9 @@ async def create_analysis( # special tracking here, if we can't catch it through the header connection.on_behalf_of = analysis.author + if not analysis.sequencing_group_ids and not analysis.cohort_ids: + raise ValueError('Must specify "sequencing_group_ids" or "cohort_ids"') + analysis_id = await atable.create_analysis( analysis.to_internal(), ) diff --git a/db/python/layers/analysis.py b/db/python/layers/analysis.py index 878873386..fb04166b8 100644 --- a/db/python/layers/analysis.py +++ b/db/python/layers/analysis.py @@ -7,6 +7,7 @@ from db.python.layers.base import BaseLayer from db.python.layers.sequencing_group import SequencingGroupLayer from db.python.tables.analysis import AnalysisFilter, AnalysisTable +from db.python.tables.cohort import CohortTable from db.python.tables.sample import SampleTable from db.python.tables.sequencing_group import SequencingGroupFilter from db.python.utils import GenericFilter, get_logger @@ -48,6 +49,7 @@ def __init__(self, connection: Connection): self.sampt = SampleTable(connection) self.at = AnalysisTable(connection) + self.ct = CohortTable(connection) # GETS @@ -545,10 +547,23 @@ async def create_analysis( project: ProjectId = None, ) -> int: """Create a new analysis""" + + # Validate cohort sgs equal sgs + if analysis.cohort_ids and analysis.sequencing_group_ids: + all_cohort_sgs: list[int] = [] + for cohort_id in analysis.cohort_ids: + cohort_sgs = await self.ct.get_cohort_sequencing_group_ids(cohort_id) + all_cohort_sgs.extend(cohort_sgs) + if set(all_cohort_sgs) != set(analysis.sequencing_group_ids): + raise ValueError( + 'Cohort sequencing groups do not match analysis sequencing groups' + ) + return await self.at.create_analysis( analysis_type=analysis.type, status=analysis.status, sequencing_group_ids=analysis.sequencing_group_ids, + cohort_ids=analysis.cohort_ids, meta=analysis.meta, output=analysis.output, active=analysis.active, diff --git a/db/python/tables/analysis.py b/db/python/tables/analysis.py index 0a3e51f0f..4f045e133 100644 --- a/db/python/tables/analysis.py +++ b/db/python/tables/analysis.py @@ -25,6 +25,7 @@ class AnalysisFilter(GenericFilterModel): id: GenericFilter[int] | None = None sample_id: GenericFilter[int] | None = None sequencing_group_id: GenericFilter[int] | None = None + cohort_id: GenericFilter[str] | None = None project: GenericFilter[int] | None = None type: GenericFilter[str] | None = None status: GenericFilter[AnalysisStatus] | None = None @@ -59,6 +60,7 @@ async def create_analysis( analysis_type: str, status: AnalysisStatus, sequencing_group_ids: List[int], + cohort_ids: List[int] | None = None, meta: Optional[Dict[str, Any]] = None, output: str = None, active: bool = True, @@ -100,6 +102,9 @@ async def create_analysis( id_of_new_analysis, sequencing_group_ids ) + if cohort_ids: + await self.add_cohorts_to_analysis(id_of_new_analysis, cohort_ids) + return id_of_new_analysis async def add_sequencing_groups_to_analysis( @@ -123,6 +128,27 @@ async def add_sequencing_groups_to_analysis( ) await self.connection.execute_many(_query, list(values)) + async def add_cohorts_to_analysis( + self, analysis_id: int, cohort_ids: list[int] + ): + """Add cohorts to an analysis (through the linked table)""" + _query = """ + INSERT INTO analysis_cohort + (analysis_id, cohort_id, audit_log_id) + VALUES (:aid, :cid, :audit_log_id) + """ + + audit_log_id = await self.audit_log_id() + values = map( + lambda cid: { + 'aid': analysis_id, + 'cid': cid, + 'audit_log_id': audit_log_id, + }, + cohort_ids, + ) + await self.connection.execute_many(_query, list(values)) + async def find_sgs_in_joint_call_or_es_index_up_to_date( self, date: datetime.date ) -> set[int]: @@ -192,11 +218,13 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: filter_.sequencing_group_id, filter_.project, filter_.sample_id, + filter_.cohort_id, + ] if not any(required_fields): raise ValueError( - 'Must provide at least one of id, sample_id, sequencing_group_id, ' + 'Must provide at least one of id, sample_id, sequencing_group_id, cohort_id ' 'or project to filter on' ) @@ -211,27 +239,50 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: 'meta': 'a.meta', 'output': 'a.output', 'active': 'a.active', + 'cohort_id': 'a_c.cohort_id', }, ) - _query = f""" - SELECT a.id as id, a.type as type, a.status as status, - a.output as output, a_sg.sequencing_group_id as sequencing_group_id, - a.project as project, a.timestamp_completed as timestamp_completed, - a.active as active, a.meta as meta, a.author as author - FROM analysis a - LEFT JOIN analysis_sequencing_group a_sg ON a.id = a_sg.analysis_id - WHERE {where_str} - """ - - rows = await self.connection.fetch_all(_query, values) retvals: Dict[int, AnalysisInternal] = {} - for row in rows: - key = row['id'] - if key in retvals: - retvals[key].sequencing_group_ids.append(row['sequencing_group_id']) - else: - retvals[key] = AnalysisInternal.from_db(**dict(row)) + + if filter_.cohort_id and filter_.sequencing_group_id: + raise ValueError('Cannot filter on both cohort_id and sequencing_group_id') + + if filter_.cohort_id: + _query = f""" + SELECT a.id as id, a.type as type, a.status as status, + a.output as output, a_c.cohort_id as cohort_id, + a.project as project, a.timestamp_completed as timestamp_completed, + a.active as active, a.meta as meta, a.author as author + FROM analysis a + LEFT JOIN analysis_cohort a_c ON a.id = a_c.analysis_id + WHERE {where_str} + """ + rows = await self.connection.fetch_all(_query, values) + for row in rows: + key = row['id'] + if key in retvals: + retvals[key].cohort_ids.append(row['cohort_id']) + else: + retvals[key] = AnalysisInternal.from_db(**dict(row)) + + else: + _query = f""" + SELECT a.id as id, a.type as type, a.status as status, + a.output as output, a_sg.sequencing_group_id as sequencing_group_id, + a.project as project, a.timestamp_completed as timestamp_completed, + a.active as active, a.meta as meta, a.author as author + FROM analysis a + LEFT JOIN analysis_sequencing_group a_sg ON a.id = a_sg.analysis_id + WHERE {where_str} + """ + rows = await self.connection.fetch_all(_query, values) + for row in rows: + key = row['id'] + if key in retvals: + retvals[key].sequencing_group_ids.append(row['sequencing_group_id']) + else: + retvals[key] = AnalysisInternal.from_db(**dict(row)) return list(retvals.values()) diff --git a/models/models/analysis.py b/models/models/analysis.py index fb6e3152d..2b2ab7784 100644 --- a/models/models/analysis.py +++ b/models/models/analysis.py @@ -7,6 +7,10 @@ from models.base import SMBase from models.enums import AnalysisStatus +from models.utils.cohort_id_format import ( + cohort_id_format_list, + cohort_id_transform_to_raw_list, +) from models.utils.sequencing_group_id_format import ( sequencing_group_id_format_list, sequencing_group_id_transform_to_raw_list, @@ -20,7 +24,8 @@ class AnalysisInternal(SMBase): type: str status: AnalysisStatus output: str = None - sequencing_group_ids: list[int] = [] + sequencing_group_ids: list[int] | None = None + cohort_ids: list[int] | None = None timestamp_completed: datetime | None = None project: int | None = None active: bool | None = None @@ -47,11 +52,16 @@ def from_db(**kwargs): if sg := kwargs.pop('sequencing_group_id', None): sequencing_group_ids.append(sg) + cohort_ids = [] + if cid := kwargs.pop('cohort_id', None): + cohort_ids.append(cid) + return AnalysisInternal( id=kwargs.pop('id'), type=analysis_type, status=AnalysisStatus(status), sequencing_group_ids=sequencing_group_ids or [], + cohort_ids=cohort_ids, output=kwargs.pop('output', []), timestamp_completed=timestamp_completed, project=kwargs.get('project'), @@ -71,6 +81,7 @@ def to_external(self): sequencing_group_ids=sequencing_group_id_format_list( self.sequencing_group_ids ), + cohort_ids=cohort_id_format_list(self.cohort_ids), output=self.output, timestamp_completed=self.timestamp_completed.isoformat() if self.timestamp_completed @@ -89,7 +100,8 @@ class Analysis(BaseModel): status: AnalysisStatus id: int | None = None output: str | None = None - sequencing_group_ids: list[str] = [] + sequencing_group_ids: list[str] | None = None + cohort_ids: list[str] | None = None author: str | None = None timestamp_completed: str | None = None project: int | None = None @@ -107,6 +119,7 @@ def to_internal(self): sequencing_group_ids=sequencing_group_id_transform_to_raw_list( self.sequencing_group_ids ), + cohort_ids=cohort_id_transform_to_raw_list(self.cohort_ids), output=self.output, # don't allow this to be set timestamp_completed=None, From 9a7a3794841c6ec0a7d3ba5b3448c9399f05dc23 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Mon, 25 Mar 2024 21:35:24 +1300 Subject: [PATCH 103/161] Account for cohort_ids in test_get_analysis() test case --- test/test_analysis.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_analysis.py b/test/test_analysis.py index be7a15e90..1c14aa1e2 100644 --- a/test/test_analysis.py +++ b/test/test_analysis.py @@ -147,6 +147,7 @@ async def test_get_analysis(self): type='analysis-runner', status=AnalysisStatus.UNKNOWN, sequencing_group_ids=[], + cohort_ids=[], output=None, timestamp_completed=None, project=1, From 7a36150f0c8b4c87f1cc78ab926cd0f2a9cdd914 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Tue, 26 Mar 2024 09:53:48 +1300 Subject: [PATCH 104/161] Verify that create_cohort_template now validates projects --- test/test_cohort.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/test_cohort.py b/test/test_cohort.py index 55912a4bf..bdab607c9 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -3,6 +3,7 @@ from pymysql.err import IntegrityError from db.python.layers import CohortLayer, SampleLayer +from db.python.utils import Forbidden, NotFoundError from models.models import SampleUpsertInternal, SequencingGroupUpsertInternal from models.models.cohort import CohortCriteria, CohortTemplate from models.utils.sequencing_group_id_format import sequencing_group_id_format @@ -28,6 +29,32 @@ async def test_create_cohort_missing_args(self): dry_run=False, ) + @run_as_sync + async def test_create_cohort_bad_project(self): + """Can't create cohort in invalid project""" + with self.assertRaises((Forbidden, NotFoundError)): + _ = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Cohort based on a missing project', + cohort_name='Bad-project cohort', + dry_run=False, + cohort_criteria=CohortCriteria(projects=['nonexistent']), + ) + + @run_as_sync + async def test_create_template_bad_project(self): + """Can't create template in invalid project""" + with self.assertRaises((Forbidden, NotFoundError)): + _ = await self.cohortl.create_cohort_template( + project=self.project_id, + cohort_template=CohortTemplate( + name='Bad-project template', + description='Template based on a missing project', + criteria=CohortCriteria(projects=['nonexistent']), + ), + ) + @run_as_sync async def test_create_empty_cohort(self): """Create cohort from empty criteria""" From 3aec4321310c55fa0614f2c80408e3fd7a8247b4 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Tue, 26 Mar 2024 10:45:31 +1300 Subject: [PATCH 105/161] Another test for an individual CohortCriteria field --- test/test_cohort.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/test/test_cohort.py b/test/test_cohort.py index bdab607c9..9429ccf03 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -132,14 +132,15 @@ async def test_create_template_then_cohorts(self): ) -def get_sample_model(eid, ty='genome', tech='short-read', plat='illumina'): +def get_sample_model(eid, s_type='blood', sg_type='genome', tech='short-read', plat='illumina'): """Create a minimal sample""" return SampleUpsertInternal( meta={}, external_id=f'EXID{eid}', + type=s_type, sequencing_groups=[ SequencingGroupUpsertInternal( - type=ty, + type=sg_type, technology=tech, platform=plat, meta={}, @@ -162,7 +163,7 @@ async def setUp(self): self.sA = await self.samplel.upsert_sample(get_sample_model('A')) self.sB = await self.samplel.upsert_sample(get_sample_model('B')) - self.sC = await self.samplel.upsert_sample(get_sample_model('C', 'exome', 'long-read', 'ONT')) + self.sC = await self.samplel.upsert_sample(get_sample_model('C', 'saliva', 'exome', 'long-read', 'ONT')) self.sgA = sequencing_group_id_format(self.sA.sequencing_groups[0].id) self.sgB = sequencing_group_id_format(self.sB.sequencing_groups[0].id) @@ -258,3 +259,20 @@ async def test_create_cohort_by_type(self): self.assertEqual(2, len(result['sequencing_group_ids'])) self.assertIn(self.sgA, result['sequencing_group_ids']) self.assertIn(self.sgB, result['sequencing_group_ids']) + + @run_as_sync + async def test_create_cohort_by_sample_type(self): + """Create cohort by selecting sample types""" + result = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Sample cohort', + cohort_name='Sample cohort 1', + dry_run=False, + cohort_criteria=CohortCriteria( + projects=['test'], + sample_type=['saliva'], + ), + ) + self.assertIsInstance(result['cohort_id'], str) + self.assertEqual([self.sgC], result['sequencing_group_ids']) From 0b1fa91b1fe28fd375484a044baa9712cef1923f Mon Sep 17 00:00:00 2001 From: John Marshall Date: Tue, 26 Mar 2024 15:29:35 +1300 Subject: [PATCH 106/161] Add test exercising re-evaluation of a cohort template --- test/test_cohort.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/test_cohort.py b/test/test_cohort.py index 9429ccf03..8b13fd550 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -276,3 +276,44 @@ async def test_create_cohort_by_sample_type(self): ) self.assertIsInstance(result['cohort_id'], str) self.assertEqual([self.sgC], result['sequencing_group_ids']) + + @run_as_sync + async def test_reevaluate_cohort(self): + """Add another sample, then reevaluate a cohort template""" + template = await self.cohortl.create_cohort_template( + project=self.project_id, + cohort_template=CohortTemplate( + name='Boold template', + description='Template selecting blood', + criteria=CohortCriteria( + projects=['test'], + sample_type=['blood'], + ), + ), + ) + + coh1 = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Blood cohort', + cohort_name='Blood cohort 1', + dry_run=False, + template_id=template, + ) + self.assertEqual(2, len(coh1['sequencing_group_ids'])) + + sD = await self.samplel.upsert_sample(get_sample_model('D')) + sgD = sequencing_group_id_format(sD.sequencing_groups[0].id) + + coh2 = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Blood cohort', + cohort_name='Blood cohort 2', + dry_run=False, + template_id=template, + ) + self.assertEqual(3, len(coh2['sequencing_group_ids'])) + + self.assertNotIn(sgD, coh1['sequencing_group_ids']) + self.assertIn(sgD, coh2['sequencing_group_ids']) From 726a48e5489d8f1f30781ed6194f8c612bd46f89 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Tue, 26 Mar 2024 16:22:54 +1300 Subject: [PATCH 107/161] Add test using all CohortCriteria fields Note that these operate as "AND". I hoped to add another sample D/saliva/exome/long-read/ONT and use sg_ids_internal=[sgD], excluded_sgs_internal=[self.sgA] to get [B, D]. However what that actually selects is D-only AND short-read-only, hence matches nothing. --- test/test_cohort.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/test_cohort.py b/test/test_cohort.py index 8b13fd550..ec55861d3 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -277,6 +277,28 @@ async def test_create_cohort_by_sample_type(self): self.assertIsInstance(result['cohort_id'], str) self.assertEqual([self.sgC], result['sequencing_group_ids']) + @run_as_sync + async def test_create_cohort_by_everything(self): + """Create cohort by selecting a variety of fields""" + result = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Everything cohort', + cohort_name='Everything cohort 1', + dry_run=False, + cohort_criteria=CohortCriteria( + projects=['test'], + sg_ids_internal=[self.sgB, self.sgC], + excluded_sgs_internal=[self.sgA], + sg_technology=['short-read'], + sg_platform=['illumina'], + sg_type=['genome'], + sample_type=['blood'], + ), + ) + self.assertEqual(1, len(result['sequencing_group_ids'])) + self.assertIn(self.sgB, result['sequencing_group_ids']) + @run_as_sync async def test_reevaluate_cohort(self): """Add another sample, then reevaluate a cohort template""" From 1caeecf4c179670a9c6fb5b8979825b0c6c620d1 Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 26 Mar 2024 16:37:05 +1100 Subject: [PATCH 108/161] Update schema docs --- README.md | 4 ++-- resources/schemav7.7.png | Bin 0 -> 205565 bytes 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 resources/schemav7.7.png diff --git a/README.md b/README.md index 5916d3282..46600f292 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,9 @@ It comprises three key components: As of Jan 15, 2024 this schema should reflect the data structure on the tables: -![Database Structure](resources/2024-01-15_db-diagram.png) +![Database Structure](resources/schemav7.7.png.png) -You can also find this at [DbDiagram](https://dbdiagram.io/d/Metamist-Schema-v6-6-2-65a48ac7ac844320aee60d16). +You can also find this at [DbDiagram](https://dbdiagram.io/d/Metamist-Schema-v7-7-6600c875ae072629ced6a1fc). The codebase contains the following modules worth noting: diff --git a/resources/schemav7.7.png b/resources/schemav7.7.png new file mode 100644 index 0000000000000000000000000000000000000000..e1722a4401d2fed548803b2791224dc9b50f89e0 GIT binary patch literal 205565 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 aWQFR6$Ys{_-MVYwe;TUVDj7;=um2ARq}kE{ literal 0 HcmV?d00001 From 73353544fd75cc73201d047f12b0d2f90fec0eff Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 27 Mar 2024 08:55:11 +1100 Subject: [PATCH 109/161] Combine project.xml entries --- db/project.xml | 67 ++++++++++++++++++++------------------------------ 1 file changed, 26 insertions(+), 41 deletions(-) diff --git a/db/project.xml b/db/project.xml index 4a49190cb..4378ad786 100644 --- a/db/project.xml +++ b/db/project.xml @@ -844,24 +844,6 @@ INSERT INTO `group` (name) VALUES ('members-admin'); - - - - - - - - - - - - - - - - - - SET @@system_versioning_alter_history = 1; @@ -1223,7 +1205,25 @@ "analysis-runner" - + + + + + + + + + + + + + @@ -1237,40 +1237,25 @@ - - - - - - - - - - - - + + - - - + - + + ALTER TABLE cohort_template ADD SYSTEM VERSIONING; ALTER TABLE cohort ADD SYSTEM VERSIONING; From d705285eb4e2fa88cace6f9d4beaf48ed69c2b41 Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 27 Mar 2024 09:29:45 +1100 Subject: [PATCH 110/161] Add human readable CTPL prefix to templates --- api/graphql/schema.py | 20 +++--- api/settings.py | 3 + db/python/layers/cohort.py | 15 +++-- models/models/cohort.py | 2 +- models/utils/cohort_template_id_format.py | 81 +++++++++++++++++++++++ 5 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 models/utils/cohort_template_id_format.py diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 86e02e1b9..87cb2f890 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -51,6 +51,10 @@ from models.models.analysis_runner import AnalysisRunnerInternal from models.models.sample import sample_id_transform_to_raw from models.utils.cohort_id_format import cohort_id_format, cohort_id_transform_to_raw +from models.utils.cohort_template_id_format import ( + cohort_template_id_format, + cohort_template_id_transform_to_raw, +) from models.utils.sample_id_format import sample_id_format from models.utils.sequencing_group_id_format import ( sequencing_group_id_format, @@ -87,7 +91,7 @@ class GraphQLCohort: name: str description: str author: str - template_id: int | None = None + template_id: str | None = None @staticmethod def from_internal(internal: Cohort) -> 'GraphQLCohort': @@ -96,7 +100,7 @@ def from_internal(internal: Cohort) -> 'GraphQLCohort': name=internal.name, description=internal.description, author=internal.author, - template_id=internal.template_id, + template_id=cohort_template_id_format(internal.template_id), ) @strawberry.field() @@ -122,7 +126,7 @@ async def project(self, info: Info, root: 'Cohort') -> 'GraphQLProject': class GraphQLCohortTemplate: """CohortTemplate GraphQL model""" - id: int + id: str name: str description: str criteria: strawberry.scalars.JSON @@ -130,7 +134,7 @@ class GraphQLCohortTemplate: @staticmethod def from_internal(internal: CohortTemplateModel) -> 'GraphQLCohortTemplate': return GraphQLCohortTemplate( - id=internal.id, + id=cohort_template_id_format(internal.id), name=internal.name, description=internal.description, criteria=internal.criteria, @@ -326,7 +330,7 @@ async def cohort( id=id.to_internal_filter(cohort_id_transform_to_raw) if id else None, name=name.to_internal_filter() if name else None, author=author.to_internal_filter() if author else None, - template_id=template_id.to_internal_filter() if template_id else None, + template_id=template_id.to_internal_filter(cohort_template_id_transform_to_raw) if template_id else None, timestamp=timestamp.to_internal_filter() if timestamp else None, ) @@ -803,7 +807,7 @@ def enum(self, info: Info) -> GraphQLEnum: async def cohort_template( self, info: Info, - id: GraphQLFilter[int] | None = None, + id: GraphQLFilter[str] | None = None, project: GraphQLFilter[str] | None = None, ) -> list[GraphQLCohortTemplate]: connection = info.context['connection'] @@ -823,7 +827,7 @@ async def cohort_template( ) filter_ = CohortTemplateFilter( - id=id.to_internal_filter() if id else None, + id=id.to_internal_filter(cohort_template_id_transform_to_raw) if id else None, project=project_filter, ) @@ -861,7 +865,7 @@ async def cohort( name=name.to_internal_filter() if name else None, project=project_filter, author=author.to_internal_filter() if author else None, - template_id=template_id.to_internal_filter() if template_id else None, + template_id=template_id.to_internal_filter(cohort_template_id_transform_to_raw) if template_id else None, ) cohorts = await clayer.query(filter_) diff --git a/api/settings.py b/api/settings.py index 39035138f..adae52b89 100644 --- a/api/settings.py +++ b/api/settings.py @@ -39,6 +39,9 @@ COHORT_PREFIX = os.getenv('SM_COHORTPREFIX', 'COH').upper() COHORT_CHECKSUM_OFFSET = int(os.getenv('SM_COHORTCHECKOFFSET', '5')) +COHORT_TEMPLATE_PREFIX = os.getenv('SM_COHORTTEMPLATEPREFIX', 'CTPL').upper() +COHORT_TEMPLATE_CHECKSUM_OFFSET = int(os.getenv('SM_COHORTTEMPLATECHECKOFFSET', '3')) + # billing settings BQ_AGGREG_VIEW = os.getenv('SM_GCP_BQ_AGGREG_VIEW') BQ_AGGREG_RAW = os.getenv('SM_GCP_BQ_AGGREG_RAW') diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 774d299f5..7c14d6adb 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -14,6 +14,10 @@ from db.python.utils import GenericFilter, get_logger from models.models.cohort import Cohort, CohortCriteria, CohortTemplate from models.utils.cohort_id_format import cohort_id_format +from models.utils.cohort_template_id_format import ( + cohort_template_id_format, + cohort_template_id_transform_to_raw, +) from models.utils.sequencing_group_id_format import ( sequencing_group_id_format_list, sequencing_group_id_transform_to_raw_list, @@ -106,13 +110,15 @@ async def create_cohort_template( user=self.connection.author, project_names=cohort_template.criteria.projects, readonly=False ) - return await self.ct.create_cohort_template( + template_id = await self.ct.create_cohort_template( name=cohort_template.name, description=cohort_template.description, criteria=dict(cohort_template.criteria), project=project ) + return cohort_template_id_format(template_id) + async def create_cohort_from_criteria( self, project_to_write: ProjectId, @@ -121,7 +127,7 @@ async def create_cohort_from_criteria( cohort_name: str, dry_run: bool, cohort_criteria: CohortCriteria = None, - template_id: int = None, + template_id: str = None, ): """ Create a new cohort from the given parameters. Returns the newly created cohort_id. @@ -136,7 +142,8 @@ async def create_cohort_from_criteria( # Get template from ID template: dict[str, str] = {} if template_id: - template = await self.ct.get_cohort_template(template_id) + template_id_raw = cohort_template_id_transform_to_raw(template_id) + template = await self.ct.get_cohort_template(template_id_raw) if not template: raise ValueError(f'Cohort template with ID {template_id} not found') @@ -201,7 +208,7 @@ async def create_cohort_from_criteria( sequencing_group_ids=[sg.id for sg in sgs], description=description, author=author, - template_id=template_id, + template_id=cohort_template_id_transform_to_raw(template_id), ) return {'cohort_id': cohort_id_format(cohort_id), 'sequencing_group_ids': rich_ids} diff --git a/models/models/cohort.py b/models/models/cohort.py index 164ae6348..2703c75bf 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -74,7 +74,7 @@ class CohortBody(BaseModel): name: str description: str - template_id: int | None = None + template_id: str | None = None class CohortCriteria(BaseModel): diff --git a/models/utils/cohort_template_id_format.py b/models/utils/cohort_template_id_format.py new file mode 100644 index 000000000..29808be4b --- /dev/null +++ b/models/utils/cohort_template_id_format.py @@ -0,0 +1,81 @@ +from typing import Iterable + +from api.settings import COHORT_TEMPLATE_CHECKSUM_OFFSET, COHORT_TEMPLATE_PREFIX +from models.utils.luhn import luhn_compute + + +def cohort_template_id_format(cohort_template_id: int | str) -> str: + """ + Transform raw (int) cohort template identifier to format (CTPLXXX) where: + - CTPL is the prefix + - XXX is the original identifier + - H is the Luhn checksum + """ + # Validate input + if isinstance(cohort_template_id, str) and not cohort_template_id.isdigit(): + if cohort_template_id.startswith(COHORT_TEMPLATE_PREFIX): + return cohort_template_id + raise ValueError(f'Unexpected format for cohort template identifier {cohort_template_id!r}') + + cohort_template_id = int(cohort_template_id) + + checksum = luhn_compute( + cohort_template_id, offset=COHORT_TEMPLATE_CHECKSUM_OFFSET + ) + + return f'{COHORT_TEMPLATE_PREFIX}{cohort_template_id}{checksum}' + + +def cohort_template_id_format_list(cohort_template_ids: Iterable[int | str]) -> list[str]: + """ + Transform LIST of raw (int) cohort template identifier to format (CTPLXXX) where: + - CTPL is the prefix + - XXX is the original identifier + - H is the Luhn checksum + """ + return [cohort_template_id_format(s) for s in cohort_template_ids] + + +def cohort_template_id_transform_to_raw(cohort_template_id: int | str) -> int: + """ + Transform STRING cohort template identifier (CTPLXXXH) to XXX by: + - validating prefix + - validating checksum + """ + expected_type = str + if not isinstance(cohort_template_id, expected_type): # type: ignore + raise TypeError( + f'Expected identifier type to be {expected_type!r}, received {type(cohort_template_id)!r}' + ) + + if isinstance(cohort_template_id, int): + return cohort_template_id + + if not isinstance(cohort_template_id, str): + raise ValueError('Programming error related to cohort template checks') + + if not cohort_template_id.startswith(COHORT_TEMPLATE_PREFIX): + raise ValueError( + f'Invalid prefix found for {COHORT_TEMPLATE_PREFIX} cohort template identifier {cohort_template_id!r}' + ) + + stripped_identifier = cohort_template_id[len(COHORT_TEMPLATE_PREFIX):] + + if not stripped_identifier.isdigit(): + raise ValueError(f'Invalid identifier found for cohort template identifier {cohort_template_id!r}') + + cohort_template_id = int(stripped_identifier) + + if not luhn_compute(cohort_template_id, offset=COHORT_TEMPLATE_CHECKSUM_OFFSET): + raise ValueError(f'Invalid checksum found for cohort template identifier {cohort_template_id!r}') + + return int(stripped_identifier[:-1]) + + +def cohort_template_id_transform_list_to_raw(cohort_template_ids: Iterable[int | str]) -> list[int]: + """ + Transform LIST of STRING cohort template identifier (CTPLXXXH) to XXX by: + - validating prefix + - validating checksum + """ + return [cohort_template_id_transform_to_raw(s) for s in cohort_template_ids] From 92091b562db2e5bdbfaff648378f59286d5ba815 Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 27 Mar 2024 09:57:08 +1100 Subject: [PATCH 111/161] Fix incorrect call of lunh_compute instead of lunh_is_valid --- models/utils/cohort_template_id_format.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/models/utils/cohort_template_id_format.py b/models/utils/cohort_template_id_format.py index 29808be4b..5f0864c49 100644 --- a/models/utils/cohort_template_id_format.py +++ b/models/utils/cohort_template_id_format.py @@ -1,7 +1,7 @@ from typing import Iterable from api.settings import COHORT_TEMPLATE_CHECKSUM_OFFSET, COHORT_TEMPLATE_PREFIX -from models.utils.luhn import luhn_compute +from models.utils.luhn import luhn_compute, luhn_is_valid def cohort_template_id_format(cohort_template_id: int | str) -> str: @@ -66,7 +66,7 @@ def cohort_template_id_transform_to_raw(cohort_template_id: int | str) -> int: cohort_template_id = int(stripped_identifier) - if not luhn_compute(cohort_template_id, offset=COHORT_TEMPLATE_CHECKSUM_OFFSET): + if not luhn_is_valid(cohort_template_id, offset=COHORT_TEMPLATE_CHECKSUM_OFFSET): raise ValueError(f'Invalid checksum found for cohort template identifier {cohort_template_id!r}') return int(stripped_identifier[:-1]) From 14aebd3d4b0943fa5888ca02245c341928a2cc33 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Wed, 27 Mar 2024 11:03:52 +1300 Subject: [PATCH 112/161] Combine John's project.xml entries into Vivian's --- db/project.xml | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/db/project.xml b/db/project.xml index 4378ad786..f915ea31b 100644 --- a/db/project.xml +++ b/db/project.xml @@ -1240,6 +1240,12 @@ + + + - - - ALTER TABLE cohort_template ADD SYSTEM VERSIONING; ALTER TABLE cohort ADD SYSTEM VERSIONING; ALTER TABLE cohort_sequencing_group ADD SYSTEM VERSIONING; ALTER TABLE analysis_cohort ADD SYSTEM VERSIONING; - - SET @@system_versioning_alter_history = 1; - - - - - - From f570e03a868c7678e2772a0a0529b96845e26c03 Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 27 Mar 2024 10:22:50 +1100 Subject: [PATCH 113/161] Add sample-type to cohort builder, fix typos --- db/python/layers/cohort.py | 2 +- scripts/create_custom_cohort.py | 6 +++++- test/test_cohort.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 7c14d6adb..ac1386a5b 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -148,7 +148,7 @@ async def create_cohort_from_criteria( raise ValueError(f'Cohort template with ID {template_id} not found') if template and cohort_criteria: - # TODO: Handle this case. For now, not supported. + # TODO: Perhaps handle this case in future. For now, not supported. raise ValueError('A cohort cannot have both criteria and be derived from a template') # Only provide a template id diff --git a/scripts/create_custom_cohort.py b/scripts/create_custom_cohort.py index 2adb20b97..44d20cab3 100644 --- a/scripts/create_custom_cohort.py +++ b/scripts/create_custom_cohort.py @@ -14,6 +14,7 @@ def main( sg_technologies: list[str], sg_platforms: list[str], sg_types: list[str], + sample_types: list[str], dry_run: bool = False ): """ Create a custom cohort""" @@ -25,7 +26,8 @@ def main( excluded_sgs_internal=excluded_sg_ids or [], sg_technology=sg_technologies or [], sg_platform=sg_platforms or [], - sg_type=sg_types or [] + sg_type=sg_types or [], + sample_types=sample_types or [], ) cohort = capi.create_cohort_from_criteria( @@ -68,6 +70,7 @@ def get_cohort_spec( parser.add_argument('--sg_technology', required=False, type=list[str], help='Sequencing group technologies') parser.add_argument('--sg_platform', required=False, type=list[str], help='Sequencing group platforms') parser.add_argument('--sg_type', required=False, type=list[str], help='Sequencing group types, e.g. exome, genome') + parser.add_argument('--sample_type', required=False, type=list[str], help='sample type') parser.add_argument('--dry_run', required=False, type=bool, help='Dry run mode') args = parser.parse_args() @@ -87,5 +90,6 @@ def get_cohort_spec( sg_technologies=args.sg_technology, sg_platforms=args.sg_platform, sg_types=args.sg_type, + sample_types=args.sample_type, dry_run=args.dry_run ) diff --git a/test/test_cohort.py b/test/test_cohort.py index ec55861d3..6280c3a77 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -305,7 +305,7 @@ async def test_reevaluate_cohort(self): template = await self.cohortl.create_cohort_template( project=self.project_id, cohort_template=CohortTemplate( - name='Boold template', + name='Blood template', description='Template selecting blood', criteria=CohortCriteria( projects=['test'], From d7a61b5d1df2394d224086418e16d416310b4a61 Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 27 Mar 2024 10:32:14 +1100 Subject: [PATCH 114/161] Return all the cohort details in builder --- scripts/create_custom_cohort.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/create_custom_cohort.py b/scripts/create_custom_cohort.py index 44d20cab3..51d6f9d59 100644 --- a/scripts/create_custom_cohort.py +++ b/scripts/create_custom_cohort.py @@ -35,7 +35,7 @@ def main( body_create_cohort_from_criteria={'cohort_spec': cohort_body_spec, 'cohort_criteria': cohort_criteria, 'dry_run': dry_run} ) - print(f'Awesome! You have created a custom cohort with id {cohort["cohort_id"]}') + print(f'Awesome! You have created a custom cohort {cohort}') def get_cohort_spec( From 6b174738d545bd7fcede240e4eae760e4ac11363 Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 27 Mar 2024 14:56:21 +1100 Subject: [PATCH 115/161] Add function to query analyses by cohort --- api/graphql/schema.py | 11 +++++++++++ db/python/tables/analysis.py | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 87cb2f890..6d8ce7c89 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -115,6 +115,17 @@ async def sequencing_groups( sequencing_groups = await sg_layer.get_sequencing_groups_by_ids(sg_ids) return [GraphQLSequencingGroup.from_internal(sg) for sg in sequencing_groups] + @strawberry.field() + async def analyses( self, info: Info, root:'Cohort') -> list['GraphQLAnalysis']: + connection = info.context['connection'] + connection.project = root.project + internal_analysis = await AnalysisLayer(connection).query( + AnalysisFilter( + cohort_id=GenericFilter(in_=[cohort_id_transform_to_raw(root.id)]), + ) + ) + return [GraphQLAnalysis.from_internal(a) for a in internal_analysis] + @strawberry.field() async def project(self, info: Info, root: 'Cohort') -> 'GraphQLProject': loader = info.context[LoaderKeys.PROJECTS_FOR_IDS] diff --git a/db/python/tables/analysis.py b/db/python/tables/analysis.py index 4f045e133..db4176135 100644 --- a/db/python/tables/analysis.py +++ b/db/python/tables/analysis.py @@ -266,6 +266,17 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: else: retvals[key] = AnalysisInternal.from_db(**dict(row)) + if retvals.keys(): + _query_sg_ids = f""" + SELECT sequencing_group_id, analysis_id + FROM analysis_sequencing_group + WHERE analysis_id IN :analysis_ids + """ + sg_ids = await self.connection.fetch_all(_query_sg_ids, {'analysis_ids': list(retvals.keys())}) + + for row in sg_ids: + retvals[row['analysis_id']].sequencing_group_ids.append(row['sequencing_group_id']) + else: _query = f""" SELECT a.id as id, a.type as type, a.status as status, @@ -284,6 +295,16 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: else: retvals[key] = AnalysisInternal.from_db(**dict(row)) + _query_cohort_ids = f""" + SELECT analysis_id, cohort_id + FROM analysis_cohort + WHERE analysis_id IN :analysis_ids; + """ + cohort_ids = await self.connection.fetch_all(_query_cohort_ids, {'analysis_ids': list(retvals.keys())}) + + for row in cohort_ids: + retvals[row['analysis_id']].cohort_ids.append(row['cohort_id']) + return list(retvals.values()) async def get_latest_complete_analysis_for_type( From 52b86bb24ede666f45d124f5553dba5969ce4f26 Mon Sep 17 00:00:00 2001 From: vivbak Date: Thu, 28 Mar 2024 07:58:56 +1100 Subject: [PATCH 116/161] Catch value error sooner when template ID is invalid --- db/python/tables/cohort.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index f26075887..2b2f49344 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -112,6 +112,9 @@ async def get_cohort_template(self, template_id: int): """ template = await self.connection.fetch_one(_query, {'template_id': template_id}) + if not template: + raise ValueError(f'Cohort template with ID {template_id} not found') + return {'id': template['id'], 'criteria': template['criteria']} async def create_cohort_template( From d6f4ac46b08f89433de3cbc2394e73fc8dc015e0 Mon Sep 17 00:00:00 2001 From: vivbak Date: Thu, 28 Mar 2024 10:04:14 +1100 Subject: [PATCH 117/161] return None instead of error --- db/python/tables/cohort.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 2b2f49344..302286e7f 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -113,7 +113,7 @@ async def get_cohort_template(self, template_id: int): template = await self.connection.fetch_one(_query, {'template_id': template_id}) if not template: - raise ValueError(f'Cohort template with ID {template_id} not found') + return None return {'id': template['id'], 'criteria': template['criteria']} From 31ebd7ca1451f649dd903bdd6646b4d0baf5ef6d Mon Sep 17 00:00:00 2001 From: John Marshall Date: Tue, 9 Apr 2024 09:21:45 +1200 Subject: [PATCH 118/161] Add tests exercising cohort queries --- test/test_cohort.py | 50 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/test/test_cohort.py b/test/test_cohort.py index 6280c3a77..2c8869751 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -1,9 +1,11 @@ +import datetime from test.testbase import DbIsolatedTest, run_as_sync from pymysql.err import IntegrityError from db.python.layers import CohortLayer, SampleLayer -from db.python.utils import Forbidden, NotFoundError +from db.python.tables.cohort import CohortFilter +from db.python.utils import Forbidden, GenericFilter, NotFoundError from models.models import SampleUpsertInternal, SequencingGroupUpsertInternal from models.models.cohort import CohortCriteria, CohortTemplate from models.utils.sequencing_group_id_format import sequencing_group_id_format @@ -132,6 +134,52 @@ async def test_create_template_then_cohorts(self): ) +class TestCohortQueries(DbIsolatedTest): + """Test query-related custom cohort layer functions""" + + @run_as_sync + async def setUp(self): + super().setUp() + self.cohortl = CohortLayer(self.connection) + + @run_as_sync + async def test_id_query(self): + """Exercise querying id against an empty database""" + result = await self.cohortl.query(CohortFilter(id=GenericFilter(eq=42))) + self.assertEqual([], result) + + @run_as_sync + async def test_name_query(self): + """Exercise querying name against an empty database""" + result = await self.cohortl.query(CohortFilter(name=GenericFilter(eq='Unknown cohort'))) + self.assertEqual([], result) + + @run_as_sync + async def test_author_query(self): + """Exercise querying author against an empty database""" + result = await self.cohortl.query(CohortFilter(author=GenericFilter(eq='Alan Smithee'))) + self.assertEqual([], result) + + @run_as_sync + async def test_template_id_query(self): + """Exercise querying template_id against an empty database""" + result = await self.cohortl.query(CohortFilter(template_id=GenericFilter(eq=28))) + self.assertEqual([], result) + + @run_as_sync + async def test_timestamp_query(self): + """Exercise querying timestamp against an empty database""" + new_years_day = datetime.datetime(2024, 1, 1) + result = await self.cohortl.query(CohortFilter(timestamp=GenericFilter(eq=new_years_day))) + self.assertEqual([], result) + + @run_as_sync + async def test_project_query(self): + """Exercise querying project against an empty database""" + result = await self.cohortl.query(CohortFilter(project=GenericFilter(eq=37))) + self.assertEqual([], result) + + def get_sample_model(eid, s_type='blood', sg_type='genome', tech='short-read', plat='illumina'): """Create a minimal sample""" return SampleUpsertInternal( From 8aa7b17d61e56121753c3ea3f6154e508cd86734 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Wed, 10 Apr 2024 14:54:27 +1200 Subject: [PATCH 119/161] Add test_query_cohort --- test/test_cohort.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/test_cohort.py b/test/test_cohort.py index 2c8869751..b32e44ecf 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -387,3 +387,27 @@ async def test_reevaluate_cohort(self): self.assertNotIn(sgD, coh1['sequencing_group_ids']) self.assertIn(sgD, coh2['sequencing_group_ids']) + + @run_as_sync + async def test_query_cohort(self): + """Create a cohort and test that it is populated when queried""" + created = await self.cohortl.create_cohort_from_criteria( + project_to_write=self.project_id, + author='bob@example.org', + description='Cohort with two samples', + cohort_name='Duo cohort', + dry_run=False, + cohort_criteria=CohortCriteria( + projects=['test'], + sg_ids_internal=[self.sgA, self.sgB], + ), + ) + self.assertEqual(2, len(created['sequencing_group_ids'])) + + queried = await self.cohortl.query(CohortFilter(name=GenericFilter(eq='Duo cohort'))) + self.assertEqual(1, len(queried)) + + result = await self.cohortl.get_cohort_sequencing_group_ids(int(queried[0].id)) + self.assertEqual(2, len(result)) + self.assertIn(self.sA.sequencing_groups[0].id, result) + self.assertIn(self.sB.sequencing_groups[0].id, result) From 5993bcfa877262c732a3ee231ada0e8ea12e7eab Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 15 Apr 2024 18:57:26 +1000 Subject: [PATCH 120/161] Cohort -> cohorts --- api/graphql/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 6d8ce7c89..87ad7a58d 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -324,7 +324,7 @@ async def analyses( return [GraphQLAnalysis.from_internal(a) for a in internal_analysis] @strawberry.field() - async def cohort( + async def cohorts( self, info: Info, root: 'Project', From 8801c27c9623a33198597c38c384a55446d0ed32 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 15 Apr 2024 19:11:42 +1000 Subject: [PATCH 121/161] Fix indent --- db/project.xml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/db/project.xml b/db/project.xml index f915ea31b..594acb0f5 100644 --- a/db/project.xml +++ b/db/project.xml @@ -1249,16 +1249,16 @@ ALTER TABLE cohort_template ADD SYSTEM VERSIONING; ALTER TABLE cohort ADD SYSTEM VERSIONING; From ac5cb09b1080321c003c366da7cc62e957d44e49 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 15 Apr 2024 19:25:30 +1000 Subject: [PATCH 122/161] Remove passing author to cohortlayer explicitly. --- api/routes/cohort.py | 1 - db/python/layers/cohort.py | 3 +-- test/test_cohort.py | 17 ----------------- 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 7ada8b974..55cc072fc 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -30,7 +30,6 @@ async def create_cohort_from_criteria( cohort_output = await cohortlayer.create_cohort_from_criteria( project_to_write=connection.project, description=cohort_spec.description, - author=connection.author, cohort_name=cohort_spec.name, dry_run=dry_run, cohort_criteria=cohort_criteria, diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index ac1386a5b..de2108146 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -122,7 +122,6 @@ async def create_cohort_template( async def create_cohort_from_criteria( self, project_to_write: ProjectId, - author: str, description: str, cohort_name: str, dry_run: bool, @@ -207,7 +206,7 @@ async def create_cohort_from_criteria( cohort_name=cohort_name, sequencing_group_ids=[sg.id for sg in sgs], description=description, - author=author, + author=self.connection.author, template_id=cohort_template_id_transform_to_raw(template_id), ) diff --git a/test/test_cohort.py b/test/test_cohort.py index 6280c3a77..3cab801c5 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -23,7 +23,6 @@ async def test_create_cohort_missing_args(self): with self.assertRaises(ValueError): _ = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='No criteria or template', cohort_name='Broken cohort', dry_run=False, @@ -35,7 +34,6 @@ async def test_create_cohort_bad_project(self): with self.assertRaises((Forbidden, NotFoundError)): _ = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Cohort based on a missing project', cohort_name='Bad-project cohort', dry_run=False, @@ -60,7 +58,6 @@ async def test_create_empty_cohort(self): """Create cohort from empty criteria""" result = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Cohort with no entries', cohort_name='Empty cohort', dry_run=False, @@ -75,7 +72,6 @@ async def test_create_duplicate_cohort(self): """Can't create cohorts with duplicate names""" _ = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Cohort with no entries', cohort_name='Trial duplicate cohort', dry_run=False, @@ -84,7 +80,6 @@ async def test_create_duplicate_cohort(self): _ = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Cohort with no entries', cohort_name='Trial duplicate cohort', dry_run=True, @@ -94,7 +89,6 @@ async def test_create_duplicate_cohort(self): with self.assertRaises(IntegrityError): _ = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Cohort with no entries', cohort_name='Trial duplicate cohort', dry_run=False, @@ -115,7 +109,6 @@ async def test_create_template_then_cohorts(self): _ = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Cohort with no entries', cohort_name='Another empty cohort', dry_run=False, @@ -124,7 +117,6 @@ async def test_create_template_then_cohorts(self): _ = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Cohort from template', cohort_name='Cohort from empty template', dry_run=False, @@ -174,7 +166,6 @@ async def test_create_cohort_by_sgs(self): """Create cohort by selecting sequencing groups""" result = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Cohort with 1 SG', cohort_name='SG cohort 1', dry_run=False, @@ -191,7 +182,6 @@ async def test_create_cohort_by_excluded_sgs(self): """Create cohort by excluding sequencing groups""" result = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Cohort without 1 SG', cohort_name='SG cohort 2', dry_run=False, @@ -210,7 +200,6 @@ async def test_create_cohort_by_technology(self): """Create cohort by selecting a technology""" result = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Short-read cohort', cohort_name='Tech cohort 1', dry_run=False, @@ -229,7 +218,6 @@ async def test_create_cohort_by_platform(self): """Create cohort by selecting a platform""" result = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='ONT cohort', cohort_name='Platform cohort 1', dry_run=False, @@ -246,7 +234,6 @@ async def test_create_cohort_by_type(self): """Create cohort by selecting types""" result = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Genome cohort', cohort_name='Type cohort 1', dry_run=False, @@ -265,7 +252,6 @@ async def test_create_cohort_by_sample_type(self): """Create cohort by selecting sample types""" result = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Sample cohort', cohort_name='Sample cohort 1', dry_run=False, @@ -282,7 +268,6 @@ async def test_create_cohort_by_everything(self): """Create cohort by selecting a variety of fields""" result = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Everything cohort', cohort_name='Everything cohort 1', dry_run=False, @@ -316,7 +301,6 @@ async def test_reevaluate_cohort(self): coh1 = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Blood cohort', cohort_name='Blood cohort 1', dry_run=False, @@ -329,7 +313,6 @@ async def test_reevaluate_cohort(self): coh2 = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Blood cohort', cohort_name='Blood cohort 2', dry_run=False, From 8e944216e1939ab7c15732ba44c729a15024086e Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 16 Apr 2024 08:51:04 +1000 Subject: [PATCH 123/161] Add type hints --- db/python/layers/cohort.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index de2108146..26aaf25be 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -27,14 +27,14 @@ def get_sg_filter( - projects, - sg_ids_internal_rich, - excluded_sgs_internal_rich, - sg_technology, - sg_platform, - sg_type, - sample_ids -): + projects: list[int], + sg_ids_internal_rich: list[str], + excluded_sgs_internal_rich: list[str], + sg_technology: list[str], + sg_platform: list[str], + sg_type: list[str], + sample_ids: list[int], +) -> SequencingGroupFilter: """ Get the sequencing group filter for cohort attributes""" # Format inputs for filter From bcc45416869ff2b47949a4f73372171f719b4b8d Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 16 Apr 2024 08:57:50 +1000 Subject: [PATCH 124/161] Rename clayer and cohortlayer to cohort_layer --- api/graphql/schema.py | 8 ++++---- api/routes/cohort.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 87ad7a58d..fdefc93f6 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -822,7 +822,7 @@ async def cohort_template( project: GraphQLFilter[str] | None = None, ) -> list[GraphQLCohortTemplate]: connection = info.context['connection'] - clayer = CohortLayer(connection) + cohort_layer = CohortLayer(connection) ptable = ProjectPermissionsTable(connection) project_name_map: dict[str, int] = {} @@ -842,7 +842,7 @@ async def cohort_template( project=project_filter, ) - cohort_templates = await clayer.query_cohort_templates(filter_) + cohort_templates = await cohort_layer.query_cohort_templates(filter_) return [GraphQLCohortTemplate.from_internal(cohort_template) for cohort_template in cohort_templates] @strawberry.field() @@ -856,7 +856,7 @@ async def cohort( template_id: GraphQLFilter[int] | None = None, ) -> list[GraphQLCohort]: connection = info.context['connection'] - clayer = CohortLayer(connection) + cohort_layer = CohortLayer(connection) ptable = ProjectPermissionsTable(connection) project_name_map: dict[str, int] = {} @@ -879,7 +879,7 @@ async def cohort( template_id=template_id.to_internal_filter(cohort_template_id_transform_to_raw) if template_id else None, ) - cohorts = await clayer.query(filter_) + cohorts = await cohort_layer.query(filter_) return [GraphQLCohort.from_internal(cohort) for cohort in cohorts] @strawberry.field() diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 55cc072fc..6797f3789 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -19,7 +19,7 @@ async def create_cohort_from_criteria( """ Create a cohort with the given name and sample/sequencing group IDs. """ - cohortlayer = CohortLayer(connection) + cohort_layer = CohortLayer(connection) if not connection.project: raise ValueError('A cohort must belong to a project') @@ -27,7 +27,7 @@ async def create_cohort_from_criteria( if not cohort_criteria and not cohort_spec.template_id: raise ValueError('A cohort must have either criteria or be derived from a template') - cohort_output = await cohortlayer.create_cohort_from_criteria( + cohort_output = await cohort_layer.create_cohort_from_criteria( project_to_write=connection.project, description=cohort_spec.description, cohort_name=cohort_spec.name, @@ -47,12 +47,12 @@ async def create_cohort_template( """ Create a cohort template with the given name and sample/sequencing group IDs. """ - cohortlayer = CohortLayer(connection) + cohort_layer = CohortLayer(connection) if not connection.project: raise ValueError('A cohort template must belong to a project') - return await cohortlayer.create_cohort_template( + return await cohort_layer.create_cohort_template( cohort_template=template, project=connection.project ) From 2e08db45b6dc3a1e42ae5cd64f0e045a50da8142 Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 16 Apr 2024 09:48:41 +1000 Subject: [PATCH 125/161] fix lint, author removed. Add newline --- db/python/tables/cohort.py | 3 ++- test/test_cohort.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 302286e7f..75eb60931 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -164,7 +164,8 @@ async def create_cohort( _query = """ INSERT INTO cohort (name, template_id, author, description, project, timestamp, audit_log_id) - VALUES (:name, :template_id, :author, :description, :project, :timestamp, :audit_log_id) RETURNING id + VALUES (:name, :template_id, :author, :description, :project, :timestamp, :audit_log_id) + RETURNING id """ cohort_id = await self.connection.fetch_val( diff --git a/test/test_cohort.py b/test/test_cohort.py index b18a8e5d7..38e4b7067 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -376,7 +376,6 @@ async def test_query_cohort(self): """Create a cohort and test that it is populated when queried""" created = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, - author='bob@example.org', description='Cohort with two samples', cohort_name='Duo cohort', dry_run=False, From 669341ed6c5b23548b5be86ffe947c8df505b7c7 Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 16 Apr 2024 09:55:55 +1000 Subject: [PATCH 126/161] Fix whitespace for linter --- db/python/tables/cohort.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 75eb60931..91ad04924 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -164,7 +164,7 @@ async def create_cohort( _query = """ INSERT INTO cohort (name, template_id, author, description, project, timestamp, audit_log_id) - VALUES (:name, :template_id, :author, :description, :project, :timestamp, :audit_log_id) + VALUES (:name, :template_id, :author, :description, :project, :timestamp, :audit_log_id) RETURNING id """ From 892128b8f4d31da10302c1f1b389c553e5cd92ad Mon Sep 17 00:00:00 2001 From: vivbak Date: Wed, 17 Apr 2024 09:39:24 +1000 Subject: [PATCH 127/161] template id should not be nullable --- models/models/cohort.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/models/cohort.py b/models/models/cohort.py index 2703c75bf..699934f36 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -14,7 +14,7 @@ class Cohort(SMBase): author: str project: str description: str - template_id: int | None + template_id: int sequencing_groups: list[SequencingGroup | SequencingGroupExternalId] @staticmethod From 68cd5b8e1d39fa991df751513dc0fa68d7f6cfc3 Mon Sep 17 00:00:00 2001 From: vivbak Date: Thu, 18 Apr 2024 20:39:13 +1000 Subject: [PATCH 128/161] Remove template_id, specify as strawberry field instead. Rename CohortTemplateModel to CohortTemplate. --- api/graphql/schema.py | 17 +++++++++++++---- db/python/layers/cohort.py | 28 +++++++++++++++++++++++++++- db/python/tables/cohort.py | 26 ++++++++++++++++++++++---- models/models/__init__.py | 2 +- models/models/cohort.py | 5 +++-- test/test_cohort.py | 3 +++ 6 files changed, 69 insertions(+), 12 deletions(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index bd2bb8fba..5698948c1 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -41,7 +41,7 @@ AssayInternal, AuditLogInternal, Cohort, - CohortTemplateModel, + CohortTemplateInternal, FamilyInternal, ParticipantInternal, Project, @@ -91,7 +91,6 @@ class GraphQLCohort: name: str description: str author: str - template_id: str | None = None @staticmethod def from_internal(internal: Cohort) -> 'GraphQLCohort': @@ -100,8 +99,17 @@ def from_internal(internal: Cohort) -> 'GraphQLCohort': name=internal.name, description=internal.description, author=internal.author, - template_id=cohort_template_id_format(internal.template_id), ) + @strawberry.field() + async def template( + self, info: Info, root: 'Cohort' + ) -> 'GraphQLCohortTemplate': + connection = info.context['connection'] + template = await CohortLayer(connection).get_template_by_cohort_id( + cohort_id_transform_to_raw(root.id) + ) + + return GraphQLCohortTemplate.from_internal(template) @strawberry.field() async def sequencing_groups( @@ -143,7 +151,8 @@ class GraphQLCohortTemplate: criteria: strawberry.scalars.JSON @staticmethod - def from_internal(internal: CohortTemplateModel) -> 'GraphQLCohortTemplate': + def from_internal(internal: CohortTemplateInternal) -> 'GraphQLCohortTemplate': + # At this point, the object that comes in doesn't have an ID field. return GraphQLCohortTemplate( id=cohort_template_id_format(internal.id), name=internal.name, diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 26aaf25be..4973b3a0e 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -12,7 +12,12 @@ SequencingGroupTable, ) from db.python.utils import GenericFilter, get_logger -from models.models.cohort import Cohort, CohortCriteria, CohortTemplate +from models.models.cohort import ( + Cohort, + CohortCriteria, + CohortTemplate, + CohortTemplateInternal, +) from models.utils.cohort_id_format import cohort_id_format from models.utils.cohort_template_id_format import ( cohort_template_id_format, @@ -89,6 +94,24 @@ async def query_cohort_templates(self, filter_: CohortTemplateFilter) -> list[Co cohort_templates = await self.ct.query_cohort_templates(filter_) return cohort_templates + async def get_template_by_cohort_id(self, cohort_id: int) -> CohortTemplateInternal: + """ + Get the cohort template for a given cohort ID. + """ + + cohort = await self.ct.get_cohort_by_id(cohort_id) + + template_id = cohort.template_id + if not template_id: + raise ValueError(f'Cohort with ID {cohort_id} does not have a template') + + template = await self.ct.get_cohort_template(template_id) + + if not template: + raise ValueError(f'Cohort template with ID {template_id} not found') + + return CohortTemplateInternal(**dict(template)) + async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: """ Get the sequencing group IDs for the given cohort. @@ -110,6 +133,8 @@ async def create_cohort_template( user=self.connection.author, project_names=cohort_template.criteria.projects, readonly=False ) + assert cohort_template.id is None, 'Cohort template ID must be None' + template_id = await self.ct.create_cohort_template( name=cohort_template.name, description=cohort_template.description, @@ -189,6 +214,7 @@ async def create_cohort_from_criteria( # 2. Create cohort template, if required. if create_cohort_template: cohort_template = CohortTemplate( + id=None, name=cohort_name, description=description, criteria=cohort_criteria diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 91ad04924..05c572f26 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -5,7 +5,7 @@ from db.python.tables.base import DbBase from db.python.tables.project import ProjectId from db.python.utils import GenericFilter, GenericFilterModel, to_db_json -from models.models.cohort import Cohort, CohortTemplateModel +from models.models.cohort import Cohort, CohortTemplateInternal @dataclasses.dataclass(kw_only=True) @@ -100,7 +100,7 @@ async def query_cohort_templates(self, filter_: CohortTemplateFilter): # TODO: """ rows = await self.connection.fetch_all(_query, values) - cohort_templates = [CohortTemplateModel.from_db(dict(row)) for row in rows] + cohort_templates = [CohortTemplateInternal.from_db(dict(row)) for row in rows] return cohort_templates async def get_cohort_template(self, template_id: int): @@ -108,14 +108,16 @@ async def get_cohort_template(self, template_id: int): Get a cohort template by ID """ _query = """ - SELECT id as id, criteria as criteria FROM cohort_template WHERE id = :template_id + SELECT id as id, name, description, criteria as criteria FROM cohort_template WHERE id = :template_id """ template = await self.connection.fetch_one(_query, {'template_id': template_id}) if not template: return None - return {'id': template['id'], 'criteria': template['criteria']} + cohort_template = CohortTemplateInternal.from_db(dict(template)) + + return cohort_template async def create_cohort_template( self, @@ -197,3 +199,19 @@ async def create_cohort( ) return cohort_id + + async def get_cohort_by_id(self, cohort_id: int) -> Cohort: + """ + Get the cohort by its ID + """ + _query = """ + SELECT id, name, template_id, author, description, project, timestamp + FROM cohort + WHERE id = :cohort_id + """ + + cohort = await self.connection.fetch_one(_query, {'cohort_id': cohort_id}) + if not cohort: + raise ValueError(f'Cohort with ID {cohort_id} not found') + + return Cohort.from_db(dict(cohort)) diff --git a/models/models/__init__.py b/models/models/__init__.py index 3ac20705e..a4fbcf368 100644 --- a/models/models/__init__.py +++ b/models/models/__init__.py @@ -20,7 +20,7 @@ BillingTotalCostQueryModel, BillingTotalCostRecord, ) -from models.models.cohort import Cohort, CohortTemplateModel +from models.models.cohort import Cohort, CohortTemplateInternal from models.models.family import ( Family, FamilyInternal, diff --git a/models/models/cohort.py b/models/models/cohort.py index 699934f36..cf93662ef 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -41,7 +41,7 @@ def from_db(d: dict): ) -class CohortTemplateModel(SMBase): +class CohortTemplateInternal(SMBase): """Model for CohortTemplate""" id: int @@ -61,7 +61,7 @@ def from_db(d: dict): if criteria and isinstance(criteria, str): criteria = json.loads(criteria) - return CohortTemplateModel( + return CohortTemplateInternal( id=_id, name=name, description=description, @@ -92,6 +92,7 @@ class CohortCriteria(BaseModel): class CohortTemplate(BaseModel): """ Represents a cohort template, to be used to build cohorts. """ + id: int | None name: str description: str criteria: CohortCriteria diff --git a/test/test_cohort.py b/test/test_cohort.py index 38e4b7067..077831b19 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -49,6 +49,7 @@ async def test_create_template_bad_project(self): _ = await self.cohortl.create_cohort_template( project=self.project_id, cohort_template=CohortTemplate( + id=None, name='Bad-project template', description='Template based on a missing project', criteria=CohortCriteria(projects=['nonexistent']), @@ -103,6 +104,7 @@ async def test_create_template_then_cohorts(self): tid = await self.cohortl.create_cohort_template( project=self.project_id, cohort_template=CohortTemplate( + id=None, name='Empty template', description='Template with no entries', criteria=CohortCriteria(projects=['test']), @@ -338,6 +340,7 @@ async def test_reevaluate_cohort(self): template = await self.cohortl.create_cohort_template( project=self.project_id, cohort_template=CohortTemplate( + id=None, name='Blood template', description='Template selecting blood', criteria=CohortCriteria( From 6f42610aa22edf3c517e70af5654a318cee59ad8 Mon Sep 17 00:00:00 2001 From: vivbak Date: Thu, 18 Apr 2024 20:51:15 +1000 Subject: [PATCH 129/161] Fix CohortTemplateInternal object is not subscriptable --- db/python/layers/cohort.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 4973b3a0e..9387dea06 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -178,7 +178,7 @@ async def create_cohort_from_criteria( # Only provide a template id if template and not cohort_criteria: create_cohort_template = False - criteria_dict = json.loads(template['criteria']) + criteria_dict = template.criteria cohort_criteria = CohortCriteria(**criteria_dict) projects_to_pull = await self.pt.get_and_check_access_to_projects_for_names( From 3ee14bab10126a3e51e85b5dca193a0b1cef7076 Mon Sep 17 00:00:00 2001 From: vivbak Date: Thu, 18 Apr 2024 21:05:41 +1000 Subject: [PATCH 130/161] Fix failing tests by modifying type hints, dict[str,str] -> CohortTemplateInternal --- db/python/layers/cohort.py | 4 +--- db/python/tables/cohort.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 9387dea06..8bdf12d02 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -1,5 +1,3 @@ -import json - from db.python.connect import Connection from db.python.layers.base import BaseLayer from db.python.layers.sequencing_group import SequencingGroupLayer @@ -163,8 +161,8 @@ async def create_cohort_from_criteria( if not cohort_criteria and not template_id: raise ValueError('A cohort must have either criteria or be derived from a template') + template : CohortTemplateInternal = None # Get template from ID - template: dict[str, str] = {} if template_id: template_id_raw = cohort_template_id_transform_to_raw(template_id) template = await self.ct.get_cohort_template(template_id_raw) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 05c572f26..b6c5ad504 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -103,7 +103,7 @@ async def query_cohort_templates(self, filter_: CohortTemplateFilter): # TODO: cohort_templates = [CohortTemplateInternal.from_db(dict(row)) for row in rows] return cohort_templates - async def get_cohort_template(self, template_id: int): + async def get_cohort_template(self, template_id: int) -> CohortTemplateInternal: """ Get a cohort template by ID """ From e1c6b86dfd47456b6491ad6f5c9d6c969c1eaf15 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 11:32:14 +1000 Subject: [PATCH 131/161] Add project ID checks --- db/python/layers/cohort.py | 136 ++++++++++++++++++++++++------------- db/python/tables/cohort.py | 48 +++++++------ models/models/cohort.py | 5 +- test/test_cohort.py | 28 ++++++-- 4 files changed, 137 insertions(+), 80 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 8bdf12d02..2a639da1c 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -30,26 +30,32 @@ def get_sg_filter( - projects: list[int], - sg_ids_internal_rich: list[str], - excluded_sgs_internal_rich: list[str], - sg_technology: list[str], - sg_platform: list[str], - sg_type: list[str], - sample_ids: list[int], + projects: list[int], + sg_ids_internal_rich: list[str], + excluded_sgs_internal_rich: list[str], + sg_technology: list[str], + sg_platform: list[str], + sg_type: list[str], + sample_ids: list[int], ) -> SequencingGroupFilter: - """ Get the sequencing group filter for cohort attributes""" + """Get the sequencing group filter for cohort attributes""" # Format inputs for filter sg_ids_internal_raw = [] excluded_sgs_internal_raw = [] if sg_ids_internal_rich: - sg_ids_internal_raw = sequencing_group_id_transform_to_raw_list(sg_ids_internal_rich) + sg_ids_internal_raw = sequencing_group_id_transform_to_raw_list( + sg_ids_internal_rich + ) if excluded_sgs_internal_rich: - excluded_sgs_internal_raw = sequencing_group_id_transform_to_raw_list(excluded_sgs_internal_rich) + excluded_sgs_internal_raw = sequencing_group_id_transform_to_raw_list( + excluded_sgs_internal_rich + ) if sg_ids_internal_raw and excluded_sgs_internal_raw: - sg_id_filter = GenericFilter(in_=sg_ids_internal_raw, nin=excluded_sgs_internal_raw) + sg_id_filter = GenericFilter( + in_=sg_ids_internal_raw, nin=excluded_sgs_internal_raw + ) elif sg_ids_internal_raw: sg_id_filter = GenericFilter(in_=sg_ids_internal_raw) elif excluded_sgs_internal_raw: @@ -58,13 +64,13 @@ def get_sg_filter( sg_id_filter = None sg_filter = SequencingGroupFilter( - project=GenericFilter(in_=projects), - id=sg_id_filter, - technology=GenericFilter(in_=sg_technology) if sg_technology else None, - platform=GenericFilter(in_=sg_platform) if sg_platform else None, - type=GenericFilter(in_=sg_type) if sg_type else None, - sample_id=GenericFilter(in_=sample_ids) if sample_ids else None, - ) + project=GenericFilter(in_=projects), + id=sg_id_filter, + technology=GenericFilter(in_=sg_technology) if sg_technology else None, + platform=GenericFilter(in_=sg_platform) if sg_platform else None, + type=GenericFilter(in_=sg_type) if sg_type else None, + sample_id=GenericFilter(in_=sample_ids) if sample_ids else None, + ) return sg_filter @@ -82,14 +88,35 @@ def __init__(self, connection: Connection): self.sgt = SequencingGroupTable(connection) self.sglayer = SequencingGroupLayer(self.connection) - async def query(self, filter_: CohortFilter) -> list[Cohort]: + async def query( + self, filter_: CohortFilter, check_project_ids: bool = True + ) -> list[Cohort]: """Query Cohorts""" - cohorts = await self.ct.query(filter_) + cohorts, project_ids = await self.ct.query(filter_) + + if not cohorts: + return [] + + if check_project_ids: + await self.pt.get_and_check_access_to_projects_for_ids( + user=self.connection.author, + project_ids=list(project_ids), + readonly=True, + ) return cohorts - async def query_cohort_templates(self, filter_: CohortTemplateFilter) -> list[CohortTemplate]: + async def query_cohort_templates( + self, filter_: CohortTemplateFilter, check_project_ids: bool = True + ) -> list[CohortTemplateInternal]: """Query CohortTemplates""" - cohort_templates = await self.ct.query_cohort_templates(filter_) + cohort_templates, project_ids = await self.ct.query_cohort_templates(filter_) + + if check_project_ids: + await self.pt.get_and_check_access_to_projects_for_ids( + user=self.connection.author, + project_ids=list(project_ids), + readonly=True, + ) return cohort_templates async def get_template_by_cohort_id(self, cohort_id: int) -> CohortTemplateInternal: @@ -117,10 +144,9 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: return await self.ct.get_cohort_sequencing_group_ids(cohort_id) async def create_cohort_template( - self, - cohort_template: CohortTemplate, - project: ProjectId, - + self, + cohort_template: CohortTemplate, + project: ProjectId, ): """ Create new cohort template @@ -128,7 +154,9 @@ async def create_cohort_template( # Validate projects specified in criteria are valid _ = await self.pt.get_and_check_access_to_projects_for_names( - user=self.connection.author, project_names=cohort_template.criteria.projects, readonly=False + user=self.connection.author, + project_names=cohort_template.criteria.projects, + readonly=False, ) assert cohort_template.id is None, 'Cohort template ID must be None' @@ -137,19 +165,19 @@ async def create_cohort_template( name=cohort_template.name, description=cohort_template.description, criteria=dict(cohort_template.criteria), - project=project + project=project, ) return cohort_template_id_format(template_id) async def create_cohort_from_criteria( - self, - project_to_write: ProjectId, - description: str, - cohort_name: str, - dry_run: bool, - cohort_criteria: CohortCriteria = None, - template_id: str = None, + self, + project_to_write: ProjectId, + description: str, + cohort_name: str, + dry_run: bool, + cohort_criteria: CohortCriteria = None, + template_id: str = None, ): """ Create a new cohort from the given parameters. Returns the newly created cohort_id. @@ -159,9 +187,11 @@ async def create_cohort_from_criteria( # Input validation if not cohort_criteria and not template_id: - raise ValueError('A cohort must have either criteria or be derived from a template') + raise ValueError( + 'A cohort must have either criteria or be derived from a template' + ) - template : CohortTemplateInternal = None + template: CohortTemplateInternal = None # Get template from ID if template_id: template_id_raw = cohort_template_id_transform_to_raw(template_id) @@ -171,7 +201,9 @@ async def create_cohort_from_criteria( if template and cohort_criteria: # TODO: Perhaps handle this case in future. For now, not supported. - raise ValueError('A cohort cannot have both criteria and be derived from a template') + raise ValueError( + 'A cohort cannot have both criteria and be derived from a template' + ) # Only provide a template id if template and not cohort_criteria: @@ -180,14 +212,20 @@ async def create_cohort_from_criteria( cohort_criteria = CohortCriteria(**criteria_dict) projects_to_pull = await self.pt.get_and_check_access_to_projects_for_names( - user=self.connection.author, project_names=cohort_criteria.projects, readonly=True + user=self.connection.author, + project_names=cohort_criteria.projects, + readonly=True, ) projects_to_pull = [p.id for p in projects_to_pull] # Get sample IDs with sample type sample_filter = SampleFilter( project=GenericFilter(in_=projects_to_pull), - type=GenericFilter(in_=cohort_criteria.sample_type) if cohort_criteria.sample_type else None, + type=( + GenericFilter(in_=cohort_criteria.sample_type) + if cohort_criteria.sample_type + else None + ), ) _, samples = await self.sampt.query(sample_filter) @@ -199,7 +237,7 @@ async def create_cohort_from_criteria( sg_technology=cohort_criteria.sg_technology, sg_platform=cohort_criteria.sg_platform, sg_type=cohort_criteria.sg_type, - sample_ids=[s.id for s in samples] + sample_ids=[s.id for s in samples], ) sgs = await self.sglayer.query(sg_filter) @@ -207,7 +245,11 @@ async def create_cohort_from_criteria( rich_ids = sequencing_group_id_format_list([sg.id for sg in sgs]) if dry_run: - return {'dry_run': True, 'template_id': template_id or 'CREATE NEW', 'sequencing_group_ids': rich_ids} + return { + 'dry_run': True, + 'template_id': template_id or 'CREATE NEW', + 'sequencing_group_ids': rich_ids, + } # 2. Create cohort template, if required. if create_cohort_template: @@ -215,11 +257,10 @@ async def create_cohort_from_criteria( id=None, name=cohort_name, description=description, - criteria=cohort_criteria + criteria=cohort_criteria, ) template_id = await self.create_cohort_template( - cohort_template=cohort_template, - project=project_to_write + cohort_template=cohort_template, project=project_to_write ) assert template_id, 'Template ID must be set' @@ -234,4 +275,7 @@ async def create_cohort_from_criteria( template_id=cohort_template_id_transform_to_raw(template_id), ) - return {'cohort_id': cohort_id_format(cohort_id), 'sequencing_group_ids': rich_ids} + return { + 'cohort_id': cohort_id_format(cohort_id), + 'sequencing_group_ids': rich_ids, + } diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index b6c5ad504..51fbe8eab 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -50,15 +50,9 @@ class CohortTable(DbBase): 'project', ] - template_keys = [ - 'id', - 'name', - 'description', - 'criteria', - 'project' - ] + template_keys = ['id', 'name', 'description', 'criteria', 'project'] - async def query(self, filter_: CohortFilter): + async def query(self, filter_: CohortFilter) -> tuple[list[Cohort], set[ProjectId]]: """Query Cohorts""" wheres, values = filter_.to_sql(field_overrides={}) if not wheres: @@ -73,7 +67,8 @@ async def query(self, filter_: CohortFilter): rows = await self.connection.fetch_all(_query, values) cohorts = [Cohort.from_db(dict(row)) for row in rows] - return cohorts + projects = {c.project for c in cohorts} + return cohorts, projects async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: """ @@ -86,7 +81,9 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: rows = await self.connection.fetch_all(_query, {'cohort_id': cohort_id}) return [row['sequencing_group_id'] for row in rows] - async def query_cohort_templates(self, filter_: CohortTemplateFilter): # TODO: Move this to its own class? + async def query_cohort_templates( + self, filter_: CohortTemplateFilter + ) -> tuple[list[CohortTemplateInternal], set[ProjectId]]: """Query CohortTemplates""" wheres, values = filter_.to_sql(field_overrides={}) if not wheres: @@ -101,7 +98,8 @@ async def query_cohort_templates(self, filter_: CohortTemplateFilter): # TODO: rows = await self.connection.fetch_all(_query, values) cohort_templates = [CohortTemplateInternal.from_db(dict(row)) for row in rows] - return cohort_templates + projects = {c.project for c in cohort_templates} + return cohort_templates, projects async def get_cohort_template(self, template_id: int) -> CohortTemplateInternal: """ @@ -120,11 +118,11 @@ async def get_cohort_template(self, template_id: int) -> CohortTemplateInternal: return cohort_template async def create_cohort_template( - self, - name: str, - description: str, - criteria: dict, - project: ProjectId, + self, + name: str, + description: str, + criteria: dict, + project: ProjectId, ): """ Create new cohort template @@ -134,15 +132,15 @@ async def create_cohort_template( VALUES (:name, :description, :criteria, :project, :audit_log_id) RETURNING id; """ cohort_template_id = await self.connection.fetch_val( - _query, - { - 'name': name, - 'description': description, - 'criteria': to_db_json(criteria), - 'project': project, - 'audit_log_id': await self.audit_log_id(), - }, - ) + _query, + { + 'name': name, + 'description': description, + 'criteria': to_db_json(criteria), + 'project': project, + 'audit_log_id': await self.audit_log_id(), + }, + ) return cohort_template_id diff --git a/models/models/cohort.py b/models/models/cohort.py index cf93662ef..cb239d815 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from models.base import SMBase +from models.models.project import ProjectId from models.models.sequencing_group import SequencingGroup, SequencingGroupExternalId @@ -12,7 +13,7 @@ class Cohort(SMBase): id: str name: str author: str - project: str + project: ProjectId description: str template_id: int sequencing_groups: list[SequencingGroup | SequencingGroupExternalId] @@ -90,7 +91,7 @@ class CohortCriteria(BaseModel): class CohortTemplate(BaseModel): - """ Represents a cohort template, to be used to build cohorts. """ + """Represents a cohort template, to be used to build cohorts.""" id: int | None name: str diff --git a/test/test_cohort.py b/test/test_cohort.py index 077831b19..ae30ff3b2 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -145,26 +145,34 @@ async def test_id_query(self): @run_as_sync async def test_name_query(self): """Exercise querying name against an empty database""" - result = await self.cohortl.query(CohortFilter(name=GenericFilter(eq='Unknown cohort'))) + result = await self.cohortl.query( + CohortFilter(name=GenericFilter(eq='Unknown cohort')) + ) self.assertEqual([], result) @run_as_sync async def test_author_query(self): """Exercise querying author against an empty database""" - result = await self.cohortl.query(CohortFilter(author=GenericFilter(eq='Alan Smithee'))) + result = await self.cohortl.query( + CohortFilter(author=GenericFilter(eq='Alan Smithee')) + ) self.assertEqual([], result) @run_as_sync async def test_template_id_query(self): """Exercise querying template_id against an empty database""" - result = await self.cohortl.query(CohortFilter(template_id=GenericFilter(eq=28))) + result = await self.cohortl.query( + CohortFilter(template_id=GenericFilter(eq=28)) + ) self.assertEqual([], result) @run_as_sync async def test_timestamp_query(self): """Exercise querying timestamp against an empty database""" new_years_day = datetime.datetime(2024, 1, 1) - result = await self.cohortl.query(CohortFilter(timestamp=GenericFilter(eq=new_years_day))) + result = await self.cohortl.query( + CohortFilter(timestamp=GenericFilter(eq=new_years_day)) + ) self.assertEqual([], result) @run_as_sync @@ -174,7 +182,9 @@ async def test_project_query(self): self.assertEqual([], result) -def get_sample_model(eid, s_type='blood', sg_type='genome', tech='short-read', plat='illumina'): +def get_sample_model( + eid, s_type='blood', sg_type='genome', tech='short-read', plat='illumina' +): """Create a minimal sample""" return SampleUpsertInternal( meta={}, @@ -205,7 +215,9 @@ async def setUp(self): self.sA = await self.samplel.upsert_sample(get_sample_model('A')) self.sB = await self.samplel.upsert_sample(get_sample_model('B')) - self.sC = await self.samplel.upsert_sample(get_sample_model('C', 'saliva', 'exome', 'long-read', 'ONT')) + self.sC = await self.samplel.upsert_sample( + get_sample_model('C', 'saliva', 'exome', 'long-read', 'ONT') + ) self.sgA = sequencing_group_id_format(self.sA.sequencing_groups[0].id) self.sgB = sequencing_group_id_format(self.sB.sequencing_groups[0].id) @@ -389,7 +401,9 @@ async def test_query_cohort(self): ) self.assertEqual(2, len(created['sequencing_group_ids'])) - queried = await self.cohortl.query(CohortFilter(name=GenericFilter(eq='Duo cohort'))) + queried = await self.cohortl.query( + CohortFilter(name=GenericFilter(eq='Duo cohort')) + ) self.assertEqual(1, len(queried)) result = await self.cohortl.get_cohort_sequencing_group_ids(int(queried[0].id)) From 750a18caffd47dd0f3be616ea8adf105892be5b5 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 11:49:43 +1000 Subject: [PATCH 132/161] Raise ValueError instead of assertion, to ensure it is caught --- db/python/layers/cohort.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 2a639da1c..9e63be6c6 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -263,7 +263,8 @@ async def create_cohort_from_criteria( cohort_template=cohort_template, project=project_to_write ) - assert template_id, 'Template ID must be set' + if not template_id: + raise ValueError('Template ID must be set') # 3. Create Cohort cohort_id = await self.ct.create_cohort( From 65b2212e9b56a2ab0c82e2900e7273243b18e580 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 11:51:02 +1000 Subject: [PATCH 133/161] Remove author being explicitly passed, use one from connection --- db/python/layers/cohort.py | 1 - db/python/tables/cohort.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 9e63be6c6..f67e5fe38 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -272,7 +272,6 @@ async def create_cohort_from_criteria( cohort_name=cohort_name, sequencing_group_ids=[sg.id for sg in sgs], description=description, - author=self.connection.author, template_id=cohort_template_id_transform_to_raw(template_id), ) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 51fbe8eab..121973a33 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -149,7 +149,6 @@ async def create_cohort( project: int, cohort_name: str, sequencing_group_ids: list[int], - author: str, description: str, template_id: int, ) -> int: @@ -172,7 +171,7 @@ async def create_cohort( _query, { 'template_id': template_id, - 'author': author, + 'author': self.author, 'description': description, 'project': project, 'name': cohort_name, From f594167a7e89041b394b7c67600bf0e35a6817c8 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 12:01:18 +1000 Subject: [PATCH 134/161] Plural cohort, cohort_template fields --- api/graphql/schema.py | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 5698948c1..0e9aed0a6 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -100,10 +100,9 @@ def from_internal(internal: Cohort) -> 'GraphQLCohort': description=internal.description, author=internal.author, ) + @strawberry.field() - async def template( - self, info: Info, root: 'Cohort' - ) -> 'GraphQLCohortTemplate': + async def template(self, info: Info, root: 'Cohort') -> 'GraphQLCohortTemplate': connection = info.context['connection'] template = await CohortLayer(connection).get_template_by_cohort_id( cohort_id_transform_to_raw(root.id) @@ -117,14 +116,16 @@ async def sequencing_groups( ) -> list['GraphQLSequencingGroup']: connection = info.context['connection'] cohort_layer = CohortLayer(connection) - sg_ids = await cohort_layer.get_cohort_sequencing_group_ids(cohort_id_transform_to_raw(root.id)) + sg_ids = await cohort_layer.get_cohort_sequencing_group_ids( + cohort_id_transform_to_raw(root.id) + ) sg_layer = SequencingGroupLayer(connection) sequencing_groups = await sg_layer.get_sequencing_groups_by_ids(sg_ids) return [GraphQLSequencingGroup.from_internal(sg) for sg in sequencing_groups] @strawberry.field() - async def analyses( self, info: Info, root:'Cohort') -> list['GraphQLAnalysis']: + async def analyses(self, info: Info, root: 'Cohort') -> list['GraphQLAnalysis']: connection = info.context['connection'] connection.project = root.project internal_analysis = await AnalysisLayer(connection).query( @@ -140,6 +141,7 @@ async def project(self, info: Info, root: 'Cohort') -> 'GraphQLProject': project = await loader.load(root.project) return GraphQLProject.from_internal(project) + # Create cohort template GraphQL model @strawberry.type class GraphQLCohortTemplate: @@ -161,7 +163,6 @@ def from_internal(internal: CohortTemplateInternal) -> 'GraphQLCohortTemplate': ) - @strawberry.type class GraphQLProject: """Project GraphQL model""" @@ -350,7 +351,11 @@ async def cohorts( id=id.to_internal_filter(cohort_id_transform_to_raw) if id else None, name=name.to_internal_filter() if name else None, author=author.to_internal_filter() if author else None, - template_id=template_id.to_internal_filter(cohort_template_id_transform_to_raw) if template_id else None, + template_id=( + template_id.to_internal_filter(cohort_template_id_transform_to_raw) + if template_id + else None + ), timestamp=timestamp.to_internal_filter() if timestamp else None, ) @@ -824,7 +829,7 @@ def enum(self, info: Info) -> GraphQLEnum: return GraphQLEnum() @strawberry.field() - async def cohort_template( + async def cohort_templates( self, info: Info, id: GraphQLFilter[str] | None = None, @@ -847,15 +852,22 @@ async def cohort_template( ) filter_ = CohortTemplateFilter( - id=id.to_internal_filter(cohort_template_id_transform_to_raw) if id else None, + id=( + id.to_internal_filter(cohort_template_id_transform_to_raw) + if id + else None + ), project=project_filter, ) cohort_templates = await cohort_layer.query_cohort_templates(filter_) - return [GraphQLCohortTemplate.from_internal(cohort_template) for cohort_template in cohort_templates] + return [ + GraphQLCohortTemplate.from_internal(cohort_template) + for cohort_template in cohort_templates + ] @strawberry.field() - async def cohort( + async def cohorts( self, info: Info, id: GraphQLFilter[str] | None = None, @@ -885,7 +897,11 @@ async def cohort( name=name.to_internal_filter() if name else None, project=project_filter, author=author.to_internal_filter() if author else None, - template_id=template_id.to_internal_filter(cohort_template_id_transform_to_raw) if template_id else None, + template_id=( + template_id.to_internal_filter(cohort_template_id_transform_to_raw) + if template_id + else None + ), ) cohorts = await cohort_layer.query(filter_) From ae19d202a4335d9faf046d8e0b6ce44dc46ae484 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 13:25:06 +1000 Subject: [PATCH 135/161] map to dict later, pass model --- db/python/layers/cohort.py | 2 +- db/python/tables/cohort.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index f67e5fe38..a750784cd 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -164,7 +164,7 @@ async def create_cohort_template( template_id = await self.ct.create_cohort_template( name=cohort_template.name, description=cohort_template.description, - criteria=dict(cohort_template.criteria), + criteria=cohort_template.criteria, project=project, ) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 121973a33..4cd536165 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -5,7 +5,7 @@ from db.python.tables.base import DbBase from db.python.tables.project import ProjectId from db.python.utils import GenericFilter, GenericFilterModel, to_db_json -from models.models.cohort import Cohort, CohortTemplateInternal +from models.models.cohort import Cohort, CohortCriteria, CohortTemplateInternal @dataclasses.dataclass(kw_only=True) @@ -121,7 +121,7 @@ async def create_cohort_template( self, name: str, description: str, - criteria: dict, + criteria: CohortCriteria, project: ProjectId, ): """ @@ -136,7 +136,7 @@ async def create_cohort_template( { 'name': name, 'description': description, - 'criteria': to_db_json(criteria), + 'criteria': to_db_json(dict(criteria)), 'project': project, 'audit_log_id': await self.audit_log_id(), }, From ef9d85e726227c23f24d441d4b2246899415fac2 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 14:10:56 +1000 Subject: [PATCH 136/161] execute -> execute_many --- db/python/tables/cohort.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 4cd536165..41b648156 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -185,15 +185,17 @@ async def create_cohort( VALUES (:cohort_id, :sequencing_group_id, :audit_log_id) """ - for sg in sequencing_group_ids: - await self.connection.execute( - _query, + await self.connection.execute_many( + _query, + [ { 'cohort_id': cohort_id, 'sequencing_group_id': sg, 'audit_log_id': audit_log_id, - }, - ) + } + for sg in sequencing_group_ids + ], + ) return cohort_id From c8021f70a3d60c0584b8d55841e9033bebbe9bf1 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 14:17:15 +1000 Subject: [PATCH 137/161] Fix type of id on Cohort model --- models/models/cohort.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/models/cohort.py b/models/models/cohort.py index cb239d815..1d0ddf0f8 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -10,7 +10,7 @@ class Cohort(SMBase): """Model for Cohort""" - id: str + id: int name: str author: str project: ProjectId From f82103a2a7d44baa97546a2b75845de5063fbc8b Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 17:41:01 +1000 Subject: [PATCH 138/161] New model for creating cohorts, fix project missing bug, rename Cohort to CohortInternal, fix tests accordingly --- api/graphql/schema.py | 4 +-- db/python/layers/cohort.py | 28 ++++++++---------- db/python/tables/cohort.py | 37 ++++++++++++++++-------- models/models/__init__.py | 2 +- models/models/cohort.py | 16 +++++++++-- test/test_cohort.py | 58 +++++++++++++++++++------------------- 6 files changed, 83 insertions(+), 62 deletions(-) diff --git a/api/graphql/schema.py b/api/graphql/schema.py index 0e9aed0a6..6210b74ac 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -40,7 +40,7 @@ AnalysisInternal, AssayInternal, AuditLogInternal, - Cohort, + CohortInternal, CohortTemplateInternal, FamilyInternal, ParticipantInternal, @@ -93,7 +93,7 @@ class GraphQLCohort: author: str @staticmethod - def from_internal(internal: Cohort) -> 'GraphQLCohort': + def from_internal(internal: CohortInternal) -> 'GraphQLCohort': return GraphQLCohort( id=cohort_id_format(internal.id), name=internal.name, diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index a750784cd..80a3e7156 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -11,12 +11,12 @@ ) from db.python.utils import GenericFilter, get_logger from models.models.cohort import ( - Cohort, CohortCriteria, + CohortInternal, CohortTemplate, CohortTemplateInternal, + NewCohort, ) -from models.utils.cohort_id_format import cohort_id_format from models.utils.cohort_template_id_format import ( cohort_template_id_format, cohort_template_id_transform_to_raw, @@ -90,7 +90,7 @@ def __init__(self, connection: Connection): async def query( self, filter_: CohortFilter, check_project_ids: bool = True - ) -> list[Cohort]: + ) -> list[CohortInternal]: """Query Cohorts""" cohorts, project_ids = await self.ct.query(filter_) @@ -135,7 +135,7 @@ async def get_template_by_cohort_id(self, cohort_id: int) -> CohortTemplateInter if not template: raise ValueError(f'Cohort template with ID {template_id} not found') - return CohortTemplateInternal(**dict(template)) + return template async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: """ @@ -178,7 +178,7 @@ async def create_cohort_from_criteria( dry_run: bool, cohort_criteria: CohortCriteria = None, template_id: str = None, - ): + ) -> NewCohort: """ Create a new cohort from the given parameters. Returns the newly created cohort_id. """ @@ -245,12 +245,11 @@ async def create_cohort_from_criteria( rich_ids = sequencing_group_id_format_list([sg.id for sg in sgs]) if dry_run: - return { - 'dry_run': True, - 'template_id': template_id or 'CREATE NEW', - 'sequencing_group_ids': rich_ids, - } - + return NewCohort( + dry_run=True, + cohort_id=template_id or 'CREATE NEW', + sequencing_group_ids=rich_ids, + ) # 2. Create cohort template, if required. if create_cohort_template: cohort_template = CohortTemplate( @@ -267,15 +266,10 @@ async def create_cohort_from_criteria( raise ValueError('Template ID must be set') # 3. Create Cohort - cohort_id = await self.ct.create_cohort( + return await self.ct.create_cohort( project=project_to_write, cohort_name=cohort_name, sequencing_group_ids=[sg.id for sg in sgs], description=description, template_id=cohort_template_id_transform_to_raw(template_id), ) - - return { - 'cohort_id': cohort_id_format(cohort_id), - 'sequencing_group_ids': rich_ids, - } diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 41b648156..6377f557b 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -4,8 +4,15 @@ from db.python.tables.base import DbBase from db.python.tables.project import ProjectId -from db.python.utils import GenericFilter, GenericFilterModel, to_db_json -from models.models.cohort import Cohort, CohortCriteria, CohortTemplateInternal +from db.python.utils import GenericFilter, GenericFilterModel, NotFoundError, to_db_json +from models.models.cohort import ( + CohortCriteria, + CohortInternal, + CohortTemplateInternal, + NewCohort, +) +from models.utils.cohort_id_format import cohort_id_format +from models.utils.sequencing_group_id_format import sequencing_group_id_format_list @dataclasses.dataclass(kw_only=True) @@ -52,7 +59,9 @@ class CohortTable(DbBase): template_keys = ['id', 'name', 'description', 'criteria', 'project'] - async def query(self, filter_: CohortFilter) -> tuple[list[Cohort], set[ProjectId]]: + async def query( + self, filter_: CohortFilter + ) -> tuple[list[CohortInternal], set[ProjectId]]: """Query Cohorts""" wheres, values = filter_.to_sql(field_overrides={}) if not wheres: @@ -66,7 +75,7 @@ async def query(self, filter_: CohortFilter) -> tuple[list[Cohort], set[ProjectI """ rows = await self.connection.fetch_all(_query, values) - cohorts = [Cohort.from_db(dict(row)) for row in rows] + cohorts = [CohortInternal.from_db(dict(row)) for row in rows] projects = {c.project for c in cohorts} return cohorts, projects @@ -106,12 +115,12 @@ async def get_cohort_template(self, template_id: int) -> CohortTemplateInternal: Get a cohort template by ID """ _query = """ - SELECT id as id, name, description, criteria as criteria FROM cohort_template WHERE id = :template_id + SELECT id as id, name, description, criteria, project FROM cohort_template WHERE id = :template_id """ template = await self.connection.fetch_one(_query, {'template_id': template_id}) if not template: - return None + raise NotFoundError(f'Cohort template with ID {template_id} not found') cohort_template = CohortTemplateInternal.from_db(dict(template)) @@ -151,12 +160,12 @@ async def create_cohort( sequencing_group_ids: list[int], description: str, template_id: int, - ) -> int: + ) -> NewCohort: """ Create a new cohort """ - # Use an atomic transaction for a mult-part insert query to prevent the database being + # Use an atomic transaction for a multi-part insert query to prevent the database being # left in an incomplete state if the query fails part way through. async with self.connection.transaction(): audit_log_id = await self.audit_log_id() @@ -197,9 +206,15 @@ async def create_cohort( ], ) - return cohort_id + return NewCohort( + dry_run=False, + cohort_id=cohort_id_format(cohort_id), + sequencing_group_ids=sequencing_group_id_format_list( + sequencing_group_ids + ), + ) - async def get_cohort_by_id(self, cohort_id: int) -> Cohort: + async def get_cohort_by_id(self, cohort_id: int) -> CohortInternal: """ Get the cohort by its ID """ @@ -213,4 +228,4 @@ async def get_cohort_by_id(self, cohort_id: int) -> Cohort: if not cohort: raise ValueError(f'Cohort with ID {cohort_id} not found') - return Cohort.from_db(dict(cohort)) + return CohortInternal.from_db(dict(cohort)) diff --git a/models/models/__init__.py b/models/models/__init__.py index a4fbcf368..e22f6be7f 100644 --- a/models/models/__init__.py +++ b/models/models/__init__.py @@ -20,7 +20,7 @@ BillingTotalCostQueryModel, BillingTotalCostRecord, ) -from models.models.cohort import Cohort, CohortTemplateInternal +from models.models.cohort import CohortInternal, CohortTemplateInternal from models.models.family import ( Family, FamilyInternal, diff --git a/models/models/cohort.py b/models/models/cohort.py index 1d0ddf0f8..8c7f027cf 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -7,7 +7,7 @@ from models.models.sequencing_group import SequencingGroup, SequencingGroupExternalId -class Cohort(SMBase): +class CohortInternal(SMBase): """Model for Cohort""" id: int @@ -31,7 +31,7 @@ def from_db(d: dict): template_id = d.pop('template_id', None) sequencing_groups = d.pop('sequencing_groups', []) - return Cohort( + return CohortInternal( id=_id, name=name, author=author, @@ -49,6 +49,7 @@ class CohortTemplateInternal(SMBase): name: str description: str criteria: dict + project: ProjectId @staticmethod def from_db(d: dict): @@ -62,11 +63,14 @@ def from_db(d: dict): if criteria and isinstance(criteria, str): criteria = json.loads(criteria) + project = d.pop('project', None) + return CohortTemplateInternal( id=_id, name=name, description=description, criteria=criteria, + project=project, ) @@ -97,3 +101,11 @@ class CohortTemplate(BaseModel): name: str description: str criteria: CohortCriteria + + +class NewCohort(BaseModel): + """Represents a cohort, which is a collection of sequencing groups.""" + + dry_run: bool = False + cohort_id: str + sequencing_group_ids: list[str] diff --git a/test/test_cohort.py b/test/test_cohort.py index ae30ff3b2..1398472cb 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -7,7 +7,7 @@ from db.python.tables.cohort import CohortFilter from db.python.utils import Forbidden, GenericFilter, NotFoundError from models.models import SampleUpsertInternal, SequencingGroupUpsertInternal -from models.models.cohort import CohortCriteria, CohortTemplate +from models.models.cohort import CohortCriteria, CohortTemplate, NewCohort from models.utils.sequencing_group_id_format import sequencing_group_id_format @@ -66,9 +66,9 @@ async def test_create_empty_cohort(self): dry_run=False, cohort_criteria=CohortCriteria(projects=['test']), ) - self.assertIsInstance(result, dict) - self.assertIsInstance(result['cohort_id'], str) - self.assertEqual([], result['sequencing_group_ids']) + self.assertIsInstance(result, NewCohort) + self.assertIsInstance(result.cohort_id, str) + self.assertEqual([], result.sequencing_group_ids) @run_as_sync async def test_create_duplicate_cohort(self): @@ -236,8 +236,8 @@ async def test_create_cohort_by_sgs(self): sg_ids_internal=[self.sgB], ), ) - self.assertIsInstance(result['cohort_id'], str) - self.assertEqual([self.sgB], result['sequencing_group_ids']) + self.assertIsInstance(result.cohort_id, str) + self.assertEqual([self.sgB], result.sequencing_group_ids) @run_as_sync async def test_create_cohort_by_excluded_sgs(self): @@ -252,10 +252,10 @@ async def test_create_cohort_by_excluded_sgs(self): excluded_sgs_internal=[self.sgA], ), ) - self.assertIsInstance(result['cohort_id'], str) - self.assertEqual(2, len(result['sequencing_group_ids'])) - self.assertIn(self.sgB, result['sequencing_group_ids']) - self.assertIn(self.sgC, result['sequencing_group_ids']) + self.assertIsInstance(result.cohort_id, str) + self.assertEqual(2, len(result.sequencing_group_ids)) + self.assertIn(self.sgB, result.sequencing_group_ids) + self.assertIn(self.sgC, result.sequencing_group_ids) @run_as_sync async def test_create_cohort_by_technology(self): @@ -270,10 +270,10 @@ async def test_create_cohort_by_technology(self): sg_technology=['short-read'], ), ) - self.assertIsInstance(result['cohort_id'], str) - self.assertEqual(2, len(result['sequencing_group_ids'])) - self.assertIn(self.sgA, result['sequencing_group_ids']) - self.assertIn(self.sgB, result['sequencing_group_ids']) + self.assertIsInstance(result.cohort_id, str) + self.assertEqual(2, len(result.sequencing_group_ids)) + self.assertIn(self.sgA, result.sequencing_group_ids) + self.assertIn(self.sgB, result.sequencing_group_ids) @run_as_sync async def test_create_cohort_by_platform(self): @@ -288,8 +288,8 @@ async def test_create_cohort_by_platform(self): sg_platform=['ONT'], ), ) - self.assertIsInstance(result['cohort_id'], str) - self.assertEqual([self.sgC], result['sequencing_group_ids']) + self.assertIsInstance(result.cohort_id, str) + self.assertEqual([self.sgC], result.sequencing_group_ids) @run_as_sync async def test_create_cohort_by_type(self): @@ -304,10 +304,10 @@ async def test_create_cohort_by_type(self): sg_type=['genome'], ), ) - self.assertIsInstance(result['cohort_id'], str) - self.assertEqual(2, len(result['sequencing_group_ids'])) - self.assertIn(self.sgA, result['sequencing_group_ids']) - self.assertIn(self.sgB, result['sequencing_group_ids']) + self.assertIsInstance(result.cohort_id, str) + self.assertEqual(2, len(result.sequencing_group_ids)) + self.assertIn(self.sgA, result.sequencing_group_ids) + self.assertIn(self.sgB, result.sequencing_group_ids) @run_as_sync async def test_create_cohort_by_sample_type(self): @@ -322,8 +322,8 @@ async def test_create_cohort_by_sample_type(self): sample_type=['saliva'], ), ) - self.assertIsInstance(result['cohort_id'], str) - self.assertEqual([self.sgC], result['sequencing_group_ids']) + self.assertIsInstance(result.cohort_id, str) + self.assertEqual([self.sgC], result.sequencing_group_ids) @run_as_sync async def test_create_cohort_by_everything(self): @@ -343,8 +343,8 @@ async def test_create_cohort_by_everything(self): sample_type=['blood'], ), ) - self.assertEqual(1, len(result['sequencing_group_ids'])) - self.assertIn(self.sgB, result['sequencing_group_ids']) + self.assertEqual(1, len(result.sequencing_group_ids)) + self.assertIn(self.sgB, result.sequencing_group_ids) @run_as_sync async def test_reevaluate_cohort(self): @@ -369,7 +369,7 @@ async def test_reevaluate_cohort(self): dry_run=False, template_id=template, ) - self.assertEqual(2, len(coh1['sequencing_group_ids'])) + self.assertEqual(2, len(coh1.sequencing_group_ids)) sD = await self.samplel.upsert_sample(get_sample_model('D')) sgD = sequencing_group_id_format(sD.sequencing_groups[0].id) @@ -381,10 +381,10 @@ async def test_reevaluate_cohort(self): dry_run=False, template_id=template, ) - self.assertEqual(3, len(coh2['sequencing_group_ids'])) + self.assertEqual(3, len(coh2.sequencing_group_ids)) - self.assertNotIn(sgD, coh1['sequencing_group_ids']) - self.assertIn(sgD, coh2['sequencing_group_ids']) + self.assertNotIn(sgD, coh1.sequencing_group_ids) + self.assertIn(sgD, coh2.sequencing_group_ids) @run_as_sync async def test_query_cohort(self): @@ -399,7 +399,7 @@ async def test_query_cohort(self): sg_ids_internal=[self.sgA, self.sgB], ), ) - self.assertEqual(2, len(created['sequencing_group_ids'])) + self.assertEqual(2, len(created.sequencing_group_ids)) queried = await self.cohortl.query( CohortFilter(name=GenericFilter(eq='Duo cohort')) From 8dac650319764d837bfc546c0e705c1146f41a99 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 17:53:36 +1000 Subject: [PATCH 139/161] Fix lint, although interesting that my linter didnt catch it --- api/routes/cohort.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 6797f3789..479ec0790 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -4,7 +4,7 @@ from api.utils.db import Connection, get_project_write_connection from db.python.layers.cohort import CohortLayer -from models.models.cohort import CohortBody, CohortCriteria, CohortTemplate +from models.models.cohort import CohortBody, CohortCriteria, CohortTemplate, NewCohort router = APIRouter(prefix='/cohort', tags=['cohort']) @@ -15,7 +15,7 @@ async def create_cohort_from_criteria( connection: Connection = get_project_write_connection, cohort_criteria: CohortCriteria = None, dry_run: bool = False, -) -> dict[str, Any]: +) -> NewCohort: """ Create a cohort with the given name and sample/sequencing group IDs. """ @@ -25,7 +25,9 @@ async def create_cohort_from_criteria( raise ValueError('A cohort must belong to a project') if not cohort_criteria and not cohort_spec.template_id: - raise ValueError('A cohort must have either criteria or be derived from a template') + raise ValueError( + 'A cohort must have either criteria or be derived from a template' + ) cohort_output = await cohort_layer.create_cohort_from_criteria( project_to_write=connection.project, @@ -53,6 +55,5 @@ async def create_cohort_template( raise ValueError('A cohort template must belong to a project') return await cohort_layer.create_cohort_template( - cohort_template=template, - project=connection.project + cohort_template=template, project=connection.project ) From 8be79e77fc01a9661f74bb0a6f1e7ef73a02dd51 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 18:11:33 +1000 Subject: [PATCH 140/161] Fix type hint, cohort_ids should be int not str --- db/python/tables/analysis.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/db/python/tables/analysis.py b/db/python/tables/analysis.py index db4176135..94e0fd6fb 100644 --- a/db/python/tables/analysis.py +++ b/db/python/tables/analysis.py @@ -25,7 +25,7 @@ class AnalysisFilter(GenericFilterModel): id: GenericFilter[int] | None = None sample_id: GenericFilter[int] | None = None sequencing_group_id: GenericFilter[int] | None = None - cohort_id: GenericFilter[str] | None = None + cohort_id: GenericFilter[int] | None = None project: GenericFilter[int] | None = None type: GenericFilter[str] | None = None status: GenericFilter[AnalysisStatus] | None = None @@ -128,9 +128,7 @@ async def add_sequencing_groups_to_analysis( ) await self.connection.execute_many(_query, list(values)) - async def add_cohorts_to_analysis( - self, analysis_id: int, cohort_ids: list[int] - ): + async def add_cohorts_to_analysis(self, analysis_id: int, cohort_ids: list[int]): """Add cohorts to an analysis (through the linked table)""" _query = """ INSERT INTO analysis_cohort @@ -219,7 +217,6 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: filter_.project, filter_.sample_id, filter_.cohort_id, - ] if not any(required_fields): @@ -272,10 +269,14 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: FROM analysis_sequencing_group WHERE analysis_id IN :analysis_ids """ - sg_ids = await self.connection.fetch_all(_query_sg_ids, {'analysis_ids': list(retvals.keys())}) + sg_ids = await self.connection.fetch_all( + _query_sg_ids, {'analysis_ids': list(retvals.keys())} + ) for row in sg_ids: - retvals[row['analysis_id']].sequencing_group_ids.append(row['sequencing_group_id']) + retvals[row['analysis_id']].sequencing_group_ids.append( + row['sequencing_group_id'] + ) else: _query = f""" @@ -300,7 +301,9 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: FROM analysis_cohort WHERE analysis_id IN :analysis_ids; """ - cohort_ids = await self.connection.fetch_all(_query_cohort_ids, {'analysis_ids': list(retvals.keys())}) + cohort_ids = await self.connection.fetch_all( + _query_cohort_ids, {'analysis_ids': list(retvals.keys())} + ) for row in cohort_ids: retvals[row['analysis_id']].cohort_ids.append(row['cohort_id']) From 3d9c0b6fc561148d4428d9939290d7933682e2ec Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 18:30:49 +1000 Subject: [PATCH 141/161] Fix create_analysis, so it can handle no sgs as inputs --- db/python/tables/analysis.py | 13 +++++++------ models/models/analysis.py | 24 +++++++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/db/python/tables/analysis.py b/db/python/tables/analysis.py index 94e0fd6fb..e6bce90e4 100644 --- a/db/python/tables/analysis.py +++ b/db/python/tables/analysis.py @@ -59,12 +59,12 @@ async def create_analysis( self, analysis_type: str, status: AnalysisStatus, - sequencing_group_ids: List[int], + sequencing_group_ids: List[int] | None = None, cohort_ids: List[int] | None = None, meta: Optional[Dict[str, Any]] = None, - output: str = None, + output: str | None = None, active: bool = True, - project: ProjectId = None, + project: ProjectId | None = None, ) -> int: """ Create a new sample, and add it to database @@ -98,9 +98,10 @@ async def create_analysis( dict(kv_pairs), ) - await self.add_sequencing_groups_to_analysis( - id_of_new_analysis, sequencing_group_ids - ) + if sequencing_group_ids: + await self.add_sequencing_groups_to_analysis( + id_of_new_analysis, sequencing_group_ids + ) if cohort_ids: await self.add_cohorts_to_analysis(id_of_new_analysis, cohort_ids) diff --git a/models/models/analysis.py b/models/models/analysis.py index 2b2ab7784..62b74f9e7 100644 --- a/models/models/analysis.py +++ b/models/models/analysis.py @@ -83,9 +83,11 @@ def to_external(self): ), cohort_ids=cohort_id_format_list(self.cohort_ids), output=self.output, - timestamp_completed=self.timestamp_completed.isoformat() - if self.timestamp_completed - else None, + timestamp_completed=( + self.timestamp_completed.isoformat() + if self.timestamp_completed + else None + ), project=self.project, active=self.active, meta=self.meta, @@ -112,14 +114,22 @@ def to_internal(self): """ Convert to internal model """ + sequencing_group_ids = None + if self.sequencing_group_ids: + sequencing_group_ids = sequencing_group_id_transform_to_raw_list( + self.sequencing_group_ids + ) + + cohort_ids = None + if self.cohort_ids: + cohort_ids = cohort_id_transform_to_raw_list(self.cohort_ids) + return AnalysisInternal( id=self.id, type=self.type, status=self.status, - sequencing_group_ids=sequencing_group_id_transform_to_raw_list( - self.sequencing_group_ids - ), - cohort_ids=cohort_id_transform_to_raw_list(self.cohort_ids), + sequencing_group_ids=sequencing_group_ids, + cohort_ids=cohort_ids, output=self.output, # don't allow this to be set timestamp_completed=None, From 7ba337107ddc2983aab9cd6f1b8d4e985f2c0d4d Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 19:01:11 +1000 Subject: [PATCH 142/161] Create two where_strs, remove unused import --- db/python/layers/cohort.py | 2 -- db/python/tables/analysis.py | 29 +++++++++++++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index 80a3e7156..bb02b06a9 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -1,7 +1,6 @@ from db.python.connect import Connection from db.python.layers.base import BaseLayer from db.python.layers.sequencing_group import SequencingGroupLayer -from db.python.tables.analysis import AnalysisTable from db.python.tables.cohort import CohortFilter, CohortTable, CohortTemplateFilter from db.python.tables.project import ProjectId, ProjectPermissionsTable from db.python.tables.sample import SampleFilter, SampleTable @@ -82,7 +81,6 @@ def __init__(self, connection: Connection): super().__init__(connection) self.sampt = SampleTable(connection) - self.at = AnalysisTable(connection) self.ct = CohortTable(connection) self.pt = ProjectPermissionsTable(connection) self.sgt = SequencingGroupTable(connection) diff --git a/db/python/tables/analysis.py b/db/python/tables/analysis.py index e6bce90e4..f1c4bd54f 100644 --- a/db/python/tables/analysis.py +++ b/db/python/tables/analysis.py @@ -226,7 +226,7 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: 'or project to filter on' ) - where_str, values = filter_.to_sql( + sg_where_str, sg_values = filter_.to_sql( { 'id': 'a.id', 'sample_id': 'a_sg.sample_id', @@ -241,22 +241,35 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: }, ) + cohort_where_str, cohort_values = filter_.to_sql( + { + 'id': 'a.id', + 'project': 'a.project', + 'type': 'a.type', + 'status': 'a.status', + 'meta': 'a.meta', + 'output': 'a.output', + 'active': 'a.active', + 'cohort_id': 'a_c.cohort_id', + }, + ) + retvals: Dict[int, AnalysisInternal] = {} if filter_.cohort_id and filter_.sequencing_group_id: raise ValueError('Cannot filter on both cohort_id and sequencing_group_id') if filter_.cohort_id: - _query = f""" + _cohort_query = f""" SELECT a.id as id, a.type as type, a.status as status, a.output as output, a_c.cohort_id as cohort_id, a.project as project, a.timestamp_completed as timestamp_completed, a.active as active, a.meta as meta, a.author as author FROM analysis a LEFT JOIN analysis_cohort a_c ON a.id = a_c.analysis_id - WHERE {where_str} + WHERE {cohort_where_str} """ - rows = await self.connection.fetch_all(_query, values) + rows = await self.connection.fetch_all(_cohort_query, cohort_values) for row in rows: key = row['id'] if key in retvals: @@ -265,7 +278,7 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: retvals[key] = AnalysisInternal.from_db(**dict(row)) if retvals.keys(): - _query_sg_ids = f""" + _query_sg_ids = """ SELECT sequencing_group_id, analysis_id FROM analysis_sequencing_group WHERE analysis_id IN :analysis_ids @@ -287,9 +300,9 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: a.active as active, a.meta as meta, a.author as author FROM analysis a LEFT JOIN analysis_sequencing_group a_sg ON a.id = a_sg.analysis_id - WHERE {where_str} + WHERE {sg_where_str} """ - rows = await self.connection.fetch_all(_query, values) + rows = await self.connection.fetch_all(_query, sg_values) for row in rows: key = row['id'] if key in retvals: @@ -297,7 +310,7 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: else: retvals[key] = AnalysisInternal.from_db(**dict(row)) - _query_cohort_ids = f""" + _query_cohort_ids = """ SELECT analysis_id, cohort_id FROM analysis_cohort WHERE analysis_id IN :analysis_ids; From 64349cd1a9aebdc60a4ccbb9915794abe5a36d05 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 19:12:31 +1000 Subject: [PATCH 143/161] Remove sgs from cohortinternal model! --- models/models/cohort.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/models/models/cohort.py b/models/models/cohort.py index 8c7f027cf..5bb0af8e8 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -4,7 +4,6 @@ from models.base import SMBase from models.models.project import ProjectId -from models.models.sequencing_group import SequencingGroup, SequencingGroupExternalId class CohortInternal(SMBase): @@ -16,7 +15,6 @@ class CohortInternal(SMBase): project: ProjectId description: str template_id: int - sequencing_groups: list[SequencingGroup | SequencingGroupExternalId] @staticmethod def from_db(d: dict): @@ -29,7 +27,6 @@ def from_db(d: dict): name = d.pop('name', None) author = d.pop('author', None) template_id = d.pop('template_id', None) - sequencing_groups = d.pop('sequencing_groups', []) return CohortInternal( id=_id, @@ -38,7 +35,6 @@ def from_db(d: dict): project=project, description=description, template_id=template_id, - sequencing_groups=sequencing_groups, ) From ac89b8392868be6887039b485a8c5cde5b6f706f Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 19:18:35 +1000 Subject: [PATCH 144/161] Add strict param to id transform function --- models/utils/cohort_id_format.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/models/utils/cohort_id_format.py b/models/utils/cohort_id_format.py index d67497062..661876218 100644 --- a/models/utils/cohort_id_format.py +++ b/models/utils/cohort_id_format.py @@ -19,9 +19,7 @@ def cohort_id_format(cohort_id: int | str) -> str: cohort_id = int(cohort_id) - checksum = luhn_compute( - cohort_id, offset=COHORT_CHECKSUM_OFFSET - ) + checksum = luhn_compute(cohort_id, offset=COHORT_CHECKSUM_OFFSET) return f'{COHORT_PREFIX}{cohort_id}{checksum}' @@ -36,13 +34,13 @@ def cohort_id_format_list(cohort_ids: Iterable[int | str]) -> list[str]: return [cohort_id_format(s) for s in cohort_ids] -def cohort_id_transform_to_raw(cohort_id: int | str) -> int: +def cohort_id_transform_to_raw(cohort_id: int | str, strict=True) -> int: """ Transform STRING cohort identifier (COHXXXH) to XXX by: - validating prefix - validating checksum """ - expected_type = str + expected_type = str if strict else (str, int) if not isinstance(cohort_id, expected_type): # type: ignore raise TypeError( f'Expected identifier type to be {expected_type!r}, received {type(cohort_id)!r}' @@ -71,11 +69,11 @@ def cohort_id_transform_to_raw(cohort_id: int | str) -> int: def cohort_id_transform_to_raw_list( - identifier: Iterable[int | str] + identifier: Iterable[int | str], strict=True ) -> list[int]: """ Transform LIST of STRING cohort identifier (COHXXXH) to XXX by: - validating prefix - validating checksum """ - return [cohort_id_transform_to_raw(s) for s in identifier] + return [cohort_id_transform_to_raw(s, strict=strict) for s in identifier] From 37d300cbf309aa06d55890bda5d2ed4840edba67 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 19:22:15 +1000 Subject: [PATCH 145/161] redundant, no? remove a type check that already happened above --- models/utils/cohort_id_format.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/models/utils/cohort_id_format.py b/models/utils/cohort_id_format.py index 661876218..e1fc6f18e 100644 --- a/models/utils/cohort_id_format.py +++ b/models/utils/cohort_id_format.py @@ -49,9 +49,6 @@ def cohort_id_transform_to_raw(cohort_id: int | str, strict=True) -> int: if isinstance(cohort_id, int): return cohort_id - if not isinstance(cohort_id, str): - raise ValueError('Programming error related to cohort checks') - if not cohort_id.startswith(COHORT_PREFIX): raise ValueError( f'Invalid prefix found for {COHORT_PREFIX} cohort identifier {cohort_id!r}' From b2c5efb9457f5fe41f5d4c4f3ac7fe264618ceb1 Mon Sep 17 00:00:00 2001 From: vivbak Date: Fri, 19 Apr 2024 19:57:59 +1000 Subject: [PATCH 146/161] Add typing to function, raise value error if template nor projects provided --- scripts/create_custom_cohort.py | 93 ++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 25 deletions(-) diff --git a/scripts/create_custom_cohort.py b/scripts/create_custom_cohort.py index 51d6f9d59..647ee1094 100644 --- a/scripts/create_custom_cohort.py +++ b/scripts/create_custom_cohort.py @@ -1,4 +1,5 @@ """ A script to create a custom cohort """ + import argparse from metamist.apis import CohortApi @@ -8,16 +9,16 @@ def main( project: str, cohort_body_spec: CohortBody, - projects: list[str], - sg_ids_internal: list[str], - excluded_sg_ids: list[str], - sg_technologies: list[str], - sg_platforms: list[str], - sg_types: list[str], - sample_types: list[str], - dry_run: bool = False + projects: list[str] | None = None, + sg_ids_internal: list[str] | None = None, + excluded_sg_ids: list[str] | None = None, + sg_technologies: list[str] | None = None, + sg_platforms: list[str] | None = None, + sg_types: list[str] | None = None, + sample_types: list[str] | None = None, + dry_run: bool = False, ): - """ Create a custom cohort""" + """Create a custom cohort""" capi = CohortApi() cohort_criteria = CohortCriteria( @@ -32,18 +33,20 @@ def main( cohort = capi.create_cohort_from_criteria( project=project, - body_create_cohort_from_criteria={'cohort_spec': cohort_body_spec, 'cohort_criteria': cohort_criteria, 'dry_run': dry_run} + body_create_cohort_from_criteria={ + 'cohort_spec': cohort_body_spec, + 'cohort_criteria': cohort_criteria, + 'dry_run': dry_run, + }, ) print(f'Awesome! You have created a custom cohort {cohort}') def get_cohort_spec( - cohort_name: str, - cohort_description: str, - cohort_template_id: int + cohort_name: str, cohort_description: str, cohort_template_id: int ) -> CohortBody: - """ Get the cohort spec """ + """Get the cohort spec""" cohort_body_spec: dict[str, int | str] = {} @@ -60,17 +63,54 @@ def get_cohort_spec( if __name__ == '__main__': parser = argparse.ArgumentParser(description='Create a custom cohort') - parser.add_argument('--project', type=str, help='The project to create the cohort in') + parser.add_argument( + '--project', type=str, help='The project to create the cohort in' + ) parser.add_argument('--name', type=str, help='The name of the cohort') parser.add_argument('--description', type=str, help='The description of the cohort') - parser.add_argument('--template_id', required=False, type=int, help='The template id of the cohort') - parser.add_argument('--projects', required=False, type=str, nargs='*', help='Pull sequencing groups from these projects') - parser.add_argument('--sg_ids_internal', required=False, type=list[str], help='Include the following sequencing groups') - parser.add_argument('--excluded_sgs_internal', required=False, type=list[str], help='Exclude the following sequencing groups') - parser.add_argument('--sg_technology', required=False, type=list[str], help='Sequencing group technologies') - parser.add_argument('--sg_platform', required=False, type=list[str], help='Sequencing group platforms') - parser.add_argument('--sg_type', required=False, type=list[str], help='Sequencing group types, e.g. exome, genome') - parser.add_argument('--sample_type', required=False, type=list[str], help='sample type') + parser.add_argument( + '--template_id', required=False, type=int, help='The template id of the cohort' + ) + parser.add_argument( + '--projects', + required=False, + type=str, + nargs='*', + help='Pull sequencing groups from these projects', + ) + parser.add_argument( + '--sg_ids_internal', + required=False, + type=list[str], + help='Include the following sequencing groups', + ) + parser.add_argument( + '--excluded_sgs_internal', + required=False, + type=list[str], + help='Exclude the following sequencing groups', + ) + parser.add_argument( + '--sg_technology', + required=False, + type=list[str], + help='Sequencing group technologies', + ) + parser.add_argument( + '--sg_platform', + required=False, + type=list[str], + help='Sequencing group platforms', + ) + parser.add_argument( + '--sg_type', + required=False, + type=list[str], + help='Sequencing group types, e.g. exome, genome', + ) + parser.add_argument( + '--sample_type', required=False, type=list[str], help='sample type' + ) parser.add_argument('--dry_run', required=False, type=bool, help='Dry run mode') args = parser.parse_args() @@ -78,9 +118,12 @@ def get_cohort_spec( cohort_spec = get_cohort_spec( cohort_name=args.name, cohort_description=args.description, - cohort_template_id=args.template_id + cohort_template_id=args.template_id, ) + if not args.projects and not args.template_id: + raise ValueError('You must provide either projects or a template id') + main( project=args.project, cohort_body_spec=cohort_spec, @@ -91,5 +134,5 @@ def get_cohort_spec( sg_platforms=args.sg_platform, sg_types=args.sg_type, sample_types=args.sample_type, - dry_run=args.dry_run + dry_run=args.dry_run, ) From 7db7f7b77a611b301e0ff2489c6f7223be03a65e Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 22 Apr 2024 08:48:25 +1000 Subject: [PATCH 147/161] Fetch assay external ids too --- db/python/tables/assay.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/db/python/tables/assay.py b/db/python/tables/assay.py index 023cd3970..ea42448e2 100644 --- a/db/python/tables/assay.py +++ b/db/python/tables/assay.py @@ -520,6 +520,8 @@ async def get_assays_for_sequencing_group_ids( 'a.type', 'a.meta', 's.project', + 'ae.name', + 'ae.external_id', ] wheres = [ 'sga.sequencing_group_id IN :sequencing_group_ids', @@ -530,6 +532,7 @@ async def get_assays_for_sequencing_group_ids( FROM sequencing_group_assay sga INNER JOIN assay a ON sga.assay_id = a.id INNER JOIN sample s ON a.sample_id = s.id + LEFT JOIN assay_external_id ae ON a.id = ae.assay_id WHERE {' AND '.join(wheres)} """ @@ -541,9 +544,11 @@ async def get_assays_for_sequencing_group_ids( for row in rows: drow = dict(row) - # Set external_id map to empty dict since we don't fetch them for this query - # TODO: Get external_ids map for this query if/when they are needed. - drow['external_ids'] = drow.pop('external_ids', {}) + external_id = drow.pop('external_id', None) + if external_id: + drow['external_ids'] = {drow.pop('name'): external_id} + else: + drow['external_ids'] = {} sequencing_group_id = drow.pop('sequencing_group_id') projects.add(drow.pop('project')) From 1b7632b2e12787535357754068d31185931b4e45 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 22 Apr 2024 09:41:14 +1000 Subject: [PATCH 148/161] Move escape_like_terms to utils, apply to contains filter --- db/python/layers/web.py | 3 ++- db/python/tables/base.py | 7 ------- db/python/tables/family.py | 4 ++-- db/python/tables/participant.py | 4 ++-- db/python/tables/sample.py | 3 ++- db/python/utils.py | 11 ++++++++++- models/models/cohort.py | 10 ++++------ 7 files changed, 22 insertions(+), 20 deletions(-) diff --git a/db/python/layers/web.py b/db/python/layers/web.py index 20e6c82fd..0cd7fe275 100644 --- a/db/python/layers/web.py +++ b/db/python/layers/web.py @@ -15,6 +15,7 @@ from db.python.tables.base import DbBase from db.python.tables.project import ProjectPermissionsTable from db.python.tables.sequencing_group import SequencingGroupTable +from db.python.utils import escape_like_term from models.models import ( AssayInternal, FamilySimpleInternal, @@ -75,7 +76,7 @@ def _project_summary_sample_query(self, grid_filter: list[SearchItem]): # double double quote field to allow white space q = f'JSON_VALUE({prefix}.meta, "$.""{field}""") LIKE :{key}' # noqa: B028 wheres.append(q) - values[key] = self.escape_like_term(value) + '%' + values[key] = escape_like_term(value) + '%' if wheres: where_str = 'WHERE ' + ' AND '.join(wheres) diff --git a/db/python/tables/base.py b/db/python/tables/base.py index e29353de8..c069bf60a 100644 --- a/db/python/tables/base.py +++ b/db/python/tables/base.py @@ -37,13 +37,6 @@ async def audit_log_id(self): # piped from the connection - @staticmethod - def escape_like_term(query: str): - """ - Escape meaningful keys when using LIKE with a user supplied input - """ - return query.replace('%', '\\%').replace('_', '\\_') - async def get_all_audit_logs_for_table( self, table: str, ids: list[int], id_field='id' ) -> dict[int, list[AuditLogInternal]]: diff --git a/db/python/tables/family.py b/db/python/tables/family.py index 18b13dd72..11176284d 100644 --- a/db/python/tables/family.py +++ b/db/python/tables/family.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List, Optional, Set from db.python.tables.base import DbBase -from db.python.utils import NotFoundError +from db.python.utils import NotFoundError, escape_like_term from models.models.family import FamilyInternal from models.models.project import ProjectId @@ -147,7 +147,7 @@ async def search( _query, { 'project_ids': project_ids, - 'search_pattern': self.escape_like_term(query) + '%', + 'search_pattern': escape_like_term(query) + '%', 'limit': limit, }, ) diff --git a/db/python/tables/participant.py b/db/python/tables/participant.py index 6f326f9cf..60d6c7111 100644 --- a/db/python/tables/participant.py +++ b/db/python/tables/participant.py @@ -2,7 +2,7 @@ from typing import Any from db.python.tables.base import DbBase -from db.python.utils import NotFoundError, to_db_json +from db.python.utils import NotFoundError, escape_like_term, to_db_json from models.models.participant import ParticipantInternal from models.models.project import ProjectId @@ -338,7 +338,7 @@ async def search( _query, { 'project_ids': project_ids, - 'search_pattern': self.escape_like_term(query) + '%', + 'search_pattern': escape_like_term(query) + '%', 'limit': limit, }, ) diff --git a/db/python/tables/sample.py b/db/python/tables/sample.py index 3457a2333..4b6d969e8 100644 --- a/db/python/tables/sample.py +++ b/db/python/tables/sample.py @@ -9,6 +9,7 @@ GenericFilterModel, GenericMetaFilter, NotFoundError, + escape_like_term, to_db_json, ) from models.models.project import ProjectId @@ -354,7 +355,7 @@ async def search( _query, { 'project_ids': project_ids, - 'search_pattern': self.escape_like_term(query) + '%', + 'search_pattern': escape_like_term(query) + '%', 'limit': limit, }, ) diff --git a/db/python/utils.py b/db/python/utils.py index b88d231a3..71362034f 100644 --- a/db/python/utils.py +++ b/db/python/utils.py @@ -203,9 +203,10 @@ def to_sql( conditionals.append(f'{column} <= :{k}') values[k] = self._sql_value_prep(self.lte) if self.contains is not None: + search_term = escape_like_term(str(self.contains)) k = self.generate_field_name(column + '_contains') conditionals.append(f'{column} LIKE :{k}') - values[k] = self._sql_value_prep(f'%{self.contains}%') + values[k] = self._sql_value_prep(f'%{search_term}%') if self.icontains is not None: k = self.generate_field_name(column + '_icontains') conditionals.append(f'LOWER({column}) LIKE LOWER(:{k})') @@ -397,3 +398,11 @@ def split_generic_terms(string: str) -> list[str]: filenames = [f for f in filenames if f] return filenames + + +def escape_like_term(query: str): + """ + Escape meaningful keys when using LIKE with a user supplied input + """ + + return query.replace('%', '\\%').replace('_', '\\_') diff --git a/models/models/cohort.py b/models/models/cohort.py index 5bb0af8e8..29d120971 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -1,7 +1,5 @@ import json -from pydantic import BaseModel - from models.base import SMBase from models.models.project import ProjectId @@ -70,7 +68,7 @@ def from_db(d: dict): ) -class CohortBody(BaseModel): +class CohortBody(SMBase): """Represents the expected JSON body of the create cohort request""" name: str @@ -78,7 +76,7 @@ class CohortBody(BaseModel): template_id: str | None = None -class CohortCriteria(BaseModel): +class CohortCriteria(SMBase): """Represents the expected JSON body of the create cohort request""" projects: list[str] | None = [] @@ -90,7 +88,7 @@ class CohortCriteria(BaseModel): sample_type: list[str] | None = None -class CohortTemplate(BaseModel): +class CohortTemplate(SMBase): """Represents a cohort template, to be used to build cohorts.""" id: int | None @@ -99,7 +97,7 @@ class CohortTemplate(BaseModel): criteria: CohortCriteria -class NewCohort(BaseModel): +class NewCohort(SMBase): """Represents a cohort, which is a collection of sequencing groups.""" dry_run: bool = False From 3c2af5fc8bea604ac1f24757802c1740c2c8ed73 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 22 Apr 2024 16:44:33 +1000 Subject: [PATCH 149/161] Implement Internal and External models for all objects. Move transform rich to raw, to be on the route. Handle raw ids only from layers onward. Update tests accordingly --- api/routes/cohort.py | 59 +++++++++++++--- db/python/layers/cohort.py | 125 +++++++++++++-------------------- db/python/tables/cohort.py | 18 ++--- models/models/cohort.py | 87 +++++++++++++++++++++-- test/test_cohort.py | 138 ++++++++++++++++++++----------------- 5 files changed, 260 insertions(+), 167 deletions(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 479ec0790..68559380e 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -1,10 +1,14 @@ -from typing import Any - from fastapi import APIRouter from api.utils.db import Connection, get_project_write_connection from db.python.layers.cohort import CohortLayer +from db.python.tables.project import ProjectPermissionsTable from models.models.cohort import CohortBody, CohortCriteria, CohortTemplate, NewCohort +from models.models.project import ProjectId +from models.utils.cohort_template_id_format import ( + cohort_template_id_format, + cohort_template_id_transform_to_raw, +) router = APIRouter(prefix='/cohort', tags=['cohort']) @@ -12,8 +16,8 @@ @router.post('/{project}/cohort', operation_id='createCohortFromCriteria') async def create_cohort_from_criteria( cohort_spec: CohortBody, + cohort_criteria: CohortCriteria | None = None, connection: Connection = get_project_write_connection, - cohort_criteria: CohortCriteria = None, dry_run: bool = False, ) -> NewCohort: """ @@ -29,23 +33,45 @@ async def create_cohort_from_criteria( 'A cohort must have either criteria or be derived from a template' ) + internal_project_ids: list[ProjectId] = [] + + if cohort_criteria: + if cohort_criteria.projects: + pt = ProjectPermissionsTable(connection) + projects = await pt.get_and_check_access_to_projects_for_names( + user=connection.author, + project_names=cohort_criteria.projects, + readonly=True, + ) + if projects: + internal_project_ids = [p.id for p in projects if p.id] + + template_id_raw = ( + cohort_template_id_transform_to_raw(cohort_spec.template_id) + if cohort_spec.template_id + else None + ) cohort_output = await cohort_layer.create_cohort_from_criteria( project_to_write=connection.project, description=cohort_spec.description, cohort_name=cohort_spec.name, dry_run=dry_run, - cohort_criteria=cohort_criteria, - template_id=cohort_spec.template_id, + cohort_criteria=( + cohort_criteria.to_internal(internal_project_ids) + if cohort_criteria + else None + ), + template_id=template_id_raw, ) - return cohort_output + return cohort_output.to_external() @router.post('/{project}/cohort_template', operation_id='createCohortTemplate') async def create_cohort_template( template: CohortTemplate, connection: Connection = get_project_write_connection, -) -> dict[str, Any]: +) -> str: """ Create a cohort template with the given name and sample/sequencing group IDs. """ @@ -54,6 +80,21 @@ async def create_cohort_template( if not connection.project: raise ValueError('A cohort template must belong to a project') - return await cohort_layer.create_cohort_template( - cohort_template=template, project=connection.project + criteria_project_ids: list[ProjectId] = [] + + if template.criteria.projects: + pt = ProjectPermissionsTable(connection) + projects_for_criteria = await pt.get_and_check_access_to_projects_for_names( + user=connection.author, + project_names=template.criteria.projects, + readonly=False, + ) + if projects_for_criteria: + criteria_project_ids = [p.id for p in projects_for_criteria if p.id] + + cohort_raw_id = await cohort_layer.create_cohort_template( + cohort_template=template.to_internal(criteria_projects=criteria_project_ids), + project=connection.project, ) + + return cohort_template_id_format(cohort_raw_id) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index bb02b06a9..d894dcac9 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -10,47 +10,27 @@ ) from db.python.utils import GenericFilter, get_logger from models.models.cohort import ( - CohortCriteria, + CohortCriteriaInternal, CohortInternal, - CohortTemplate, CohortTemplateInternal, - NewCohort, -) -from models.utils.cohort_template_id_format import ( - cohort_template_id_format, - cohort_template_id_transform_to_raw, -) -from models.utils.sequencing_group_id_format import ( - sequencing_group_id_format_list, - sequencing_group_id_transform_to_raw_list, + NewCohortInternal, ) logger = get_logger() def get_sg_filter( - projects: list[int], - sg_ids_internal_rich: list[str], - excluded_sgs_internal_rich: list[str], - sg_technology: list[str], - sg_platform: list[str], - sg_type: list[str], - sample_ids: list[int], + projects: list[ProjectId] | None, + sg_ids_internal_raw: list[int] | None, + excluded_sgs_internal_raw: list[int] | None, + sg_technology: list[str] | None, + sg_platform: list[str] | None, + sg_type: list[str] | None, + sample_ids: list[int] | None, ) -> SequencingGroupFilter: """Get the sequencing group filter for cohort attributes""" # Format inputs for filter - sg_ids_internal_raw = [] - excluded_sgs_internal_raw = [] - if sg_ids_internal_rich: - sg_ids_internal_raw = sequencing_group_id_transform_to_raw_list( - sg_ids_internal_rich - ) - if excluded_sgs_internal_rich: - excluded_sgs_internal_raw = sequencing_group_id_transform_to_raw_list( - excluded_sgs_internal_rich - ) - if sg_ids_internal_raw and excluded_sgs_internal_raw: sg_id_filter = GenericFilter( in_=sg_ids_internal_raw, nin=excluded_sgs_internal_raw @@ -143,20 +123,14 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: async def create_cohort_template( self, - cohort_template: CohortTemplate, + cohort_template: CohortTemplateInternal, project: ProjectId, - ): + ) -> int: """ Create new cohort template """ - # Validate projects specified in criteria are valid - _ = await self.pt.get_and_check_access_to_projects_for_names( - user=self.connection.author, - project_names=cohort_template.criteria.projects, - readonly=False, - ) - + assert cohort_template.criteria.projects, 'Projects must be set in criteria' assert cohort_template.id is None, 'Cohort template ID must be None' template_id = await self.ct.create_cohort_template( @@ -166,7 +140,7 @@ async def create_cohort_template( project=project, ) - return cohort_template_id_format(template_id) + return template_id async def create_cohort_from_criteria( self, @@ -174,9 +148,9 @@ async def create_cohort_from_criteria( description: str, cohort_name: str, dry_run: bool, - cohort_criteria: CohortCriteria = None, - template_id: str = None, - ) -> NewCohort: + cohort_criteria: CohortCriteriaInternal | None = None, + template_id: int | None = None, + ) -> NewCohortInternal: """ Create a new cohort from the given parameters. Returns the newly created cohort_id. """ @@ -189,11 +163,10 @@ async def create_cohort_from_criteria( 'A cohort must have either criteria or be derived from a template' ) - template: CohortTemplateInternal = None + template: CohortTemplateInternal | None = None # Get template from ID if template_id: - template_id_raw = cohort_template_id_transform_to_raw(template_id) - template = await self.ct.get_cohort_template(template_id_raw) + template = await self.ct.get_cohort_template(template_id) if not template: raise ValueError(f'Cohort template with ID {template_id} not found') @@ -206,51 +179,49 @@ async def create_cohort_from_criteria( # Only provide a template id if template and not cohort_criteria: create_cohort_template = False - criteria_dict = template.criteria - cohort_criteria = CohortCriteria(**criteria_dict) - - projects_to_pull = await self.pt.get_and_check_access_to_projects_for_names( - user=self.connection.author, - project_names=cohort_criteria.projects, - readonly=True, - ) - projects_to_pull = [p.id for p in projects_to_pull] - - # Get sample IDs with sample type - sample_filter = SampleFilter( - project=GenericFilter(in_=projects_to_pull), - type=( - GenericFilter(in_=cohort_criteria.sample_type) - if cohort_criteria.sample_type - else None - ), - ) + cohort_criteria = template.criteria + + if not cohort_criteria: + raise ValueError('Cohort criteria must be set') + + sample_ids: list[int] = [] + if cohort_criteria.sample_type: + # Get sample IDs with sample type + sample_filter = SampleFilter( + project=GenericFilter(in_=cohort_criteria.projects), + type=( + GenericFilter(in_=cohort_criteria.sample_type) + if cohort_criteria.sample_type + else None + ), + ) - _, samples = await self.sampt.query(sample_filter) + _, samples = await self.sampt.query(sample_filter) + sample_ids = [s.id for s in samples] sg_filter = get_sg_filter( - projects=projects_to_pull, - sg_ids_internal_rich=cohort_criteria.sg_ids_internal, - excluded_sgs_internal_rich=cohort_criteria.excluded_sgs_internal, + projects=cohort_criteria.projects, + sg_ids_internal_raw=cohort_criteria.sg_ids_internal_raw, + excluded_sgs_internal_raw=cohort_criteria.excluded_sgs_internal_raw, sg_technology=cohort_criteria.sg_technology, sg_platform=cohort_criteria.sg_platform, sg_type=cohort_criteria.sg_type, - sample_ids=[s.id for s in samples], + sample_ids=sample_ids, ) sgs = await self.sglayer.query(sg_filter) - rich_ids = sequencing_group_id_format_list([sg.id for sg in sgs]) - if dry_run: - return NewCohort( + sg_ids = [sg.id for sg in sgs if sg.id] if sgs else [] + + return NewCohortInternal( dry_run=True, - cohort_id=template_id or 'CREATE NEW', - sequencing_group_ids=rich_ids, + cohort_id=template_id, + sequencing_group_ids=sg_ids, ) # 2. Create cohort template, if required. if create_cohort_template: - cohort_template = CohortTemplate( + cohort_template = CohortTemplateInternal( id=None, name=cohort_name, description=description, @@ -267,7 +238,7 @@ async def create_cohort_from_criteria( return await self.ct.create_cohort( project=project_to_write, cohort_name=cohort_name, - sequencing_group_ids=[sg.id for sg in sgs], + sequencing_group_ids=[sg.id for sg in sgs if sg.id], description=description, - template_id=cohort_template_id_transform_to_raw(template_id), + template_id=template_id, ) diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 6377f557b..22f212d79 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -6,13 +6,11 @@ from db.python.tables.project import ProjectId from db.python.utils import GenericFilter, GenericFilterModel, NotFoundError, to_db_json from models.models.cohort import ( - CohortCriteria, + CohortCriteriaInternal, CohortInternal, CohortTemplateInternal, - NewCohort, + NewCohortInternal, ) -from models.utils.cohort_id_format import cohort_id_format -from models.utils.sequencing_group_id_format import sequencing_group_id_format_list @dataclasses.dataclass(kw_only=True) @@ -130,7 +128,7 @@ async def create_cohort_template( self, name: str, description: str, - criteria: CohortCriteria, + criteria: CohortCriteriaInternal, project: ProjectId, ): """ @@ -160,7 +158,7 @@ async def create_cohort( sequencing_group_ids: list[int], description: str, template_id: int, - ) -> NewCohort: + ) -> NewCohortInternal: """ Create a new cohort """ @@ -206,12 +204,10 @@ async def create_cohort( ], ) - return NewCohort( + return NewCohortInternal( dry_run=False, - cohort_id=cohort_id_format(cohort_id), - sequencing_group_ids=sequencing_group_id_format_list( - sequencing_group_ids - ), + cohort_id=cohort_id, + sequencing_group_ids=sequencing_group_ids, ) async def get_cohort_by_id(self, cohort_id: int) -> CohortInternal: diff --git a/models/models/cohort.py b/models/models/cohort.py index 29d120971..3c7d60f14 100644 --- a/models/models/cohort.py +++ b/models/models/cohort.py @@ -2,6 +2,11 @@ from models.base import SMBase from models.models.project import ProjectId +from models.utils.cohort_id_format import cohort_id_format +from models.utils.sequencing_group_id_format import ( + sequencing_group_id_format_list, + sequencing_group_id_transform_to_raw_list, +) class CohortInternal(SMBase): @@ -36,14 +41,25 @@ def from_db(d: dict): ) +class CohortCriteriaInternal(SMBase): + """Internal Model for CohortCriteria""" + + projects: list[ProjectId] | None = [] + sg_ids_internal_raw: list[int] | None = None + excluded_sgs_internal_raw: list[int] | None = None + sg_technology: list[str] | None = None + sg_platform: list[str] | None = None + sg_type: list[str] | None = None + sample_type: list[str] | None = None + + class CohortTemplateInternal(SMBase): """Model for CohortTemplate""" - id: int + id: int | None name: str description: str - criteria: dict - project: ProjectId + criteria: CohortCriteriaInternal @staticmethod def from_db(d: dict): @@ -57,14 +73,11 @@ def from_db(d: dict): if criteria and isinstance(criteria, str): criteria = json.loads(criteria) - project = d.pop('project', None) - return CohortTemplateInternal( id=_id, name=name, description=description, criteria=criteria, - project=project, ) @@ -87,6 +100,33 @@ class CohortCriteria(SMBase): sg_type: list[str] | None = None sample_type: list[str] | None = None + def to_internal( + self, projects_internal: list[ProjectId] | None + ) -> CohortCriteriaInternal: + """ + Convert to internal model + """ + + sg_ids_raw = None + if self.sg_ids_internal: + sg_ids_raw = sequencing_group_id_transform_to_raw_list(self.sg_ids_internal) + + excluded_sgs_raw = None + if self.excluded_sgs_internal: + excluded_sgs_raw = sequencing_group_id_transform_to_raw_list( + self.excluded_sgs_internal + ) + + return CohortCriteriaInternal( + projects=projects_internal, + sg_ids_internal_raw=sg_ids_raw, + excluded_sgs_internal_raw=excluded_sgs_raw, + sg_technology=self.sg_technology, + sg_platform=self.sg_platform, + sg_type=self.sg_type, + sample_type=self.sample_type, + ) + class CohortTemplate(SMBase): """Represents a cohort template, to be used to build cohorts.""" @@ -96,6 +136,17 @@ class CohortTemplate(SMBase): description: str criteria: CohortCriteria + def to_internal(self, criteria_projects: list[ProjectId]) -> CohortTemplateInternal: + """ + Convert to internal model + """ + return CohortTemplateInternal( + id=self.id, + name=self.name, + description=self.description, + criteria=self.criteria.to_internal(criteria_projects), + ) + class NewCohort(SMBase): """Represents a cohort, which is a collection of sequencing groups.""" @@ -103,3 +154,27 @@ class NewCohort(SMBase): dry_run: bool = False cohort_id: str sequencing_group_ids: list[str] + + +class NewCohortInternal(SMBase): + """Represents a cohort, which is a collection of sequencing groups.""" + + dry_run: bool = False + cohort_id: int | None + sequencing_group_ids: list[int] | None = None + + def to_external(self) -> NewCohort: + """ + Convert to external model + """ + return NewCohort( + dry_run=self.dry_run, + cohort_id=( + cohort_id_format(self.cohort_id) if self.cohort_id else 'CREATE NEW' + ), + sequencing_group_ids=( + sequencing_group_id_format_list(self.sequencing_group_ids) + if self.sequencing_group_ids + else [] + ), + ) diff --git a/test/test_cohort.py b/test/test_cohort.py index 1398472cb..57a2a8c3e 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -5,9 +5,15 @@ from db.python.layers import CohortLayer, SampleLayer from db.python.tables.cohort import CohortFilter -from db.python.utils import Forbidden, GenericFilter, NotFoundError +from db.python.utils import GenericFilter from models.models import SampleUpsertInternal, SequencingGroupUpsertInternal -from models.models.cohort import CohortCriteria, CohortTemplate, NewCohort +from models.models.cohort import ( + CohortCriteria, + CohortCriteriaInternal, + CohortTemplate, + CohortTemplateInternal, + NewCohortInternal, +) from models.utils.sequencing_group_id_format import sequencing_group_id_format @@ -30,31 +36,32 @@ async def test_create_cohort_missing_args(self): dry_run=False, ) - @run_as_sync - async def test_create_cohort_bad_project(self): - """Can't create cohort in invalid project""" - with self.assertRaises((Forbidden, NotFoundError)): - _ = await self.cohortl.create_cohort_from_criteria( - project_to_write=self.project_id, - description='Cohort based on a missing project', - cohort_name='Bad-project cohort', - dry_run=False, - cohort_criteria=CohortCriteria(projects=['nonexistent']), - ) - - @run_as_sync - async def test_create_template_bad_project(self): - """Can't create template in invalid project""" - with self.assertRaises((Forbidden, NotFoundError)): - _ = await self.cohortl.create_cohort_template( - project=self.project_id, - cohort_template=CohortTemplate( - id=None, - name='Bad-project template', - description='Template based on a missing project', - criteria=CohortCriteria(projects=['nonexistent']), - ), - ) + # These tests are disabled because the move to an Internal Model means that verification happens in the route not the layer + # @run_as_sync + # async def test_create_cohort_bad_project(self): + # """Can't create cohort in invalid project""" + # with self.assertRaises((Forbidden, NotFoundError)): + # _ = await self.cohortl.create_cohort_from_criteria( + # project_to_write=self.project_id, + # description='Cohort based on a missing project', + # cohort_name='Bad-project cohort', + # dry_run=False, + # cohort_criteria=CohortCriteriaInternal(projects=[5]), + # ) + + # @run_as_sync + # async def test_create_template_bad_project(self): + # """Can't create template in invalid project""" + # with self.assertRaises((Forbidden, NotFoundError)): + # _ = await self.cohortl.create_cohort_template( + # project=self.project_id, + # cohort_template=CohortTemplate( + # id=None, + # name='Bad-project template', + # description='Template based on a missing project', + # criteria=CohortCriteria(projects=['nonexistent']), + # ), + # ) @run_as_sync async def test_create_empty_cohort(self): @@ -64,10 +71,10 @@ async def test_create_empty_cohort(self): description='Cohort with no entries', cohort_name='Empty cohort', dry_run=False, - cohort_criteria=CohortCriteria(projects=['test']), + cohort_criteria=CohortCriteriaInternal(projects=[self.project_id]), ) - self.assertIsInstance(result, NewCohort) - self.assertIsInstance(result.cohort_id, str) + self.assertIsInstance(result, NewCohortInternal) + self.assertIsInstance(result.cohort_id, int) self.assertEqual([], result.sequencing_group_ids) @run_as_sync @@ -78,7 +85,7 @@ async def test_create_duplicate_cohort(self): description='Cohort with no entries', cohort_name='Trial duplicate cohort', dry_run=False, - cohort_criteria=CohortCriteria(projects=['test']), + cohort_criteria=CohortCriteriaInternal(projects=[self.project_id]), ) _ = await self.cohortl.create_cohort_from_criteria( @@ -86,7 +93,7 @@ async def test_create_duplicate_cohort(self): description='Cohort with no entries', cohort_name='Trial duplicate cohort', dry_run=True, - cohort_criteria=CohortCriteria(projects=['test']), + cohort_criteria=CohortCriteriaInternal(projects=[self.project_id]), ) with self.assertRaises(IntegrityError): @@ -95,7 +102,7 @@ async def test_create_duplicate_cohort(self): description='Cohort with no entries', cohort_name='Trial duplicate cohort', dry_run=False, - cohort_criteria=CohortCriteria(projects=['test']), + cohort_criteria=CohortCriteriaInternal(projects=[self.project_id]), ) @run_as_sync @@ -103,11 +110,11 @@ async def test_create_template_then_cohorts(self): """Test with template and cohort IDs out of sync, and creating from template""" tid = await self.cohortl.create_cohort_template( project=self.project_id, - cohort_template=CohortTemplate( + cohort_template=CohortTemplateInternal( id=None, name='Empty template', description='Template with no entries', - criteria=CohortCriteria(projects=['test']), + criteria=CohortCriteriaInternal(projects=[self.project_id]), ), ) @@ -116,7 +123,7 @@ async def test_create_template_then_cohorts(self): description='Cohort with no entries', cohort_name='Another empty cohort', dry_run=False, - cohort_criteria=CohortCriteria(projects=['test']), + cohort_criteria=CohortCriteriaInternal(projects=[self.project_id]), ) _ = await self.cohortl.create_cohort_from_criteria( @@ -220,8 +227,11 @@ async def setUp(self): ) self.sgA = sequencing_group_id_format(self.sA.sequencing_groups[0].id) + self.sgA_raw = self.sA.sequencing_groups[0].id self.sgB = sequencing_group_id_format(self.sB.sequencing_groups[0].id) + self.sgB_raw = self.sB.sequencing_groups[0].id self.sgC = sequencing_group_id_format(self.sC.sequencing_groups[0].id) + self.sgC_raw = self.sC.sequencing_groups[0].id @run_as_sync async def test_create_cohort_by_sgs(self): @@ -234,10 +244,10 @@ async def test_create_cohort_by_sgs(self): cohort_criteria=CohortCriteria( projects=['test'], sg_ids_internal=[self.sgB], - ), + ).to_internal(projects_internal=[self.project_id]), ) - self.assertIsInstance(result.cohort_id, str) - self.assertEqual([self.sgB], result.sequencing_group_ids) + self.assertIsInstance(result.cohort_id, int) + self.assertEqual([self.sgB_raw], result.sequencing_group_ids) @run_as_sync async def test_create_cohort_by_excluded_sgs(self): @@ -250,12 +260,12 @@ async def test_create_cohort_by_excluded_sgs(self): cohort_criteria=CohortCriteria( projects=['test'], excluded_sgs_internal=[self.sgA], - ), + ).to_internal(projects_internal=[self.project_id]), ) - self.assertIsInstance(result.cohort_id, str) + self.assertIsInstance(result.cohort_id, int) self.assertEqual(2, len(result.sequencing_group_ids)) - self.assertIn(self.sgB, result.sequencing_group_ids) - self.assertIn(self.sgC, result.sequencing_group_ids) + self.assertIn(self.sgB_raw, result.sequencing_group_ids) + self.assertIn(self.sgC_raw, result.sequencing_group_ids) @run_as_sync async def test_create_cohort_by_technology(self): @@ -268,12 +278,12 @@ async def test_create_cohort_by_technology(self): cohort_criteria=CohortCriteria( projects=['test'], sg_technology=['short-read'], - ), + ).to_internal(projects_internal=[self.project_id]), ) - self.assertIsInstance(result.cohort_id, str) + self.assertIsInstance(result.cohort_id, int) self.assertEqual(2, len(result.sequencing_group_ids)) - self.assertIn(self.sgA, result.sequencing_group_ids) - self.assertIn(self.sgB, result.sequencing_group_ids) + self.assertIn(self.sgA_raw, result.sequencing_group_ids) + self.assertIn(self.sgB_raw, result.sequencing_group_ids) @run_as_sync async def test_create_cohort_by_platform(self): @@ -286,10 +296,10 @@ async def test_create_cohort_by_platform(self): cohort_criteria=CohortCriteria( projects=['test'], sg_platform=['ONT'], - ), + ).to_internal(projects_internal=[self.project_id]), ) - self.assertIsInstance(result.cohort_id, str) - self.assertEqual([self.sgC], result.sequencing_group_ids) + self.assertIsInstance(result.cohort_id, int) + self.assertEqual([self.sgC_raw], result.sequencing_group_ids) @run_as_sync async def test_create_cohort_by_type(self): @@ -302,12 +312,12 @@ async def test_create_cohort_by_type(self): cohort_criteria=CohortCriteria( projects=['test'], sg_type=['genome'], - ), + ).to_internal(projects_internal=[self.project_id]), ) - self.assertIsInstance(result.cohort_id, str) + self.assertIsInstance(result.cohort_id, int) self.assertEqual(2, len(result.sequencing_group_ids)) - self.assertIn(self.sgA, result.sequencing_group_ids) - self.assertIn(self.sgB, result.sequencing_group_ids) + self.assertIn(self.sgA_raw, result.sequencing_group_ids) + self.assertIn(self.sgB_raw, result.sequencing_group_ids) @run_as_sync async def test_create_cohort_by_sample_type(self): @@ -320,10 +330,10 @@ async def test_create_cohort_by_sample_type(self): cohort_criteria=CohortCriteria( projects=['test'], sample_type=['saliva'], - ), + ).to_internal(projects_internal=[self.project_id]), ) - self.assertIsInstance(result.cohort_id, str) - self.assertEqual([self.sgC], result.sequencing_group_ids) + self.assertIsInstance(result.cohort_id, int) + self.assertEqual([self.sgC_raw], result.sequencing_group_ids) @run_as_sync async def test_create_cohort_by_everything(self): @@ -341,10 +351,10 @@ async def test_create_cohort_by_everything(self): sg_platform=['illumina'], sg_type=['genome'], sample_type=['blood'], - ), + ).to_internal(projects_internal=[self.project_id]), ) self.assertEqual(1, len(result.sequencing_group_ids)) - self.assertIn(self.sgB, result.sequencing_group_ids) + self.assertIn(self.sgB_raw, result.sequencing_group_ids) @run_as_sync async def test_reevaluate_cohort(self): @@ -359,7 +369,7 @@ async def test_reevaluate_cohort(self): projects=['test'], sample_type=['blood'], ), - ), + ).to_internal(criteria_projects=[self.project_id]), ) coh1 = await self.cohortl.create_cohort_from_criteria( @@ -372,7 +382,7 @@ async def test_reevaluate_cohort(self): self.assertEqual(2, len(coh1.sequencing_group_ids)) sD = await self.samplel.upsert_sample(get_sample_model('D')) - sgD = sequencing_group_id_format(sD.sequencing_groups[0].id) + sgD_raw = sD.sequencing_groups[0].id coh2 = await self.cohortl.create_cohort_from_criteria( project_to_write=self.project_id, @@ -383,8 +393,8 @@ async def test_reevaluate_cohort(self): ) self.assertEqual(3, len(coh2.sequencing_group_ids)) - self.assertNotIn(sgD, coh1.sequencing_group_ids) - self.assertIn(sgD, coh2.sequencing_group_ids) + self.assertNotIn(sgD_raw, coh1.sequencing_group_ids) + self.assertIn(sgD_raw, coh2.sequencing_group_ids) @run_as_sync async def test_query_cohort(self): @@ -397,7 +407,7 @@ async def test_query_cohort(self): cohort_criteria=CohortCriteria( projects=['test'], sg_ids_internal=[self.sgA, self.sgB], - ), + ).to_internal(projects_internal=[self.project_id]), ) self.assertEqual(2, len(created.sequencing_group_ids)) From 86e9237a37b544b7286bb0eaad796db846f6893b Mon Sep 17 00:00:00 2001 From: John Marshall Date: Tue, 23 Apr 2024 11:41:48 +1200 Subject: [PATCH 150/161] Use UTC for test_query_with_creation_date comparisons The creation time of the record inserted by upsert_sample() will be reported in UTC, so we need to compare against today in UTC. Otherwise tests fail when run locally before lunchtimeish as it is still "yesterday" in UTC, so lt=today unexpectedly returns the just-created record. --- test/test_sequencing_groups.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/test/test_sequencing_groups.py b/test/test_sequencing_groups.py index f5fe077d6..39295e4b5 100644 --- a/test/test_sequencing_groups.py +++ b/test/test_sequencing_groups.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import datetime from test.testbase import DbIsolatedTest, run_as_sync from db.python.layers import AnalysisLayer, SampleLayer, SequencingGroupLayer @@ -202,31 +202,34 @@ async def test_query_with_creation_date(self): sample_to_insert = get_sample_model() await self.slayer.upsert_sample(sample_to_insert) + # There's a race condition here -- don't run this near UTC midnight! + today = datetime.utcnow().date() + # Query for sequencing group with creation date before today sgs = await self.sglayer.query( - SequencingGroupFilter(created_on=GenericFilter(lt=date.today())) + SequencingGroupFilter(created_on=GenericFilter(lt=today)) ) self.assertEqual(len(sgs), 0) # Query for sequencing group with creation date today sgs = await self.sglayer.query( - SequencingGroupFilter(created_on=GenericFilter(eq=date.today())) + SequencingGroupFilter(created_on=GenericFilter(eq=today)) ) self.assertEqual(len(sgs), 1) sgs = await self.sglayer.query( - SequencingGroupFilter(created_on=GenericFilter(lte=date.today())) + SequencingGroupFilter(created_on=GenericFilter(lte=today)) ) self.assertEqual(len(sgs), 1) sgs = await self.sglayer.query( - SequencingGroupFilter(created_on=GenericFilter(gte=date.today())) + SequencingGroupFilter(created_on=GenericFilter(gte=today)) ) self.assertEqual(len(sgs), 1) # Query for sequencing group with creation date today sgs = await self.sglayer.query( - SequencingGroupFilter(created_on=GenericFilter(gt=date.today())) + SequencingGroupFilter(created_on=GenericFilter(gt=today)) ) self.assertEqual(len(sgs), 0) From b10f10e14caeefd3d1ddcd2ba8cd6aaca7f8792b Mon Sep 17 00:00:00 2001 From: John Marshall Date: Tue, 23 Apr 2024 15:54:12 +1200 Subject: [PATCH 151/161] Add basic tests for scripts/create_custom_cohort.py Fix the script's template_id type, reflecting CohortBody's corresponding member's change from str to int in d705285eb. --- scripts/create_custom_cohort.py | 6 +-- test/test_cohort_builder.py | 82 +++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 test/test_cohort_builder.py diff --git a/scripts/create_custom_cohort.py b/scripts/create_custom_cohort.py index 647ee1094..246c70f9b 100644 --- a/scripts/create_custom_cohort.py +++ b/scripts/create_custom_cohort.py @@ -44,11 +44,11 @@ def main( def get_cohort_spec( - cohort_name: str, cohort_description: str, cohort_template_id: int + cohort_name: str, cohort_description: str, cohort_template_id: str ) -> CohortBody: """Get the cohort spec""" - cohort_body_spec: dict[str, int | str] = {} + cohort_body_spec: dict[str, str] = {} if cohort_name: cohort_body_spec['name'] = cohort_name @@ -69,7 +69,7 @@ def get_cohort_spec( parser.add_argument('--name', type=str, help='The name of the cohort') parser.add_argument('--description', type=str, help='The description of the cohort') parser.add_argument( - '--template_id', required=False, type=int, help='The template id of the cohort' + '--template_id', required=False, type=str, help='The template id of the cohort' ) parser.add_argument( '--projects', diff --git a/test/test_cohort_builder.py b/test/test_cohort_builder.py new file mode 100644 index 000000000..41745ea4a --- /dev/null +++ b/test/test_cohort_builder.py @@ -0,0 +1,82 @@ +from test.testbase import DbIsolatedTest, run_as_sync +from unittest.mock import patch + +import metamist.model.cohort_body +import metamist.model.cohort_criteria +from metamist.models import CohortBody +from models.utils.cohort_template_id_format import cohort_template_id_format +from scripts.create_custom_cohort import get_cohort_spec, main + + +class TestCohortBuilder(DbIsolatedTest): + """Test custom cohort builder script""" + + @run_as_sync + async def setUp(self): + super().setUp() + + @run_as_sync + async def test_get_cohort_spec(self): + """Test get_cohort_spec(), invoked by the creator script""" + ctemplate_id = cohort_template_id_format(28) + result = get_cohort_spec('My cohort', 'Describing the cohort', ctemplate_id) + self.assertIsInstance(result, metamist.model.cohort_body.CohortBody) + self.assertEqual(result.name, 'My cohort') + self.assertEqual(result.description, 'Describing the cohort') + self.assertEqual(result.template_id, ctemplate_id) + + @run_as_sync + @patch('metamist.apis.CohortApi.create_cohort_from_criteria') + async def test_empty_main(self, mock): + """Test main with no criteria""" + mock.return_value = {'cohort_id': 'COH1', 'sequencing_group_ids': ['SG1', 'SG2']} + main( + project='greek-myth', + cohort_body_spec=CohortBody(name='Empty cohort', description='No criteria'), + projects=None, + sg_ids_internal=[], + excluded_sg_ids=[], + sg_technologies=[], + sg_platforms=[], + sg_types=[], + sample_types=[], + dry_run=False, + ) + mock.assert_called_once() + + @run_as_sync + @patch('metamist.apis.CohortApi.create_cohort_from_criteria') + async def test_epic_main(self, mock): + """Test""" + mock.return_value = {'cohort_id': 'COH2', 'sequencing_group_ids': ['SG3']} + main( + project='greek-myth', + cohort_body_spec=CohortBody(name='Epic cohort', description='Every criterion'), + projects=['alpha', 'beta'], + sg_ids_internal=['SG3'], + excluded_sg_ids=['SG1', 'SG2'], + sg_technologies=['short-read'], + sg_platforms=['illumina'], + sg_types=['genome'], + sample_types=['blood'], + dry_run=False, + ) + mock.assert_called_once() + self.assertEqual(mock.call_args.kwargs['project'], 'greek-myth') + + body = mock.call_args.kwargs['body_create_cohort_from_criteria'] + spec = body['cohort_spec'] + self.assertEqual(spec.name, 'Epic cohort') + self.assertEqual(spec.description, 'Every criterion') + + criteria = body['cohort_criteria'] + self.assertIsInstance(criteria, metamist.model.cohort_criteria.CohortCriteria) + self.assertListEqual(criteria.projects, ['alpha', 'beta']) + self.assertListEqual(criteria.sg_ids_internal, ['SG3']) + self.assertListEqual(criteria.excluded_sgs_internal, ['SG1', 'SG2']) + self.assertListEqual(criteria.sg_technology, ['short-read']) + self.assertListEqual(criteria.sg_platform, ['illumina']) + self.assertListEqual(criteria.sg_type, ['genome']) + self.assertListEqual(criteria.sample_types, ['blood']) + + self.assertFalse(body['dry_run']) From 6f78b9b7063c50fd2f213ddc4569b520bcfad428 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Wed, 24 Apr 2024 12:27:34 +1200 Subject: [PATCH 152/161] Add separate CohortCriteria/Template.to_internal() tests And in all the other tests, use CohortCriteriaInternal directly. --- test/test_cohort.py | 110 +++++++++++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 33 deletions(-) diff --git a/test/test_cohort.py b/test/test_cohort.py index 57a2a8c3e..dec455f3e 100644 --- a/test/test_cohort.py +++ b/test/test_cohort.py @@ -233,6 +233,50 @@ async def setUp(self): self.sgC = sequencing_group_id_format(self.sC.sequencing_groups[0].id) self.sgC_raw = self.sC.sequencing_groups[0].id + @run_as_sync + async def test_internal_external(self): + """Test to_internal() methods""" + cc_external_dict = { + 'projects': ['test'], + 'sg_ids_internal': [self.sgB, self.sgC], + 'excluded_sgs_internal': [self.sgA], + 'sg_technology': ['short-read'], + 'sg_platform': ['illumina'], + 'sg_type': ['genome'], + 'sample_type': ['blood'], + } + + cc_internal_dict = { + 'projects': [self.project_id], + 'sg_ids_internal_raw': [self.sgB_raw, self.sgC_raw], + 'excluded_sgs_internal_raw': [self.sgA_raw], + 'sg_technology': ['short-read'], + 'sg_platform': ['illumina'], + 'sg_type': ['genome'], + 'sample_type': ['blood'], + } + + cc_external = CohortCriteria(**cc_external_dict) + cc_internal = cc_external.to_internal(projects_internal=[self.project_id]) + self.assertIsInstance(cc_internal, CohortCriteriaInternal) + self.assertDictEqual(cc_internal.dict(), cc_internal_dict) + + ctpl_internal_dict = { + 'id': 496, + 'name': 'My template', + 'description': 'Testing template', + 'criteria': cc_internal_dict, + } + + ctpl_internal = CohortTemplate( + id=496, + name='My template', + description='Testing template', + criteria=cc_external, + ).to_internal(criteria_projects=[self.project_id]) + self.assertIsInstance(ctpl_internal, CohortTemplateInternal) + self.assertDictEqual(ctpl_internal.dict(), ctpl_internal_dict) + @run_as_sync async def test_create_cohort_by_sgs(self): """Create cohort by selecting sequencing groups""" @@ -241,10 +285,10 @@ async def test_create_cohort_by_sgs(self): description='Cohort with 1 SG', cohort_name='SG cohort 1', dry_run=False, - cohort_criteria=CohortCriteria( - projects=['test'], - sg_ids_internal=[self.sgB], - ).to_internal(projects_internal=[self.project_id]), + cohort_criteria=CohortCriteriaInternal( + projects=[self.project_id], + sg_ids_internal_raw=[self.sgB_raw], + ), ) self.assertIsInstance(result.cohort_id, int) self.assertEqual([self.sgB_raw], result.sequencing_group_ids) @@ -257,10 +301,10 @@ async def test_create_cohort_by_excluded_sgs(self): description='Cohort without 1 SG', cohort_name='SG cohort 2', dry_run=False, - cohort_criteria=CohortCriteria( - projects=['test'], - excluded_sgs_internal=[self.sgA], - ).to_internal(projects_internal=[self.project_id]), + cohort_criteria=CohortCriteriaInternal( + projects=[self.project_id], + excluded_sgs_internal_raw=[self.sgA_raw], + ), ) self.assertIsInstance(result.cohort_id, int) self.assertEqual(2, len(result.sequencing_group_ids)) @@ -275,10 +319,10 @@ async def test_create_cohort_by_technology(self): description='Short-read cohort', cohort_name='Tech cohort 1', dry_run=False, - cohort_criteria=CohortCriteria( - projects=['test'], + cohort_criteria=CohortCriteriaInternal( + projects=[self.project_id], sg_technology=['short-read'], - ).to_internal(projects_internal=[self.project_id]), + ), ) self.assertIsInstance(result.cohort_id, int) self.assertEqual(2, len(result.sequencing_group_ids)) @@ -293,10 +337,10 @@ async def test_create_cohort_by_platform(self): description='ONT cohort', cohort_name='Platform cohort 1', dry_run=False, - cohort_criteria=CohortCriteria( - projects=['test'], + cohort_criteria=CohortCriteriaInternal( + projects=[self.project_id], sg_platform=['ONT'], - ).to_internal(projects_internal=[self.project_id]), + ), ) self.assertIsInstance(result.cohort_id, int) self.assertEqual([self.sgC_raw], result.sequencing_group_ids) @@ -309,10 +353,10 @@ async def test_create_cohort_by_type(self): description='Genome cohort', cohort_name='Type cohort 1', dry_run=False, - cohort_criteria=CohortCriteria( - projects=['test'], + cohort_criteria=CohortCriteriaInternal( + projects=[self.project_id], sg_type=['genome'], - ).to_internal(projects_internal=[self.project_id]), + ), ) self.assertIsInstance(result.cohort_id, int) self.assertEqual(2, len(result.sequencing_group_ids)) @@ -327,10 +371,10 @@ async def test_create_cohort_by_sample_type(self): description='Sample cohort', cohort_name='Sample cohort 1', dry_run=False, - cohort_criteria=CohortCriteria( - projects=['test'], + cohort_criteria=CohortCriteriaInternal( + projects=[self.project_id], sample_type=['saliva'], - ).to_internal(projects_internal=[self.project_id]), + ), ) self.assertIsInstance(result.cohort_id, int) self.assertEqual([self.sgC_raw], result.sequencing_group_ids) @@ -343,15 +387,15 @@ async def test_create_cohort_by_everything(self): description='Everything cohort', cohort_name='Everything cohort 1', dry_run=False, - cohort_criteria=CohortCriteria( - projects=['test'], - sg_ids_internal=[self.sgB, self.sgC], - excluded_sgs_internal=[self.sgA], + cohort_criteria=CohortCriteriaInternal( + projects=[self.project_id], + sg_ids_internal_raw=[self.sgB_raw, self.sgC_raw], + excluded_sgs_internal_raw=[self.sgA_raw], sg_technology=['short-read'], sg_platform=['illumina'], sg_type=['genome'], sample_type=['blood'], - ).to_internal(projects_internal=[self.project_id]), + ), ) self.assertEqual(1, len(result.sequencing_group_ids)) self.assertIn(self.sgB_raw, result.sequencing_group_ids) @@ -361,15 +405,15 @@ async def test_reevaluate_cohort(self): """Add another sample, then reevaluate a cohort template""" template = await self.cohortl.create_cohort_template( project=self.project_id, - cohort_template=CohortTemplate( + cohort_template=CohortTemplateInternal( id=None, name='Blood template', description='Template selecting blood', - criteria=CohortCriteria( - projects=['test'], + criteria=CohortCriteriaInternal( + projects=[self.project_id], sample_type=['blood'], ), - ).to_internal(criteria_projects=[self.project_id]), + ), ) coh1 = await self.cohortl.create_cohort_from_criteria( @@ -404,10 +448,10 @@ async def test_query_cohort(self): description='Cohort with two samples', cohort_name='Duo cohort', dry_run=False, - cohort_criteria=CohortCriteria( - projects=['test'], - sg_ids_internal=[self.sgA, self.sgB], - ).to_internal(projects_internal=[self.project_id]), + cohort_criteria=CohortCriteriaInternal( + projects=[self.project_id], + sg_ids_internal_raw=[self.sgA_raw, self.sgB_raw], + ), ) self.assertEqual(2, len(created.sequencing_group_ids)) From 9aaaa10dfb8d5f0e938bc870f679907fcd3125f1 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 29 Apr 2024 09:27:48 +1000 Subject: [PATCH 153/161] Switch get_project_write_connection to get_project_readonly_connection as noone will be able to use it at present --- api/routes/cohort.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/routes/cohort.py b/api/routes/cohort.py index 68559380e..a8cb7ec25 100644 --- a/api/routes/cohort.py +++ b/api/routes/cohort.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from api.utils.db import Connection, get_project_write_connection +from api.utils.db import Connection, get_project_readonly_connection from db.python.layers.cohort import CohortLayer from db.python.tables.project import ProjectPermissionsTable from models.models.cohort import CohortBody, CohortCriteria, CohortTemplate, NewCohort @@ -17,7 +17,7 @@ async def create_cohort_from_criteria( cohort_spec: CohortBody, cohort_criteria: CohortCriteria | None = None, - connection: Connection = get_project_write_connection, + connection: Connection = get_project_readonly_connection, dry_run: bool = False, ) -> NewCohort: """ @@ -70,7 +70,7 @@ async def create_cohort_from_criteria( @router.post('/{project}/cohort_template', operation_id='createCohortTemplate') async def create_cohort_template( template: CohortTemplate, - connection: Connection = get_project_write_connection, + connection: Connection = get_project_readonly_connection, ) -> str: """ Create a cohort template with the given name and sample/sequencing group IDs. From b7b9d1a7912d311e4119f1f42e991e5d573b0de7 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 29 Apr 2024 09:37:23 +1000 Subject: [PATCH 154/161] Return [] if no template meets criteria, switch return order of project and templates --- db/python/layers/cohort.py | 5 ++++- db/python/tables/cohort.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index d894dcac9..bfe06ca2b 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -87,7 +87,10 @@ async def query_cohort_templates( self, filter_: CohortTemplateFilter, check_project_ids: bool = True ) -> list[CohortTemplateInternal]: """Query CohortTemplates""" - cohort_templates, project_ids = await self.ct.query_cohort_templates(filter_) + project_ids, cohort_templates = await self.ct.query_cohort_templates(filter_) + + if not cohort_templates: + return [] if check_project_ids: await self.pt.get_and_check_access_to_projects_for_ids( diff --git a/db/python/tables/cohort.py b/db/python/tables/cohort.py index 22f212d79..c0e0da24c 100644 --- a/db/python/tables/cohort.py +++ b/db/python/tables/cohort.py @@ -90,7 +90,7 @@ async def get_cohort_sequencing_group_ids(self, cohort_id: int) -> list[int]: async def query_cohort_templates( self, filter_: CohortTemplateFilter - ) -> tuple[list[CohortTemplateInternal], set[ProjectId]]: + ) -> tuple[set[ProjectId], list[CohortTemplateInternal]]: """Query CohortTemplates""" wheres, values = filter_.to_sql(field_overrides={}) if not wheres: @@ -106,7 +106,7 @@ async def query_cohort_templates( rows = await self.connection.fetch_all(_query, values) cohort_templates = [CohortTemplateInternal.from_db(dict(row)) for row in rows] projects = {c.project for c in cohort_templates} - return cohort_templates, projects + return projects, cohort_templates async def get_cohort_template(self, template_id: int) -> CohortTemplateInternal: """ From f52af308ccf4034013a1c9fb648576cfa0beb4ab Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 29 Apr 2024 09:43:17 +1000 Subject: [PATCH 155/161] Cohort ID should be None in dry-run mode --- db/python/layers/cohort.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/python/layers/cohort.py b/db/python/layers/cohort.py index bfe06ca2b..6f07736ed 100644 --- a/db/python/layers/cohort.py +++ b/db/python/layers/cohort.py @@ -219,7 +219,7 @@ async def create_cohort_from_criteria( return NewCohortInternal( dry_run=True, - cohort_id=template_id, + cohort_id=None, sequencing_group_ids=sg_ids, ) # 2. Create cohort template, if required. From c3473098d7c6caf54de2c7e0938748b20839dc76 Mon Sep 17 00:00:00 2001 From: vivbak Date: Mon, 29 Apr 2024 11:01:07 +1000 Subject: [PATCH 156/161] sample_types -> sample_type --- scripts/create_custom_cohort.py | 2 +- test/test_cohort_builder.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/create_custom_cohort.py b/scripts/create_custom_cohort.py index 246c70f9b..6d96ca21b 100644 --- a/scripts/create_custom_cohort.py +++ b/scripts/create_custom_cohort.py @@ -28,7 +28,7 @@ def main( sg_technology=sg_technologies or [], sg_platform=sg_platforms or [], sg_type=sg_types or [], - sample_types=sample_types or [], + sample_type=sample_types or [], ) cohort = capi.create_cohort_from_criteria( diff --git a/test/test_cohort_builder.py b/test/test_cohort_builder.py index 41745ea4a..e62e3e159 100644 --- a/test/test_cohort_builder.py +++ b/test/test_cohort_builder.py @@ -29,7 +29,10 @@ async def test_get_cohort_spec(self): @patch('metamist.apis.CohortApi.create_cohort_from_criteria') async def test_empty_main(self, mock): """Test main with no criteria""" - mock.return_value = {'cohort_id': 'COH1', 'sequencing_group_ids': ['SG1', 'SG2']} + mock.return_value = { + 'cohort_id': 'COH1', + 'sequencing_group_ids': ['SG1', 'SG2'], + } main( project='greek-myth', cohort_body_spec=CohortBody(name='Empty cohort', description='No criteria'), @@ -51,7 +54,9 @@ async def test_epic_main(self, mock): mock.return_value = {'cohort_id': 'COH2', 'sequencing_group_ids': ['SG3']} main( project='greek-myth', - cohort_body_spec=CohortBody(name='Epic cohort', description='Every criterion'), + cohort_body_spec=CohortBody( + name='Epic cohort', description='Every criterion' + ), projects=['alpha', 'beta'], sg_ids_internal=['SG3'], excluded_sg_ids=['SG1', 'SG2'], @@ -77,6 +82,6 @@ async def test_epic_main(self, mock): self.assertListEqual(criteria.sg_technology, ['short-read']) self.assertListEqual(criteria.sg_platform, ['illumina']) self.assertListEqual(criteria.sg_type, ['genome']) - self.assertListEqual(criteria.sample_types, ['blood']) + self.assertListEqual(criteria.sample_type, ['blood']) self.assertFalse(body['dry_run']) From 5f5cf2385a2390c7f7066ac17c59c4efd26eb2d1 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Tue, 30 Apr 2024 14:03:02 +1200 Subject: [PATCH 157/161] Only run _query_cohort_ids query if :analysis_ids will be non-empty --- db/python/tables/analysis.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/db/python/tables/analysis.py b/db/python/tables/analysis.py index f1c4bd54f..9759396b3 100644 --- a/db/python/tables/analysis.py +++ b/db/python/tables/analysis.py @@ -277,7 +277,7 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: else: retvals[key] = AnalysisInternal.from_db(**dict(row)) - if retvals.keys(): + if retvals: _query_sg_ids = """ SELECT sequencing_group_id, analysis_id FROM analysis_sequencing_group @@ -310,17 +310,18 @@ async def query(self, filter_: AnalysisFilter) -> List[AnalysisInternal]: else: retvals[key] = AnalysisInternal.from_db(**dict(row)) - _query_cohort_ids = """ - SELECT analysis_id, cohort_id - FROM analysis_cohort - WHERE analysis_id IN :analysis_ids; - """ - cohort_ids = await self.connection.fetch_all( - _query_cohort_ids, {'analysis_ids': list(retvals.keys())} - ) + if retvals: + _query_cohort_ids = """ + SELECT analysis_id, cohort_id + FROM analysis_cohort + WHERE analysis_id IN :analysis_ids + """ + cohort_ids = await self.connection.fetch_all( + _query_cohort_ids, {'analysis_ids': list(retvals.keys())} + ) - for row in cohort_ids: - retvals[row['analysis_id']].cohort_ids.append(row['cohort_id']) + for row in cohort_ids: + retvals[row['analysis_id']].cohort_ids.append(row['cohort_id']) return list(retvals.values()) From 0d1757f643ae2cedbfcc67448485a50b05f8b487 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Mon, 29 Apr 2024 21:17:07 +1200 Subject: [PATCH 158/161] Improve mocking in test_cohort_builder.py tests Actually call the underlying route so real data can be returned. In create_custom_cohort.py, add a return value for ease of testing and fix get_cohort_spec() type annotations. --- scripts/create_custom_cohort.py | 3 +- test/test_cohort_builder.py | 66 ++++++++++++++++++++------------- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/scripts/create_custom_cohort.py b/scripts/create_custom_cohort.py index 6d96ca21b..376c960a9 100644 --- a/scripts/create_custom_cohort.py +++ b/scripts/create_custom_cohort.py @@ -41,10 +41,11 @@ def main( ) print(f'Awesome! You have created a custom cohort {cohort}') + return cohort def get_cohort_spec( - cohort_name: str, cohort_description: str, cohort_template_id: str + cohort_name: str | None, cohort_description: str | None, cohort_template_id: str | None ) -> CohortBody: """Get the cohort spec""" diff --git a/test/test_cohort_builder.py b/test/test_cohort_builder.py index e62e3e159..0918741a9 100644 --- a/test/test_cohort_builder.py +++ b/test/test_cohort_builder.py @@ -1,9 +1,9 @@ from test.testbase import DbIsolatedTest, run_as_sync from unittest.mock import patch -import metamist.model.cohort_body -import metamist.model.cohort_criteria -from metamist.models import CohortBody +import api.routes.cohort +import metamist.models +from models.models.cohort import CohortBody, CohortCriteria, NewCohort from models.utils.cohort_template_id_format import cohort_template_id_format from scripts.create_custom_cohort import get_cohort_spec, main @@ -15,12 +15,23 @@ class TestCohortBuilder(DbIsolatedTest): async def setUp(self): super().setUp() + @run_as_sync + async def mock_ccfc(self, project, body_create_cohort_from_criteria): + """Mock by directly calling the API route""" + self.assertEqual(project, self.project_name) + return await api.routes.cohort.create_cohort_from_criteria( + CohortBody(**body_create_cohort_from_criteria['cohort_spec'].to_dict()), + CohortCriteria(**body_create_cohort_from_criteria['cohort_criteria'].to_dict()), + self.connection, + body_create_cohort_from_criteria['dry_run'], + ) + @run_as_sync async def test_get_cohort_spec(self): """Test get_cohort_spec(), invoked by the creator script""" ctemplate_id = cohort_template_id_format(28) result = get_cohort_spec('My cohort', 'Describing the cohort', ctemplate_id) - self.assertIsInstance(result, metamist.model.cohort_body.CohortBody) + self.assertIsInstance(result, metamist.models.CohortBody) self.assertEqual(result.name, 'My cohort') self.assertEqual(result.description, 'Describing the cohort') self.assertEqual(result.template_id, ctemplate_id) @@ -29,14 +40,11 @@ async def test_get_cohort_spec(self): @patch('metamist.apis.CohortApi.create_cohort_from_criteria') async def test_empty_main(self, mock): """Test main with no criteria""" - mock.return_value = { - 'cohort_id': 'COH1', - 'sequencing_group_ids': ['SG1', 'SG2'], - } - main( - project='greek-myth', - cohort_body_spec=CohortBody(name='Empty cohort', description='No criteria'), - projects=None, + mock.side_effect = self.mock_ccfc + result = main( + project=self.project_name, + cohort_body_spec=metamist.models.CohortBody(name='Empty cohort', description='No criteria'), + projects=['test'], sg_ids_internal=[], excluded_sg_ids=[], sg_technologies=[], @@ -46,20 +54,22 @@ async def test_empty_main(self, mock): dry_run=False, ) mock.assert_called_once() + self.assertIsInstance(result, NewCohort) + self.assertIsInstance(result.cohort_id, str) + self.assertListEqual(result.sequencing_group_ids, []) + self.assertEqual(result.dry_run, False) @run_as_sync @patch('metamist.apis.CohortApi.create_cohort_from_criteria') async def test_epic_main(self, mock): """Test""" - mock.return_value = {'cohort_id': 'COH2', 'sequencing_group_ids': ['SG3']} - main( - project='greek-myth', - cohort_body_spec=CohortBody( - name='Epic cohort', description='Every criterion' - ), - projects=['alpha', 'beta'], - sg_ids_internal=['SG3'], - excluded_sg_ids=['SG1', 'SG2'], + mock.side_effect = self.mock_ccfc + result = main( + project=self.project_name, + cohort_body_spec=metamist.models.CohortBody(name='Epic cohort', description='Every criterion'), + projects=['test'], + sg_ids_internal=['CPGLCL33'], + excluded_sg_ids=['CPGLCL17', 'CPGLCL25'], sg_technologies=['short-read'], sg_platforms=['illumina'], sg_types=['genome'], @@ -67,7 +77,7 @@ async def test_epic_main(self, mock): dry_run=False, ) mock.assert_called_once() - self.assertEqual(mock.call_args.kwargs['project'], 'greek-myth') + self.assertEqual(mock.call_args.kwargs['project'], self.project_name) body = mock.call_args.kwargs['body_create_cohort_from_criteria'] spec = body['cohort_spec'] @@ -75,13 +85,17 @@ async def test_epic_main(self, mock): self.assertEqual(spec.description, 'Every criterion') criteria = body['cohort_criteria'] - self.assertIsInstance(criteria, metamist.model.cohort_criteria.CohortCriteria) - self.assertListEqual(criteria.projects, ['alpha', 'beta']) - self.assertListEqual(criteria.sg_ids_internal, ['SG3']) - self.assertListEqual(criteria.excluded_sgs_internal, ['SG1', 'SG2']) + self.assertIsInstance(criteria, metamist.models.CohortCriteria) + self.assertListEqual(criteria.projects, ['test']) + self.assertListEqual(criteria.sg_ids_internal, ['CPGLCL33']) + self.assertListEqual(criteria.excluded_sgs_internal, ['CPGLCL17', 'CPGLCL25']) self.assertListEqual(criteria.sg_technology, ['short-read']) self.assertListEqual(criteria.sg_platform, ['illumina']) self.assertListEqual(criteria.sg_type, ['genome']) self.assertListEqual(criteria.sample_type, ['blood']) self.assertFalse(body['dry_run']) + + self.assertIsInstance(result, NewCohort) + self.assertListEqual(result.sequencing_group_ids, []) + self.assertEqual(result.dry_run, False) From f7939279fb5b459bb104388ad6f84e9b2ed85f80 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Tue, 30 Apr 2024 13:30:31 +1200 Subject: [PATCH 159/161] Escape metacharacters in icontains query string (and add tests) --- db/python/utils.py | 3 ++- test/test_generic_filters.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/db/python/utils.py b/db/python/utils.py index 0aae8671f..3afebbaff 100644 --- a/db/python/utils.py +++ b/db/python/utils.py @@ -218,9 +218,10 @@ def to_sql( conditionals.append(f'{column} LIKE :{k}') values[k] = self._sql_value_prep(f'%{search_term}%') if self.icontains is not None: + search_term = escape_like_term(str(self.icontains)) k = self.generate_field_name(column + '_icontains') conditionals.append(f'LOWER({column}) LIKE LOWER(:{k})') - values[k] = self._sql_value_prep(f'%{self.icontains}%') + values[k] = self._sql_value_prep(f'%{search_term}%') return ' AND '.join(conditionals), values diff --git a/test/test_generic_filters.py b/test/test_generic_filters.py index fb6234ae5..ba08befff 100644 --- a/test/test_generic_filters.py +++ b/test/test_generic_filters.py @@ -32,6 +32,14 @@ def test_contains_case_sensitive(self): self.assertEqual('test_string LIKE :test_string_contains', sql) self.assertDictEqual({'test_string_contains': '%test%'}, values) + def test_malicious_contains(self): + """Test that a contains-% filter converts to SQL by appropriately encoding _ and %""" + filter_ = GenericFilterTest(test_string=GenericFilter(contains='per%ce_nt')) + sql, values = filter_.to_sql() + + self.assertEqual('test_string LIKE :test_string_contains', sql) + self.assertDictEqual({'test_string_contains': r'%per\%ce\_nt%'}, values) + def test_icontains_is_not_case_sensitive(self): """Test that the basic filter converts to SQL as expected""" filter_ = GenericFilterTest(test_string=GenericFilter(icontains='test')) @@ -40,6 +48,14 @@ def test_icontains_is_not_case_sensitive(self): self.assertEqual('LOWER(test_string) LIKE LOWER(:test_string_icontains)', sql) self.assertDictEqual({'test_string_icontains': '%test%'}, values) + def test_malicious_icontains(self): + """Test that an icontains-% filter converts to SQL by appropriately encoding _ and %""" + filter_ = GenericFilterTest(test_string=GenericFilter(icontains='per%ce_nt')) + sql, values = filter_.to_sql() + + self.assertEqual('LOWER(test_string) LIKE LOWER(:test_string_icontains)', sql) + self.assertDictEqual({'test_string_icontains': r'%per\%ce\_nt%'}, values) + def test_basic_override(self): """Test that the basic filter with an override converts to SQL as expected""" filter_ = GenericFilterTest(test_string=GenericFilter(eq='test')) From b8e125275fa3d1f5907171f66296dd33bf0470e1 Mon Sep 17 00:00:00 2001 From: John Marshall Date: Tue, 30 Apr 2024 14:21:20 +1200 Subject: [PATCH 160/161] Make --dry-run an argumentless flag option --- scripts/create_custom_cohort.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/create_custom_cohort.py b/scripts/create_custom_cohort.py index 376c960a9..f2fe9bfe2 100644 --- a/scripts/create_custom_cohort.py +++ b/scripts/create_custom_cohort.py @@ -112,7 +112,7 @@ def get_cohort_spec( parser.add_argument( '--sample_type', required=False, type=list[str], help='sample type' ) - parser.add_argument('--dry_run', required=False, type=bool, help='Dry run mode') + parser.add_argument('--dry-run', '--dry_run', action='store_true', help='Dry run mode') args = parser.parse_args() From 9fcc2a93334e3a92188a4e7a068fc901ee322a2c Mon Sep 17 00:00:00 2001 From: vivbak Date: Tue, 30 Apr 2024 12:30:43 +1000 Subject: [PATCH 161/161] =?UTF-8?q?Bump=20version:=206.9.1=20=E2=86=92=206?= =?UTF-8?q?.10.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- api/server.py | 2 +- deploy/python/version.txt | 2 +- setup.py | 2 +- web/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d86150fbc..091a18616 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 6.9.1 +current_version = 6.10.0 commit = True tag = False parse = (?P\d+)\.(?P\d+)\.(?P[A-z0-9-]+) diff --git a/api/server.py b/api/server.py index e320869b9..c4ad484b4 100644 --- a/api/server.py +++ b/api/server.py @@ -19,7 +19,7 @@ from db.python.utils import get_logger # This tag is automatically updated by bump2version -_VERSION = '6.9.1' +_VERSION = '6.10.0' logger = get_logger() diff --git a/deploy/python/version.txt b/deploy/python/version.txt index dc3829f5e..cf79bf90e 100644 --- a/deploy/python/version.txt +++ b/deploy/python/version.txt @@ -1 +1 @@ -6.9.1 +6.10.0 diff --git a/setup.py b/setup.py index 72c9c27e3..ade917b90 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ setup( name=PKG, # This tag is automatically updated by bump2version - version='6.9.1', + version='6.10.0', description='Python API for interacting with the Sample API system', long_description=readme, long_description_content_type='text/markdown', diff --git a/web/package.json b/web/package.json index 615a7a278..d8b163225 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "metamist", - "version": "6.9.1", + "version": "6.10.0", "private": true, "dependencies": { "@apollo/client": "^3.7.3",