From bd74457f1eeff0aad022e998645b5f6372823ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Gaonach?= Date: Tue, 24 Jan 2023 14:40:41 +0100 Subject: [PATCH 1/3] authorization helpers --- .../app/extensions/database/definitions.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/pyispyb/app/extensions/database/definitions.py b/pyispyb/app/extensions/database/definitions.py index 16633a17..74968661 100644 --- a/pyispyb/app/extensions/database/definitions.py +++ b/pyispyb/app/extensions/database/definitions.py @@ -1,5 +1,6 @@ import logging from typing import Optional, Any +from fastapi import HTTPException import sqlalchemy from sqlalchemy.orm import joinedload @@ -8,6 +9,8 @@ from pyispyb.app.globals import g from pyispyb.app.extensions.database.middleware import db +from sqlalchemy.orm import joinedload + logger = logging.getLogger(__name__) @@ -52,6 +55,42 @@ def get_options() -> Options: return app.db_options +def authorize_for_proposal(proposalId: int) -> True: + query = db.session.query(models.Proposal).filter( + models.Proposal.proposalId == proposalId + ) + query = with_authorization_proposal(query) + res = query.count() + if res == 0: + raise HTTPException( + status_code=403, detail="User is not authorized for proposal" + ) + + +def with_authorization_proposal( + query: "sqlalchemy.orm.Query[Any]", + includeArchived: bool = False, +): + return with_authorization( + query=query, + includeArchived=includeArchived, + proposalColumn=None, + joinBLSession=True, + ) + + +def with_authorization_session( + query: "sqlalchemy.orm.Query[Any]", + includeArchived: bool = False, +): + return with_authorization( + query=query, + includeArchived=includeArchived, + proposalColumn=models.BLSession.proposalId, + joinBLSession=False, + ) + + def with_authorization( query: "sqlalchemy.orm.Query[Any]", includeArchived: bool = False, From f378878d0acf277c54da3feda99498d3496927b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Gaonach?= Date: Tue, 24 Jan 2023 14:42:25 +0100 Subject: [PATCH 2/3] optional nested model update --- pyispyb/app/extensions/database/utils.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyispyb/app/extensions/database/utils.py b/pyispyb/app/extensions/database/utils.py index e8848911..c73e2628 100644 --- a/pyispyb/app/extensions/database/utils.py +++ b/pyispyb/app/extensions/database/utils.py @@ -106,11 +106,15 @@ def with_metadata( return parsed -def update_model(model: any, values: dict[str, any]): +def update_model(model: any, values: dict[str, any], nested=True): """Update a model with new values including nested models""" for key, value in values.items(): if isinstance(value, dict): - update_model(getattr(model, key), value) + if nested: + update_model(getattr(model, key), value) + elif isinstance(value, list): + if nested: + raise NotImplementedError("Need to implement nested list update") else: if isinstance(value, enum.Enum): value = value.value From 9bd17086634a7b56452a1b6ea04f983f2704d5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Gaonach?= Date: Tue, 24 Jan 2023 14:50:59 +0100 Subject: [PATCH 3/3] sample routes --- .../app/extensions/database/definitions.py | 1 - pyispyb/core/modules/samples.py | 251 +++++++++++++++++- pyispyb/core/routes/samples.py | 67 +++++ pyispyb/core/schemas/crystal.py | 4 + pyispyb/core/schemas/samples.py | 133 ++++++++-- requirements.txt | 2 +- tests/core/api/data/samples.py | 189 +++++++++++++ tests/core/api/test_samples.py | 47 ++++ tests/core/api/utils/apitest.py | 2 +- 9 files changed, 666 insertions(+), 30 deletions(-) diff --git a/pyispyb/app/extensions/database/definitions.py b/pyispyb/app/extensions/database/definitions.py index 74968661..39e09f49 100644 --- a/pyispyb/app/extensions/database/definitions.py +++ b/pyispyb/app/extensions/database/definitions.py @@ -9,7 +9,6 @@ from pyispyb.app.globals import g from pyispyb.app.extensions.database.middleware import db -from sqlalchemy.orm import joinedload logger = logging.getLogger(__name__) diff --git a/pyispyb/core/modules/samples.py b/pyispyb/core/modules/samples.py index 0d25c076..9f3033d6 100644 --- a/pyispyb/core/modules/samples.py +++ b/pyispyb/core/modules/samples.py @@ -1,12 +1,17 @@ import enum from typing import Optional +from fastapi import HTTPException from sqlalchemy.orm import contains_eager, aliased, joinedload from sqlalchemy.sql.expression import func, distinct, and_, literal_column from ispyb import models from ...config import settings -from ...app.extensions.database.definitions import with_authorization +from ...app.extensions.database.definitions import ( + authorize_for_proposal, + with_authorization, + with_authorization_proposal, +) from ...app.extensions.database.middleware import db from ...app.extensions.database.utils import ( Paged, @@ -117,7 +122,7 @@ def get_samples( models.AutoProcScalingHasInt.autoProcScalingId == models.AutoProcScalingStatistics.autoProcScalingId, ) - .join( + .outerjoin( models.Container, models.BLSample.containerId == models.Container.containerId, ) @@ -126,7 +131,7 @@ def get_samples( models.Container.code, ) ) - .join(models.Dewar, models.Container.dewarId == models.Dewar.dewarId) + .outerjoin(models.Dewar, models.Container.dewarId == models.Dewar.dewarId) .options( contains_eager( models.BLSample.Container, @@ -135,7 +140,9 @@ def get_samples( models.Dewar.code, ) ) - .join(models.Shipping, models.Dewar.shippingId == models.Shipping.shippingId) + .outerjoin( + models.Shipping, models.Dewar.shippingId == models.Shipping.shippingId + ) .options( contains_eager( models.BLSample.Container, models.Container.Dewar, models.Dewar.Shipping @@ -143,7 +150,7 @@ def get_samples( models.Shipping.shippingName, ) ) - .join(models.Proposal, models.Proposal.proposalId == models.Shipping.proposalId) + .join(models.Proposal, models.Proposal.proposalId == models.Protein.proposalId) .group_by(models.BLSample.blSampleId) ) @@ -238,24 +245,204 @@ def get_samples( return Paged(total=total, results=results, skip=skip, limit=limit) +def build_compositions( + composition_model, + compositions: list[schema.CompositionCreate | schema.Composition | None] | None, + proposal: models.Proposal, +): + res = [] + if compositions is None: + return res + for c in compositions: + if c is not None: + component: models.Component = None + # Try to find component in DB + if isinstance(c.Component, schema.Component): + component = with_authorization_proposal( + db.session.query(models.Component) + .filter(models.Component.componentId == c.Component.componentId) + .join(models.Proposal) + .filter(models.Proposal.proposalId == proposal.proposalId) + ).first() + if component is None: + raise HTTPException( + status_code=422, + detail=f"Could not find component with id {c.Component.componentId}", + ) + # If c.Component is ComponentCreate, try to find same component to avoid duplicate + else: + # Try to find component type in DB + component_type: models.ComponentType = ( + db.session.query(models.ComponentType) + .filter(models.ComponentType.name == c.Component.ComponentType.name) + .first() + ) + # If component_type found, try to find component + if component_type is not None: + component = ( + db.session.query(models.Component) + .filter(models.Component.name == c.Component.name) + .filter(models.Component.ComponentType == component_type) + .filter(models.Component.Proposal == proposal) + .first() + ) + # If no component type found, create + else: + component_type = models.ComponentType( + **c.Component.ComponentType.dict() + ) + + # If no component found, create + if component is None: + component = models.Component( + **{ + **c.Component.dict(), + "ComponentType": component_type, + "Proposal": proposal, + "componentId": None, + } + ) + + # find concentration type in DB + concentration_type = None + if c.ConcentrationType is not None: + concentration_type = ( + db.session.query(models.ConcentrationType) + .filter( + models.ConcentrationType.concentrationTypeId + == c.ConcentrationType.concentrationTypeId + ) + .first() + ) + if concentration_type is None: + raise HTTPException( + status_code=422, + detail=f"Could not find concentration_type with id {c.ConcentrationType.concentrationTypeId}", + ) + + # create final composition object + composition = composition_model( + **{ + **c.dict(), + "Component": component, + "ConcentrationType": concentration_type, + } + ) + res.append(composition) + return res + + +def build_crystal(sample: schema.SampleCreate | schema.SampleUpdate) -> models.Crystal: + crystal: models.Crystal = None + if isinstance(sample.Crystal, schema.SampleCrystalUpdate): + crystal = with_authorization_proposal( + db.session.query(models.Crystal) + .filter(models.Crystal.crystalId == sample.Crystal.crystalId) + .join(models.Protein) + .join(models.Proposal) + ).first() + if crystal is None: + raise HTTPException( + status_code=422, + detail=f"Could not find Crystal with id {sample.Crystal.crystalId}", + ) + update_model(crystal, sample.Crystal.dict(exclude_unset=True), nested=False) + else: + # Create new crystal + protein = with_authorization_proposal( + db.session.query(models.Protein) + .filter(models.Protein.proteinId == sample.Crystal.Protein.proteinId) + .join(models.Proposal) + ).first() + if protein is None: + raise HTTPException( + status_code=422, + detail=f"Could not find protein with id {sample.Crystal.Protein.proteinId}", + ) + crystal = models.Crystal( + **{**sample.Crystal.dict(), "Protein": protein, "crystal_compositions": []} + ) + + proposal = crystal.Protein.Proposal + + crystal.crystal_compositions = build_compositions( + models.CrystalComposition, sample.Crystal.crystal_compositions, proposal + ) + + return crystal + + def create_sample(sample: schema.SampleCreate) -> models.BLSample: sample_dict = sample.dict() - sample = models.BLSample(**sample_dict) - db.session.add(sample) + crystal = build_crystal(sample) + + proposal = crystal.Protein.Proposal + + authorize_for_proposal(proposal.proposalId) + + sample_compositions = build_compositions( + models.SampleComposition, sample.sample_compositions, proposal + ) + + new_sample = models.BLSample( + **{ + **sample_dict, + "Crystal": crystal, + "sample_compositions": sample_compositions, + } + ) + + db.session.add(new_sample) db.session.commit() - new_sample = get_samples(sampleId=sample.sampleId, skip=0, limit=1) + new_sample = get_samples(blSampleId=new_sample.blSampleId, skip=0, limit=1) return new_sample.first -def update_sample(sampleId: int, sample: schema.SampleCreate) -> models.BLSample: +def update_sample(blSampleId: int, sample: schema.SampleUpdate) -> models.BLSample: sample_dict = sample.dict(exclude_unset=True) - new_sample = get_samples(sampleId=sampleId, skip=0, limit=1).first + old_sample = get_samples(blSampleId=blSampleId, skip=0, limit=1).first + update_model(old_sample, sample_dict, nested=False) + + crystal = build_crystal(sample) + old_sample.Crystal = crystal + + proposal = crystal.Protein.Proposal + authorize_for_proposal(proposal.proposalId) + + old_sample.sample_compositions = build_compositions( + models.SampleComposition, sample.sample_compositions, proposal + ) - update_model(new_sample, sample_dict) db.session.commit() - return get_samples(sampleId=sampleId, skip=0, limit=1).first + return get_samples(blSampleId=sample.blSampleId, skip=0, limit=1).first + + +def delete_sample( + blSampleId: int, +) -> None: + sample = get_samples(blSampleId=blSampleId, skip=0, limit=1).first + if sample._metadata["datacollections"] > 0: + raise HTTPException( + status_code=409, + detail="Sample cannot be deleted because it is associated with data collections", + ) + + if sample._metadata["subsamples"] > 0: + raise HTTPException( + status_code=409, + detail="Sample cannot be deleted because it is associated with sub samples", + ) + + if sample._metadata["autoIntegrations"] > 0: + raise HTTPException( + status_code=409, + detail="Sample cannot be deleted because it is associated autoIntegrations", + ) + + db.session.delete(sample) + db.session.commit() SUBSAMPLE_ORDER_BY_MAP = { @@ -420,3 +607,43 @@ def get_sample_images( results = with_metadata(query.all(), list(metadata.keys())) return Paged(total=total, results=results, skip=skip, limit=limit) + + +def get_components( + skip: int, + limit: int, + proposal: Optional[str] = None, +) -> Paged[models.Component]: + + query = db.session.query(models.Component).join(models.Proposal) + + if proposal: + proposal_row = ( + db.session.query(models.Proposal) + .filter(models.Proposal.proposal == proposal) + .first() + ) + if proposal_row: + query = query.filter(models.Proposal.proposalId == proposal_row.proposalId) + + query = with_authorization(query) + + total = query.count() + query = page(query, skip=skip, limit=limit) + results = query.all() + + return Paged(total=total, results=results, skip=skip, limit=limit) + + +def get_component_types() -> list[models.ComponentType]: + + query = db.session.query(models.ComponentType) + results = query.all() + + return results + + +def get_concentration_types() -> list[models.ConcentrationType]: + query = db.session.query(models.ConcentrationType) + results = query.all() + return results diff --git a/pyispyb/core/routes/samples.py b/pyispyb/core/routes/samples.py index b770940a..eb56ca43 100644 --- a/pyispyb/core/routes/samples.py +++ b/pyispyb/core/routes/samples.py @@ -128,6 +128,30 @@ def get_samples( ) +@router.get("/components", response_model=paginated(schema.Component)) +def get_components( + page: dict[str, int] = Depends(pagination), + proposal: str = Depends(filters.proposal), +) -> Paged[models.BLSample]: + """Get a list of available components""" + return crud.get_components( + proposal=proposal, + **page, + ) + + +@router.get("/components/types", response_model=list[schema.ComponentType]) +def get_components_types() -> Paged[models.BLSample]: + """Get a list of available component types""" + return crud.get_component_types() + + +@router.get("/concentration/types", response_model=list[schema.ConcentrationType]) +def get_concentration_types() -> list[models.ConcentrationType]: + """Get a list of available concentration types""" + return crud.get_concentration_types() + + @router.get( "/{blSampleId}", response_model=schema.Sample, @@ -147,3 +171,46 @@ def get_sample( return samples.first except IndexError: raise HTTPException(status_code=404, detail="Sample not found") + + +@router.patch( + "/{blSampleId}", + response_model=schema.Sample, + responses={404: {"description": "No such sample"}}, +) +def update_sample( + sample: schema.SampleUpdate, + blSampleId: int = Depends(filters.blSampleId), +) -> models.BLSample: + """update a sample""" + try: + sample = crud.update_sample(blSampleId=blSampleId, sample=sample) + return sample + except IndexError: + raise HTTPException(status_code=404, detail="Sample not found") + + +@router.post( + "", + response_model=schema.Sample, +) +def create_sample( + sample: schema.SampleCreate, +) -> models.BLSample: + """create a sample""" + sample = crud.create_sample(sample=sample) + return sample + + +@router.delete( + "/{blSampleId}", + responses={404: {"description": "No such sample"}}, +) +def delete_sample( + blSampleId: int = Depends(filters.blSampleId), +) -> None: + """delete a sample""" + try: + crud.delete_sample(blSampleId=blSampleId) + except IndexError: + raise HTTPException(status_code=404, detail="Sample not found") diff --git a/pyispyb/core/schemas/crystal.py b/pyispyb/core/schemas/crystal.py index f7c018e6..20cbfc1d 100644 --- a/pyispyb/core/schemas/crystal.py +++ b/pyispyb/core/schemas/crystal.py @@ -40,6 +40,10 @@ class CrystalBase(BaseModel): cell_beta: Optional[float] = Field(title="Cell Beta", nullable=True) cell_gamma: Optional[float] = Field(title="Cell Gamma", nullable=True) Protein: Protein + size_X: Optional[float] + size_Y: Optional[float] + size_Z: Optional[float] + abundance: Optional[float] class Crystal(CrystalBase): diff --git a/pyispyb/core/schemas/samples.py b/pyispyb/core/schemas/samples.py index 32fa6043..c791bb97 100644 --- a/pyispyb/core/schemas/samples.py +++ b/pyispyb/core/schemas/samples.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field -from .crystal import Crystal +from pyispyb.core.schemas.utils import make_optional class Position(BaseModel): @@ -33,22 +33,74 @@ class SampleMetaData(BaseModel): ) proposal: Optional[str] = Field(description="The associated proposal") + class Config: + orm_mode = True -class SampleCrystalCreate(BaseModel): - proteinId: int +class ComponentTypeCreate(BaseModel): + name: str = Field(title="Name") -class SampleCreate(BaseModel): - name: str - comments: Optional[str] = Field(title="Comments", nullable=True) - location: Optional[int] = Field( - title="Location", description="Location in container" - ) - containerId: Optional[int] - Crystal: SampleCrystalCreate + class Config: + orm_mode = True + + +class ComponentType(ComponentTypeCreate): + componentTypeId: int = Field(title="id") + + class Config: + orm_mode = True + + +class ConcentrationType(BaseModel): + concentrationTypeId: int = Field(title="id") + name: str = Field(title="Name") + symbol: str = Field(title="Symbol") + + class Config: + orm_mode = True + + +class ComponentCreate(BaseModel): + name: str = Field(title="Name") + composition: Optional[str] = Field(title="Composition", nullable=True) + ComponentType: ComponentType | ComponentTypeCreate + + class Config: + orm_mode = True + + +class Component(ComponentCreate): + componentId: int = Field(name="id") + ComponentType: ComponentType + + class Config: + orm_mode = True + + +class CompositionCreate(BaseModel): + Component: Component | ComponentCreate + abundance: Optional[float] = Field(title="Abundance", nullable=True) + ratio: Optional[float] = Field(title="Ratio", nullable=True) + ph: Optional[float] = Field(title="pH", nullable=True) + ConcentrationType: Optional[ConcentrationType] + + class Config: + orm_mode = True -class SampleProtein(BaseModel): +class Composition(CompositionCreate): + class Config: + orm_mode = True + + +class SampleCrystalProteinCreate(BaseModel): + proteinId: int + + class Config: + orm_mode = True + + +class SampleCrystalProtein(SampleCrystalProteinCreate): proposalId: str name: str acronym: str @@ -57,8 +109,33 @@ class Config: orm_mode = True -class SampleCrystal(Crystal): - Protein: SampleProtein = Field(title="Protein") +class SampleCrystalCreate(BaseModel): + Protein: SampleCrystalProteinCreate + cell_a: Optional[int] + cell_b: Optional[int] + cell_c: Optional[int] + cell_alpha: Optional[int] + cell_beta: Optional[int] + cell_gamma: Optional[int] + size_X: Optional[int] + size_Y: Optional[int] + size_Z: Optional[int] + crystal_compositions: Optional[list[CompositionCreate | None]] + + class Config: + orm_mode = True + + +class SampleCrystal(SampleCrystalCreate): + crystalId: int = Field(name="id") + Protein: SampleCrystalProtein + crystal_compositions: Optional[list[Composition]] + + +class SampleCrystalUpdate(make_optional(SampleCrystal)): + crystalId: int = Field(name="id") + Protein: Optional[SampleCrystalProteinCreate] = Field(title="Protein") + crystal_compositions: Optional[list[CompositionCreate | None]] class SampleContainer(BaseModel): @@ -75,18 +152,44 @@ class Config: orm_mode = True +class SampleCreate(BaseModel): + name: str + + Crystal: SampleCrystalUpdate | SampleCrystalCreate + sample_compositions: Optional[list[CompositionCreate | None]] + + comments: Optional[str] = Field(title="Comments", nullable=True) + location: Optional[int] = Field( + title="Location", description="Location in container" + ) + containerId: Optional[int] + loopType: Optional[str] = Field(title="Sample support", nullable=True) + + class Sample(SampleCreate): blSampleId: int Crystal: SampleCrystal = Field(title="Crystal") - Container: Optional[SampleContainer] = Field(title="Container") + sample_compositions: Optional[list[Composition]] + Container: Optional[SampleContainer] = Field(title="Container") metadata: Optional[SampleMetaData] = Field(alias="_metadata") class Config: orm_mode = True +class SampleUpdate( + make_optional(Sample, exclude={"Container": True, "metadata": True}) +): + blSampleId: int + sample_compositions: Optional[list[CompositionCreate | None]] + Crystal: SampleCrystalUpdate | SampleCrystalCreate + + class Config: + orm_mode = True + + class SampleImageMetaData(BaseModel): url: str = Field(description="Url to sample image") diff --git a/requirements.txt b/requirements.txt index 750fe688..2b3fc173 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -ispyb-models==1.0.6 +ispyb-models==1.1.0 fastapi pydantic[dotenv] diff --git a/tests/core/api/data/samples.py b/tests/core/api/data/samples.py index fa6168f7..0a002f06 100644 --- a/tests/core/api/data/samples.py +++ b/tests/core/api/data/samples.py @@ -109,3 +109,192 @@ ), ), ] + +test_data_create_sample = [ + ApiTestElem( + name="Create minimal sample", + input=ApiTestInput( + route="/samples", + method="post", + payload={ + "name": "test sample", + "Crystal": { + "crystalId": 1, + }, + "sample_compositions": [], + "loopType": "Injector", + }, + ), + expected=ApiTestExpected( + code=200, + ), + ), + ApiTestElem( + name="Create sample with new crystal basic", + input=ApiTestInput( + route="/samples", + method="post", + payload={ + "name": "test sample", + "Crystal": { + "Protein": {"proteinId": 1}, + "crystal_compositions": [], + }, + "sample_compositions": [], + "loopType": "Injector", + }, + ), + expected=ApiTestExpected( + code=200, + ), + ), + ApiTestElem( + name="Create sample with new crystal with composition", + input=ApiTestInput( + route="/samples", + method="post", + payload={ + "name": "test sample", + "Crystal": { + "Protein": {"proteinId": 1}, + "crystal_compositions": [ + { + "Component": { + "name": "a", + "composition": "test", + "ComponentType": { + "name": "Buffer", + }, + }, + "abundance": "1", + } + ], + }, + "sample_compositions": [], + "loopType": "Injector", + }, + ), + expected=ApiTestExpected( + code=200, + ), + ), + ApiTestElem( + name="Create sample unknown crystal", + input=ApiTestInput( + route="/samples", + method="post", + payload={ + "name": "test sample", + "Crystal": { + "crystalId": -1, + }, + "sample_compositions": [], + "loopType": "Injector", + }, + ), + expected=ApiTestExpected( + code=422, + ), + ), + ApiTestElem( + name="Create sample unknown protein", + input=ApiTestInput( + route="/samples", + method="post", + payload={ + "name": "test sample", + "Crystal": { + "Protein": {"proteinId": -1}, + "crystal_compositions": [], + }, + "sample_compositions": [], + "loopType": "Injector", + }, + ), + expected=ApiTestExpected( + code=422, + ), + ), + ApiTestElem( + name="Create sample with composition", + input=ApiTestInput( + route="/samples", + method="post", + payload={ + "name": "test sample", + "Crystal": { + "crystalId": 1, + }, + "sample_compositions": [ + { + "Component": { + "name": "a", + "composition": "test", + "ComponentType": { + "name": "Buffer", + }, + }, + "abundance": "1", + } + ], + "loopType": "Injector", + }, + ), + expected=ApiTestExpected( + code=200, + ), + ), + ApiTestElem( + name="Create sample with unknown component", + input=ApiTestInput( + route="/samples", + method="post", + payload={ + "name": "test sample", + "Crystal": { + "crystalId": 1, + }, + "sample_compositions": [ + { + "Component": {"componentId": -1}, + "abundance": "1", + } + ], + "loopType": "Injector", + }, + ), + expected=ApiTestExpected( + code=422, + ), + ), +] + +test_data_components = [ + ApiTestElem( + name="List components", + input=ApiTestInput( + route="/samples/components", + ), + expected=ApiTestExpected( + code=200, + ), + ), + ApiTestElem( + name="List component types", + input=ApiTestInput( + route="/samples/components/types", + ), + expected=ApiTestExpected( + code=200, + ), + ), + ApiTestElem( + name="List component types", + input=ApiTestInput( + route="/samples/concentration/types", + ), + expected=ApiTestExpected( + code=200, + ), + ), +] diff --git a/tests/core/api/test_samples.py b/tests/core/api/test_samples.py index 6bf4099d..970dd35b 100644 --- a/tests/core/api/test_samples.py +++ b/tests/core/api/test_samples.py @@ -9,6 +9,8 @@ test_data_samples_list, test_data_sampleimages_list, test_data_subsamples_list, + test_data_create_sample, + test_data_components, ) @@ -25,3 +27,48 @@ def test_sample_images(auth_client: AuthClient, test_elem: ApiTestElem, app: ASG @pytest.mark.parametrize("test_elem", test_data_subsamples_list, ids=get_elem_name) def test_subsamples_list(auth_client: AuthClient, test_elem: ApiTestElem, app: ASGIApp): run_test(auth_client, test_elem, app) + + +@pytest.mark.parametrize("test_elem", test_data_create_sample, ids=get_elem_name) +def test_sample_create(auth_client: AuthClient, test_elem: ApiTestElem, app: ASGIApp): + run_test(auth_client, test_elem, app) + + +@pytest.mark.parametrize("test_elem", test_data_components, ids=get_elem_name) +def test_components(auth_client: AuthClient, test_elem: ApiTestElem, app: ASGIApp): + run_test(auth_client, test_elem, app) + + +def test_sample_create_delete(auth_client: AuthClient): + auth_client.login("abcd", "password") + + sample_response = auth_client.post( + "/samples", + payload={ + "name": "test sample", + "Crystal": { + "crystalId": 1, + }, + "sample_compositions": [], + "loopType": "Injector", + }, + ) + assert sample_response.status_code == 200 + + assert "blSampleId" in sample_response.json() + + sampleId = sample_response.json()["blSampleId"] + + get_response = auth_client.get(f"/samples/{sampleId}") + assert get_response.status_code == 200 + + assert "blSampleId" in get_response.json() + assert get_response.json()["blSampleId"] == sampleId + + delete_response = auth_client.delete( + f"/samples/{sampleId}", + ) + assert delete_response.status_code == 200 + + get_response = auth_client.get(f"/samples/{sampleId}") + assert get_response.status_code == 404 diff --git a/tests/core/api/utils/apitest.py b/tests/core/api/utils/apitest.py index 49c049c5..ea750360 100644 --- a/tests/core/api/utils/apitest.py +++ b/tests/core/api/utils/apitest.py @@ -12,7 +12,7 @@ class ApiTestInput: def __init__( self, *, - login: str, + login: str = "abcd", route: str, permissions: list[str] = [], method: str = "get",