From 0c9b4e4505f714d48585961c6e88f503f441ff6c Mon Sep 17 00:00:00 2001 From: Sebastian Allard Date: Fri, 30 Aug 2024 11:14:31 +0200 Subject: [PATCH] Add balsamic sample sex validation (#3665) --- .../errors/case_sample_errors.py | 5 ++ .../balsamic/rules/case_sample/rules.py | 30 ++++++++++ .../balsamic/rules/case_sample/utils.py | 8 +++ .../workflows/balsamic/validation_rules.py | 5 ++ cg/store/crud/read.py | 36 +++++++++--- .../workflows/__init__.py | 0 .../workflows/balsamic/__init__.py | 0 .../workflows/balsamic/conftest.py | 55 +++++++++++++++++++ .../balsamic/test_case_sample_rules.py | 32 +++++++++++ 9 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 cg/services/order_validation_service/workflows/balsamic/rules/case_sample/rules.py create mode 100644 cg/services/order_validation_service/workflows/balsamic/rules/case_sample/utils.py create mode 100644 tests/services/order_validation_service/workflows/__init__.py create mode 100644 tests/services/order_validation_service/workflows/balsamic/__init__.py create mode 100644 tests/services/order_validation_service/workflows/balsamic/conftest.py create mode 100644 tests/services/order_validation_service/workflows/balsamic/test_case_sample_rules.py diff --git a/cg/services/order_validation_service/errors/case_sample_errors.py b/cg/services/order_validation_service/errors/case_sample_errors.py index 7d04d8857d..afd886bdbb 100644 --- a/cg/services/order_validation_service/errors/case_sample_errors.py +++ b/cg/services/order_validation_service/errors/case_sample_errors.py @@ -133,3 +133,8 @@ class InvalidVolumeError(CaseSampleError): class InvalidBufferError(CaseSampleError): field: str = "elution_buffer" message: str = "The chosen buffer is not allowed when skipping reception control" + + +class SexSubjectIdError(CaseSampleError): + field: str = "sex" + message: str = "Another sample with the same subject id has a different sex" diff --git a/cg/services/order_validation_service/workflows/balsamic/rules/case_sample/rules.py b/cg/services/order_validation_service/workflows/balsamic/rules/case_sample/rules.py new file mode 100644 index 0000000000..58cf5f0561 --- /dev/null +++ b/cg/services/order_validation_service/workflows/balsamic/rules/case_sample/rules.py @@ -0,0 +1,30 @@ +from cg.services.order_validation_service.errors.case_sample_errors import SexSubjectIdError +from cg.services.order_validation_service.workflows.balsamic.models.order import BalsamicOrder +from cg.services.order_validation_service.workflows.balsamic.rules.case_sample.utils import ( + has_sex_and_subject, +) +from cg.store.store import Store + + +def validate_subject_sex_consistency( + order: BalsamicOrder, + store: Store, +) -> list[SexSubjectIdError]: + errors: list[SexSubjectIdError] = [] + + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if not has_sex_and_subject(sample): + continue + + if store.sample_exists_with_different_sex( + customer_internal_id=order.customer, + subject_id=sample.subject_id, + sex=sample.sex, + ): + error = SexSubjectIdError( + case_index=case_index, + sample_index=sample_index, + ) + errors.append(error) + return errors diff --git a/cg/services/order_validation_service/workflows/balsamic/rules/case_sample/utils.py b/cg/services/order_validation_service/workflows/balsamic/rules/case_sample/utils.py new file mode 100644 index 0000000000..b0b6f450a2 --- /dev/null +++ b/cg/services/order_validation_service/workflows/balsamic/rules/case_sample/utils.py @@ -0,0 +1,8 @@ +from cg.models.orders.sample_base import SexEnum +from cg.services.order_validation_service.workflows.balsamic.models.sample import ( + BalsamicSample, +) + + +def has_sex_and_subject(sample: BalsamicSample) -> bool: + return bool(sample.subject_id and sample.sex != SexEnum.unknown) diff --git a/cg/services/order_validation_service/workflows/balsamic/validation_rules.py b/cg/services/order_validation_service/workflows/balsamic/validation_rules.py index b96906073a..7a75eaf779 100644 --- a/cg/services/order_validation_service/workflows/balsamic/validation_rules.py +++ b/cg/services/order_validation_service/workflows/balsamic/validation_rules.py @@ -20,6 +20,10 @@ validate_volume_interval, validate_wells_contain_at_most_one_sample, ) +from cg.services.order_validation_service.workflows.balsamic.rules.case_sample.rules import ( + validate_subject_sex_consistency, +) + CASE_RULES: list[callable] = [ validate_case_internal_ids_exist, @@ -43,4 +47,5 @@ validate_subject_ids_different_from_sample_names, validate_volume_interval, validate_wells_contain_at_most_one_sample, + validate_subject_sex_consistency, ] diff --git a/cg/store/crud/read.py b/cg/store/crud/read.py index ef3be2b4ac..250783f387 100644 --- a/cg/store/crud/read.py +++ b/cg/store/crud/read.py @@ -10,9 +10,13 @@ from cg.constants import SequencingRunDataAvailability, Workflow from cg.constants.constants import CaseActions, CustomerId, PrepCategory, SampleType from cg.exc import CaseNotFoundError, CgError, OrderNotFoundError, SampleNotFoundError +from cg.models.orders.sample_base import SexEnum from cg.server.dto.orders.orders_request import OrdersRequest -from cg.server.dto.samples.collaborator_samples_request import CollaboratorSamplesRequest +from cg.server.dto.samples.collaborator_samples_request import ( + CollaboratorSamplesRequest, +) from cg.store.base import BaseHandler +from cg.store.exc import EntryNotFoundError from cg.store.filters.status_analysis_filters import ( AnalysisFilter, apply_analysis_filter, @@ -21,9 +25,6 @@ ApplicationFilter, apply_application_filter, ) -from cg.store.exc import EntryNotFoundError -from cg.store.filters.status_analysis_filters import AnalysisFilter, apply_analysis_filter -from cg.store.filters.status_application_filters import ApplicationFilter, apply_application_filter from cg.store.filters.status_application_limitations_filters import ( ApplicationLimitationsFilter, apply_application_limitations_filter, @@ -64,11 +65,13 @@ ) from cg.store.filters.status_invoice_filters import InvoiceFilter, apply_invoice_filter from cg.store.filters.status_order_filters import OrderFilter, apply_order_filters - -from cg.store.filters.status_organism_filters import OrganismFilter, apply_organism_filter +from cg.store.filters.status_organism_filters import ( + OrganismFilter, + apply_organism_filter, +) from cg.store.filters.status_pacbio_smrt_cell_filters import ( - apply_pac_bio_smrt_cell_filters, PacBioSMRTCellFilter, + apply_pac_bio_smrt_cell_filters, ) from cg.store.filters.status_panel_filters import PanelFilter, apply_panel_filter from cg.store.filters.status_pool_filters import PoolFilter, apply_pool_filter @@ -91,12 +94,12 @@ Invoice, Order, Organism, + PacBioSMRTCell, Panel, Pool, Sample, SampleRunMetrics, User, - PacBioSMRTCell, ) LOG = logging.getLogger(__name__) @@ -1562,3 +1565,20 @@ def get_case_ids_for_samples(self, sample_ids: list[int]) -> list[str]: for sample_id in sample_ids: case_ids.extend(self.get_case_ids_with_sample(sample_id)) return list(set(case_ids)) + + def sample_exists_with_different_sex( + self, + customer_internal_id: str, + subject_id: str, + sex: SexEnum, + ) -> bool: + samples: list[Sample] = self.get_samples_by_customer_and_subject_id( + customer_internal_id=customer_internal_id, + subject_id=subject_id, + ) + for sample in samples: + if sample.sex == SexEnum.unknown: + continue + if sample.sex != sex: + return True + return False diff --git a/tests/services/order_validation_service/workflows/__init__.py b/tests/services/order_validation_service/workflows/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/order_validation_service/workflows/balsamic/__init__.py b/tests/services/order_validation_service/workflows/balsamic/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/order_validation_service/workflows/balsamic/conftest.py b/tests/services/order_validation_service/workflows/balsamic/conftest.py new file mode 100644 index 0000000000..d771e37d07 --- /dev/null +++ b/tests/services/order_validation_service/workflows/balsamic/conftest.py @@ -0,0 +1,55 @@ +import pytest + +from cg.constants.constants import GenomeVersion, Workflow +from cg.models.orders.sample_base import ContainerEnum, ControlEnum, SexEnum, StatusEnum +from cg.services.order_validation_service.constants import MINIMUM_VOLUME +from cg.services.order_validation_service.workflows.balsamic.constants import BalsamicDeliveryType +from cg.services.order_validation_service.workflows.balsamic.models.case import BalsamicCase +from cg.services.order_validation_service.workflows.balsamic.models.order import BalsamicOrder +from cg.services.order_validation_service.workflows.balsamic.models.sample import BalsamicSample + + +def create_sample(id: int) -> BalsamicSample: + return BalsamicSample( + name=f"name{id}", + application="PANKTTR020", + container=ContainerEnum.plate, + container_name="ContainerName", + control=ControlEnum.not_control, + require_qc_ok=True, + reference_genome=GenomeVersion.HG19, + sex=SexEnum.female, + source="source", + status=StatusEnum.affected, + subject_id=f"subject{id}", + well_position=f"A:{id}", + volume=MINIMUM_VOLUME, + is_tumour=False, + ) + + +def create_case(samples: list[BalsamicSample]) -> BalsamicCase: + return BalsamicCase( + name="name", + samples=samples, + ) + + +def create_order(cases: list[BalsamicCase]) -> BalsamicOrder: + return BalsamicOrder( + connect_to_ticket=True, + delivery_type=BalsamicDeliveryType.FASTQ_ANALYSIS, + name="order_name", + ticket_number="#12345", + workflow=Workflow.BALSAMIC, + user_id=1, + customer="cust000", + cases=cases, + ) + + +@pytest.fixture +def valid_order() -> BalsamicOrder: + sample = create_sample(1) + case = create_case([sample]) + return create_order([case]) diff --git a/tests/services/order_validation_service/workflows/balsamic/test_case_sample_rules.py b/tests/services/order_validation_service/workflows/balsamic/test_case_sample_rules.py new file mode 100644 index 0000000000..4cb4e8e89f --- /dev/null +++ b/tests/services/order_validation_service/workflows/balsamic/test_case_sample_rules.py @@ -0,0 +1,32 @@ +from cg.models.orders.sample_base import SexEnum +from cg.services.order_validation_service.errors.case_sample_errors import SexSubjectIdError +from cg.services.order_validation_service.workflows.balsamic.models.order import BalsamicOrder +from cg.services.order_validation_service.workflows.balsamic.rules.case_sample.rules import ( + validate_subject_sex_consistency, +) +from cg.store.models import Sample +from cg.store.store import Store + + +def test_validate_sex_subject_id_clash(valid_order: BalsamicOrder, sample_store: Store): + # GIVEN an existing sample + sample = sample_store.session.query(Sample).first() + + # GIVEN an order and sample with the same customer and subject id + valid_order.customer = sample.customer.internal_id + valid_order.cases[0].samples[0].subject_id = "subject" + sample.subject_id = "subject" + + # GIVEN a sample in the order that has a different sex + valid_order.cases[0].samples[0].sex = SexEnum.female + sample.sex = SexEnum.male + + # WHEN validating the order + errors = validate_subject_sex_consistency( + order=valid_order, + store=sample_store, + ) + + # THEN an error should be given for the clash + assert errors + assert isinstance(errors[0], SexSubjectIdError)