diff --git a/cg/apps/orderform/excel_orderform_parser.py b/cg/apps/orderform/excel_orderform_parser.py index 796150d6c4..835701ac02 100644 --- a/cg/apps/orderform/excel_orderform_parser.py +++ b/cg/apps/orderform/excel_orderform_parser.py @@ -12,8 +12,8 @@ from cg.constants import DataDelivery from cg.constants.orderforms import Orderform from cg.exc import OrderFormError +from cg.models.orders.constants import OrderType from cg.models.orders.excel_sample import ExcelSample -from cg.models.orders.order import OrderType LOG = logging.getLogger(__name__) diff --git a/cg/apps/orderform/json_orderform_parser.py b/cg/apps/orderform/json_orderform_parser.py index 27c316fbc4..aadbb1ecb4 100644 --- a/cg/apps/orderform/json_orderform_parser.py +++ b/cg/apps/orderform/json_orderform_parser.py @@ -1,8 +1,8 @@ from cg.apps.orderform.orderform_parser import OrderformParser from cg.constants import DataDelivery, Workflow from cg.exc import OrderFormError +from cg.models.orders.constants import OrderType from cg.models.orders.json_sample import JsonSample -from cg.models.orders.order import OrderType class JsonOrderformParser(OrderformParser): diff --git a/cg/apps/orderform/orderform_parser.py b/cg/apps/orderform/orderform_parser.py index 48e52bd6de..fa0a189655 100644 --- a/cg/apps/orderform/orderform_parser.py +++ b/cg/apps/orderform/orderform_parser.py @@ -7,7 +7,7 @@ from cg.apps.orderform.utils import ORDER_TYPES_WITH_CASES from cg.constants import DataDelivery from cg.exc import OrderFormError -from cg.models.orders.order import OrderType +from cg.models.orders.constants import OrderType from cg.models.orders.orderform_schema import OrderCase, Orderform, OrderPool from cg.models.orders.sample_base import OrderSample from cg.store.models import Customer diff --git a/cg/constants/orderforms.py b/cg/constants/orderforms.py index b02d088c3b..fdd70bcd25 100644 --- a/cg/constants/orderforms.py +++ b/cg/constants/orderforms.py @@ -1,19 +1,11 @@ from enum import StrEnum from cg.constants import ANALYSIS_SOURCES, METAGENOME_SOURCES -from cg.models.orders.order import OrderType SEX_MAP = {"male": "M", "female": "F", "unknown": "unknown"} REV_SEX_MAP = {value: key for key, value in SEX_MAP.items()} -CONTAINER_TYPES = ["Tube", "96 well plate"] SOURCE_TYPES = set().union(METAGENOME_SOURCES, ANALYSIS_SOURCES) -CASE_PROJECT_TYPES = [ - OrderType.MIP_DNA, - OrderType.BALSAMIC, - OrderType.MIP_RNA, -] - class Orderform(StrEnum): BALSAMIC: str = "1508" @@ -79,4 +71,5 @@ def get_current_orderform_version(order_form: str) -> str: "LaboratorieMedicinskt Centrum Gotland": "621 84 Visby", "Unilabs Eskilstuna Laboratorium": "631 88 Eskilstuna", "Norrland University Hospital": "901 85 Umeå", + "Länssjukhuset Sundsvall": "856 43 Sundsvall", } diff --git a/cg/exc.py b/cg/exc.py index 4f5d3b7cbc..0c3fab12d9 100644 --- a/cg/exc.py +++ b/cg/exc.py @@ -174,6 +174,12 @@ class OrderError(CgError): """ +class OrderSubmissionError(CgError): + """ + Exception related to order submission. + """ + + class OrderFormError(CgError): """ Exception related to the order form. diff --git a/cg/meta/orders/__init__.py b/cg/meta/orders/__init__.py deleted file mode 100644 index 4142bdd47b..0000000000 --- a/cg/meta/orders/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .api import OrdersAPI diff --git a/cg/meta/orders/api.py b/cg/meta/orders/api.py deleted file mode 100644 index d62d063119..0000000000 --- a/cg/meta/orders/api.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Unified interface to handle sample submissions. - -This interface will update information in Status and/or LIMS as required. - -The normal entry for information is through the REST API which will pass a JSON -document with all information about samples in the submission. The input will -be validated and if passing all checks be accepted as new samples. -""" - -import logging - -from cg.apps.lims import LimsAPI -from cg.meta.orders.ticket_handler import TicketHandler -from cg.models.orders.order import OrderIn, OrderType -from cg.services.orders.submitters.order_submitter_registry import OrderSubmitterRegistry -from cg.store.store import Store - -LOG = logging.getLogger(__name__) - - -class OrdersAPI: - """Orders API for accepting new samples into the system.""" - - def __init__( - self, - lims: LimsAPI, - status: Store, - ticket_handler: TicketHandler, - submitter_registry: OrderSubmitterRegistry, - ): - super().__init__() - self.lims = lims - self.status = status - self.ticket_handler = ticket_handler - self.submitter_registry = submitter_registry - - def submit(self, project: OrderType, order_in: OrderIn, user_name: str, user_mail: str) -> dict: - """Submit a batch of samples. - - Main entry point for the class towards interfaces that implements it. - """ - submit_handler = self.submitter_registry.get_order_submitter(project) - submit_handler.order_validation_service.validate_order(order_in) - ticket_number = self.ticket_handler.create_ticket( - order=order_in, user_name=user_name, user_mail=user_mail, project=project - ) - order_in.ticket = ticket_number - return submit_handler.submit_order(order_in=order_in) diff --git a/cg/models/orders/order.py b/cg/models/orders/order.py deleted file mode 100644 index 5674e72c37..0000000000 --- a/cg/models/orders/order.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import Any - -from pydantic.v1 import BaseModel, conlist, constr - -from cg.models.orders.constants import OrderType -from cg.models.orders.samples import sample_class_for -from cg.store.models import Customer, Sample - - -class OrderIn(BaseModel): - name: constr(min_length=2, max_length=Sample.order.property.columns[0].type.length) - comment: str | None - customer: constr(min_length=1, max_length=Customer.internal_id.property.columns[0].type.length) - samples: conlist(Any, min_items=1) - skip_reception_control: bool | None = None - ticket: str | None - order_type: OrderType | None = None - - @classmethod - def parse_obj(cls, obj: dict, project: OrderType) -> "OrderIn": - parsed_obj: OrderIn = super().parse_obj(obj) - parsed_obj.parse_samples(project=project) - parsed_obj.order_type = project - return parsed_obj - - def parse_samples(self, project: OrderType) -> None: - """ - Parses samples of by the type given by the project - - Parameters: - project (OrderType): type of project - - Returns: - Nothing - """ - parsed_samples = [] - - sample: dict - for sample in self.samples: - parsed_sample = sample_class_for(project=project).parse_obj(sample) - parsed_sample.skip_reception_control = self.skip_reception_control - parsed_samples.append(parsed_sample) - self.samples = parsed_samples diff --git a/cg/models/orders/samples.py b/cg/models/orders/samples.py deleted file mode 100644 index 66c4d620d8..0000000000 --- a/cg/models/orders/samples.py +++ /dev/null @@ -1,350 +0,0 @@ -from pydantic.v1 import BaseModel, constr, validator - -from cg.constants import DataDelivery -from cg.constants.constants import GenomeVersion, Workflow -from cg.constants.orderforms import ORIGINAL_LAB_ADDRESSES, REGION_CODES -from cg.models.orders.constants import OrderType -from cg.models.orders.sample_base import ( - NAME_PATTERN, - ContainerEnum, - ControlEnum, - PriorityEnum, - SexEnum, - StatusEnum, -) -from cg.store.models import Application, Case, Organism, Panel, Pool, Sample - - -class OptionalIntValidator: - @classmethod - def str_to_int(cls, v: str) -> int | None: - return int(v) if v else None - - -class OptionalFloatValidator: - @classmethod - def str_to_float(cls, v: str) -> float | None: - return float(v) if v else None - - -class OrderInSample(BaseModel): - # Order portal specific - internal_id: constr(max_length=Sample.internal_id.property.columns[0].type.length) | None - _suitable_project: OrderType = None - application: constr(max_length=Application.tag.property.columns[0].type.length) - comment: constr(max_length=Sample.comment.property.columns[0].type.length) | None - skip_reception_control: bool | None = None - data_analysis: Workflow - data_delivery: DataDelivery - name: constr( - regex=NAME_PATTERN, - min_length=2, - max_length=Sample.name.property.columns[0].type.length, - ) - priority: PriorityEnum = PriorityEnum.standard - require_qc_ok: bool = False - volume: str - concentration_ng_ul: str | None - - @classmethod - def is_sample_for(cls, project: OrderType): - return project == cls._suitable_project - - -class Of1508Sample(OrderInSample): - # Orderform 1508 - # Order portal specific - internal_id: constr(max_length=Sample.internal_id.property.columns[0].type.length) | None - # "required for new samples" - name: ( - constr( - regex=NAME_PATTERN, - min_length=2, - max_length=Sample.name.property.columns[0].type.length, - ) - | None - ) - - # customer - age_at_sampling: float | None - family_name: constr( - regex=NAME_PATTERN, - min_length=2, - max_length=Case.name.property.columns[0].type.length, - ) - case_internal_id: constr(max_length=Sample.internal_id.property.columns[0].type.length) | None - sex: SexEnum = SexEnum.unknown - tumour: bool = False - source: str | None - control: ControlEnum | None - volume: str | None - container: ContainerEnum | None - # "required if plate for new samples" - container_name: str | None - well_position: str | None - # "Required if samples are part of trio/family" - mother: ( - constr(regex=NAME_PATTERN, max_length=Sample.name.property.columns[0].type.length) | None - ) - father: ( - constr(regex=NAME_PATTERN, max_length=Sample.name.property.columns[0].type.length) | None - ) - # This information is required for panel analysis - capture_kit: str | None - # This information is required for panel- or exome analysis - elution_buffer: str | None - tumour_purity: int | None - # "This information is optional for FFPE-samples for new samples" - formalin_fixation_time: int | None - post_formalin_fixation_time: int | None - tissue_block_size: str | None - # "Not Required" - cohorts: list[str] | None - phenotype_groups: list[str] | None - phenotype_terms: list[str] | None - require_qc_ok: bool = False - quantity: int | None - subject_id: ( - constr(regex=NAME_PATTERN, max_length=Sample.subject_id.property.columns[0].type.length) - | None - ) - synopsis: str | None - - @validator("container", "container_name", "name", "source", "subject_id", "volume") - def required_for_new_samples(cls, value, values, **kwargs): - if not value and not values.get("internal_id"): - raise ValueError(f"required for new sample {values.get('name')}") - return value - - @validator( - "tumour_purity", - "formalin_fixation_time", - "post_formalin_fixation_time", - "quantity", - pre=True, - ) - def str_to_int(cls, v: str) -> int | None: - return OptionalIntValidator.str_to_int(v=v) - - @validator( - "age_at_sampling", - "volume", - pre=True, - ) - def str_to_float(cls, v: str) -> float | None: - return OptionalFloatValidator.str_to_float(v=v) - - -class MipDnaSample(Of1508Sample): - _suitable_project = OrderType.MIP_DNA - # "Required if data analysis in Scout or vcf delivery" - panels: list[constr(min_length=1, max_length=Panel.abbrev.property.columns[0].type.length)] - status: StatusEnum - - -class BalsamicSample(Of1508Sample): - _suitable_project = OrderType.BALSAMIC - - -class BalsamicQCSample(Of1508Sample): - _suitable_project = OrderType.BALSAMIC_QC - reference_genome: GenomeVersion | None - - -class BalsamicUmiSample(Of1508Sample): - _suitable_project = OrderType.BALSAMIC_UMI - - -class MipRnaSample(Of1508Sample): - _suitable_project = OrderType.MIP_RNA - - -class RnafusionSample(Of1508Sample): - _suitable_project = OrderType.RNAFUSION - - -class TomteSample(MipDnaSample): - _suitable_project = OrderType.TOMTE - reference_genome: GenomeVersion | None - - -class FastqSample(OrderInSample): - _suitable_project = OrderType.FASTQ - - # Orderform 1508 - # "required" - container: ContainerEnum | None - sex: SexEnum = SexEnum.unknown - source: str - tumour: bool - # "required if plate" - container_name: str | None - well_position: str | None - elution_buffer: str - # This information is required for panel analysis - capture_kit: str | None - # "Not Required" - quantity: int | None - subject_id: str | None - - @validator("quantity", pre=True) - def str_to_int(cls, v: str) -> int | None: - return OptionalIntValidator.str_to_int(v=v) - - -class PacBioSample(OrderInSample): - _suitable_project = OrderType.PACBIO_LONG_READ - - container: ContainerEnum - container_name: str | None = None - sex: SexEnum = SexEnum.unknown - source: str - subject_id: str - tumour: bool - well_position: str | None = None - - -class RmlSample(OrderInSample): - _suitable_project = OrderType.RML - - # 1604 Orderform Ready made libraries (RML) - # Order portal specific - # "This information is required" - pool: constr(max_length=Pool.name.property.columns[0].type.length) - concentration: float - concentration_sample: float | None - index: str - index_number: str | None - # "Required if Plate" - rml_plate_name: str | None - well_position_rml: str | None - # "Automatically generated (if not custom) or custom" - index_sequence: str | None - # "Not required" - control: str | None - - @validator("concentration_sample", pre=True) - def str_to_float(cls, v: str) -> float | None: - return OptionalFloatValidator.str_to_float(v=v) - - -class FluffySample(RmlSample): - _suitable_project = OrderType.FLUFFY - # 1604 Orderform Ready made libraries (RML) - - -class MetagenomeSample(Of1508Sample): - _suitable_project = OrderType.METAGENOME - # "This information is required" - source: str - # "This information is not required" - concentration_sample: float | None - family_name: None = None - subject_id: None = None - - @validator("concentration_sample", pre=True) - def str_to_float(cls, v: str) -> float | None: - return OptionalFloatValidator.str_to_float(v=v) - - @validator("subject_id", pre=True) - def required_for_new_samples(cls, v: str) -> None: - """Overrides the parent validator since subject_id is optional for these samples.""" - return None - - -class TaxprofilerSample(MetagenomeSample): - _suitable_project = OrderType.TAXPROFILER - - -class MicrobialSample(OrderInSample): - # 1603 Orderform Microbial WHOLE_GENOME_SEQUENCING - # "These fields are required" - organism: constr(max_length=Organism.internal_id.property.columns[0].type.length) - reference_genome: constr(max_length=Sample.reference_genome.property.columns[0].type.length) - elution_buffer: str - extraction_method: str | None - container: ContainerEnum - # "Required if Plate" - container_name: str | None - well_position: str | None - # "Required if "Other" is chosen in column "Species"" - organism_other: constr(max_length=Organism.internal_id.property.columns[0].type.length) | None - verified_organism: bool | None # sent to LIMS - control: str | None - - -class MicrobialFastqSample(OrderInSample): - _suitable_project = OrderType.MICROBIAL_FASTQ - - elution_buffer: str - container: ContainerEnum - # "Required if Plate" - container_name: str | None - well_position: str | None - # "These fields are not required" - control: str | None - - -class MicrosaltSample(MicrobialSample): - _suitable_project = OrderType.MICROSALT - # 1603 Orderform Microbial WHOLE_GENOME_SEQUENCING - - -class SarsCov2Sample(MicrobialSample): - _suitable_project = OrderType.SARS_COV_2 - - # 2184 Orderform SARS-COV-2 - # "These fields are required" - collection_date: str - lab_code: str = None - primer: str - original_lab: str - original_lab_address: str = None - pre_processing_method: str - region: str - region_code: str = None - selection_criteria: str - volume: str | None - - @validator("lab_code", pre=True, always=True) - def set_lab_code(cls, value): - return "SE100 Karolinska" - - @validator("region_code", pre=True, always=True) - def set_region_code(cls, value, values): - return value if value else REGION_CODES[values["region"]] - - @validator("original_lab_address", pre=True, always=True) - def set_original_lab_address(cls, value, values): - return value if value else ORIGINAL_LAB_ADDRESSES[values["original_lab"]] - - -def sample_class_for(project: OrderType): - """Get the sample class for the specified project - - Args: - project (OrderType): Project to get sample subclass for - Returns: - Subclass of OrderInSample - """ - - def all_subclasses(cls): - """Get all subclasses recursively for a class - - Args: - cls (Class): Class to get all subclasses for - Returns: - Set of Subclasses of cls - """ - if cls.__subclasses__(): - return set(cls.__subclasses__()).union( - [s for c in cls.__subclasses__() for s in all_subclasses(c)] - ) - - return [] - - for sub_cls in all_subclasses(OrderInSample): - if sub_cls.is_sample_for(project): - return sub_cls - - raise ValueError diff --git a/cg/server/endpoints/orders.py b/cg/server/endpoints/orders.py index 4d45485872..59afcaad76 100644 --- a/cg/server/endpoints/orders.py +++ b/cg/server/endpoints/orders.py @@ -23,8 +23,7 @@ TicketCreationError, ) from cg.io.controller import WriteStream -from cg.meta.orders import OrdersAPI -from cg.models.orders.order import OrderIn, OrderType +from cg.models.orders.constants import OrderType from cg.models.orders.orderform_schema import Orderform from cg.server.dto.delivery_message.delivery_message_response import DeliveryMessageResponse from cg.server.dto.orders.order_delivery_update_request import OrderOpenUpdateRequest @@ -35,11 +34,12 @@ from cg.server.ext import ( db, delivery_message_service, - lims, order_service, - order_submitter_registry, + order_validation_service, + storing_service_registry, ticket_handler, ) +from cg.services.orders.submitter.service import OrderSubmitter from cg.store.models import Application, Customer ORDERS_BLUEPRINT = Blueprint("orders", __name__, url_prefix="/api/v1") @@ -148,13 +148,12 @@ def create_order_from_form(): @ORDERS_BLUEPRINT.route("/submit_order/", methods=["POST"]) -def submit_order(order_type): +def submit_order(order_type: OrderType): """Submit an order for samples.""" - api = OrdersAPI( - lims=lims, - status=db, + submitter = OrderSubmitter( ticket_handler=ticket_handler, - submitter_registry=order_submitter_registry, + storing_registry=storing_service_registry, + validation_service=order_validation_service, ) error_message: str try: @@ -165,14 +164,11 @@ def submit_order(order_type): content=request_json, file_format=FileFormat.JSON ), ) - project = OrderType(order_type) - order_in = OrderIn.parse_obj(request_json, project=project) - - result: dict = api.submit( - project=project, - order_in=order_in, - user_name=g.current_user.name, - user_mail=g.current_user.email, + + result: dict = submitter.submit( + raw_order=request_json, + order_type=order_type, + user=g.current_user, ) except ( # user misbehaviour @@ -252,3 +248,12 @@ def get_options(): panels=[panel.abbrev for panel in db.get_panels()], sources=source_groups, ) + + +@ORDERS_BLUEPRINT.route("/validate_order/", methods=["POST"]) +def validate_order(order_type: OrderType): + raw_order = request.get_json() + response = order_validation_service.get_validation_response( + raw_order=raw_order, order_type=order_type, user_id=g.current_user.id + ) + return jsonify(response), HTTPStatus.OK diff --git a/cg/server/endpoints/samples.py b/cg/server/endpoints/samples.py index 007a345782..4ee0d8d5f8 100644 --- a/cg/server/endpoints/samples.py +++ b/cg/server/endpoints/samples.py @@ -2,9 +2,7 @@ from flask import Blueprint, abort, g, jsonify, request -from cg.server.dto.samples.collaborator_samples_request import ( - CollaboratorSamplesRequest, -) +from cg.server.dto.samples.collaborator_samples_request import CollaboratorSamplesRequest from cg.server.dto.samples.samples_response import SamplesResponse from cg.server.endpoints.utils import before_request from cg.server.ext import db, sample_service diff --git a/cg/server/ext.py b/cg/server/ext.py index a69f9ec573..a1f0d97cec 100644 --- a/cg/server/ext.py +++ b/cg/server/ext.py @@ -8,16 +8,17 @@ from cg.apps.lims import LimsAPI from cg.apps.tb.api import TrailblazerAPI from cg.clients.freshdesk.freshdesk_client import FreshdeskClient -from cg.meta.orders.ticket_handler import TicketHandler from cg.server.app_config import app_config from cg.services.application.service import ApplicationsWebService from cg.services.delivery_message.delivery_message_service import DeliveryMessageService from cg.services.orders.order_service.order_service import OrderService from cg.services.orders.order_summary_service.order_summary_service import OrderSummaryService -from cg.services.orders.submitters.order_submitter_registry import ( - OrderSubmitterRegistry, - setup_order_submitter_registry, +from cg.services.orders.storing.service_registry import ( + StoringServiceRegistry, + setup_storing_service_registry, ) +from cg.services.orders.submitter.ticket_handler import TicketHandler +from cg.services.orders.validation.service import OrderValidationService from cg.services.sample_run_metrics_service.sample_run_metrics_service import ( SampleRunMetricsService, ) @@ -92,10 +93,12 @@ def init_app(self, app): order_service = OrderService(store=db, status_service=summary_service) sample_service = SampleService(db) flow_cell_service = SampleRunMetricsService(db) -order_submitter_registry: OrderSubmitterRegistry = setup_order_submitter_registry( +storing_service_registry: StoringServiceRegistry = setup_storing_service_registry( lims=lims, status_db=db, ) + +order_validation_service = OrderValidationService(store=db) freshdesk_client = FreshdeskClient( base_url=app_config.freshdesk_url, api_key=app_config.freshdesk_api_key ) diff --git a/cg/services/orders/constants.py b/cg/services/orders/constants.py new file mode 100644 index 0000000000..1023e4b122 --- /dev/null +++ b/cg/services/orders/constants.py @@ -0,0 +1,20 @@ +from cg.constants import Workflow +from cg.models.orders.constants import OrderType + +ORDER_TYPE_WORKFLOW_MAP: dict[OrderType, Workflow] = { + OrderType.BALSAMIC: Workflow.BALSAMIC, + OrderType.BALSAMIC_UMI: Workflow.BALSAMIC_UMI, + OrderType.FASTQ: Workflow.RAW_DATA, + OrderType.FLUFFY: Workflow.FLUFFY, + OrderType.METAGENOME: Workflow.RAW_DATA, + OrderType.MICROBIAL_FASTQ: Workflow.RAW_DATA, + OrderType.MICROSALT: Workflow.MICROSALT, + OrderType.MIP_DNA: Workflow.MIP_DNA, + OrderType.MIP_RNA: Workflow.MIP_RNA, + OrderType.PACBIO_LONG_READ: Workflow.RAW_DATA, + OrderType.RML: Workflow.RAW_DATA, + OrderType.RNAFUSION: Workflow.RNAFUSION, + OrderType.SARS_COV_2: Workflow.MUTANT, + OrderType.TAXPROFILER: Workflow.TAXPROFILER, + OrderType.TOMTE: Workflow.TOMTE, +} diff --git a/cg/services/orders/lims_service/service.py b/cg/services/orders/lims_service/service.py new file mode 100644 index 0000000000..28d5c255e5 --- /dev/null +++ b/cg/services/orders/lims_service/service.py @@ -0,0 +1,65 @@ +import logging + +from cg.apps.lims import LimsAPI +from cg.constants import DataDelivery, Workflow +from cg.models.lims.sample import LimsSample +from cg.services.orders.validation.models.sample import Sample + +LOG = logging.getLogger(__name__) + + +class OrderLimsService: + + def __init__(self, lims_api: LimsAPI): + self.lims_api = lims_api + + @staticmethod + def _build_lims_sample( + customer: str, + samples: list[Sample], + workflow: Workflow, + delivery_type: DataDelivery, + skip_reception_control: bool, + ) -> list[LimsSample]: + """Convert order input to LIMS interface input.""" + samples_lims = [] + for sample in samples: + dict_sample = sample.model_dump() + LOG.debug(f"{sample.name}: prepare LIMS input") + dict_sample["customer"] = customer + dict_sample["data_analysis"] = workflow + dict_sample["data_delivery"] = delivery_type + dict_sample["family_name"] = sample._case_name + if skip_reception_control: + dict_sample["skip_reception_control"] = True + lims_sample: LimsSample = LimsSample.parse_obj(dict_sample) + samples_lims.append(lims_sample) + return samples_lims + + def process_lims( + self, + samples: list[Sample], + customer: str, + ticket: int | None, + order_name: str, + workflow: Workflow, + delivery_type: DataDelivery, + skip_reception_control: bool, + ) -> tuple[any, dict]: + """Process samples to add them to LIMS.""" + samples_lims: list[LimsSample] = self._build_lims_sample( + customer=customer, + samples=samples, + workflow=workflow, + delivery_type=delivery_type, + skip_reception_control=skip_reception_control, + ) + project_name: str = str(ticket) or order_name + # Create new lims project + project_data = self.lims_api.submit_project( + project_name, [lims_sample.dict() for lims_sample in samples_lims] + ) + lims_map: dict[str, str] = self.lims_api.get_samples( + projectlimsid=project_data["id"], map_ids=True + ) + return project_data, lims_map diff --git a/cg/services/orders/order_lims_service/order_lims_service.py b/cg/services/orders/order_lims_service/order_lims_service.py deleted file mode 100644 index 03d9210400..0000000000 --- a/cg/services/orders/order_lims_service/order_lims_service.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging - -from cg.apps.lims import LimsAPI -from cg.models.lims.sample import LimsSample -from cg.models.orders.order import OrderIn -from cg.models.orders.samples import OrderInSample - -LOG = logging.getLogger(__name__) - - -class OrderLimsService: - - def __init__(self, lims_api: LimsAPI): - self.lims_api = lims_api - - @staticmethod - def _build_lims_sample(customer: str, samples: list[OrderInSample]) -> list[LimsSample]: - """Convert order input to lims interface input.""" - samples_lims = [] - for sample in samples: - dict_sample = sample.__dict__ - LOG.debug(f"{sample.name}: prepare LIMS input") - dict_sample["customer"] = customer - lims_sample: LimsSample = LimsSample.parse_obj(dict_sample) - samples_lims.append(lims_sample) - return samples_lims - - def process_lims(self, lims_order: OrderIn, new_samples: list[OrderInSample]): - """Process samples to add them to LIMS.""" - samples_lims: list[LimsSample] = self._build_lims_sample( - lims_order.customer, samples=new_samples - ) - project_name: str = lims_order.ticket or lims_order.name - # Create new lims project - project_data = self.lims_api.submit_project( - project_name, [lims_sample.dict() for lims_sample in samples_lims] - ) - lims_map = self.lims_api.get_samples(projectlimsid=project_data["id"], map_ids=True) - return project_data, lims_map diff --git a/cg/services/orders/order_service/order_service.py b/cg/services/orders/order_service/order_service.py index 11d5ee9fa1..d3c7a32921 100644 --- a/cg/services/orders/order_service/order_service.py +++ b/cg/services/orders/order_service/order_service.py @@ -1,13 +1,10 @@ from cg.server.dto.orders.orders_request import OrdersRequest -from cg.server.dto.orders.orders_response import Order as OrderResponse, Order -from cg.server.dto.orders.orders_response import OrdersResponse +from cg.server.dto.orders.orders_response import Order, OrdersResponse from cg.services.orders.order_service.models import OrderQueryParams from cg.services.orders.order_summary_service.dto.order_summary import OrderSummary -from cg.services.orders.order_summary_service.order_summary_service import ( - OrderSummaryService, -) +from cg.services.orders.order_summary_service.order_summary_service import OrderSummaryService +from cg.store.models import Order as DbOrder from cg.store.store import Store -from cg.store.models import Order as DatabaseOrder class OrderService: @@ -15,8 +12,8 @@ def __init__(self, store: Store, status_service: OrderSummaryService) -> None: self.store = store self.summary_service = status_service - def get_order(self, order_id: int) -> OrderResponse: - order: Order = self.store.get_order_by_id(order_id) + def get_order(self, order_id: int) -> Order: + order: DbOrder = self.store.get_order_by_id(order_id) summary: OrderSummary = self.summary_service.get_summary(order_id) return self._create_order_response(order=order, summary=summary) @@ -29,13 +26,13 @@ def get_orders(self, orders_request: OrdersRequest) -> OrdersResponse: summaries: list[OrderSummary] = self.summary_service.get_summaries(order_ids) return self._create_orders_response(orders=orders, summaries=summaries, total=total_count) - def set_open(self, order_id: int, open: bool) -> OrderResponse: - order: Order = self.store.update_order_status(order_id=order_id, open=open) + def set_open(self, order_id: int, open: bool) -> Order: + order: DbOrder = self.store.update_order_status(order_id=order_id, open=open) return self._create_order_response(order) def update_is_open(self, order_id: int, delivered_analyses: int) -> None: """Update the is_open parameter of an order based on the number of delivered analyses.""" - order: Order = self.store.get_order_by_id(order_id) + order: DbOrder = self.store.get_order_by_id(order_id) case_count: int = len(order.cases) if self._is_order_closed(case_count=case_count, delivered_analyses=delivered_analyses): self.set_open(order_id=order_id, open=False) @@ -55,7 +52,7 @@ def _get_order_query_params(orders_request: OrdersRequest) -> OrderQueryParams: ) @staticmethod - def _create_order_response(order: DatabaseOrder, summary: OrderSummary | None = None) -> Order: + def _create_order_response(order: DbOrder, summary: OrderSummary | None = None) -> Order: return Order( customer_id=order.customer.internal_id, ticket_id=order.ticket_id, @@ -67,7 +64,7 @@ def _create_order_response(order: DatabaseOrder, summary: OrderSummary | None = ) def _create_orders_response( - self, orders: list[DatabaseOrder], summaries: list[OrderSummary], total: int + self, orders: list[DbOrder], summaries: list[OrderSummary], total: int ) -> OrdersResponse: orders: list[Order] = [self._create_order_response(order) for order in orders] self._add_summaries(orders=orders, summaries=summaries) diff --git a/cg/services/orders/store_order_services/store_case_order.py b/cg/services/orders/store_order_services/store_case_order.py deleted file mode 100644 index 41ff3041e0..0000000000 --- a/cg/services/orders/store_order_services/store_case_order.py +++ /dev/null @@ -1,305 +0,0 @@ -import logging -from datetime import datetime - -from cg.constants import Priority, Workflow -from cg.constants.constants import CaseActions, DataDelivery -from cg.constants.pedigree import Pedigree -from cg.models.orders.order import OrderIn -from cg.models.orders.samples import Of1508Sample -from cg.services.orders.order_lims_service.order_lims_service import OrderLimsService -from cg.services.orders.submitters.order_submitter import StoreOrderService -from cg.store.models import ( - ApplicationVersion, - Case, - CaseSample, - Customer, - Order, - Sample, -) -from cg.store.store import Store - -LOG = logging.getLogger(__name__) - - -class StoreCaseOrderService(StoreOrderService): - """ - Service for storing generic orders in StatusDB and Lims. - This class is used to store orders for the following workflows: - - Balsamic - - Balsamic QC - - Balsamic UMI - - MIP DNA - - MIP RNA - - Tomte - """ - - def __init__( - self, - status_db: Store, - lims_service: OrderLimsService, - ): - self.status_db = status_db - self.lims = lims_service - - def store_order(self, order: OrderIn) -> dict: - """Submit a batch of samples for sequencing and analysis.""" - return self._process_case_samples(order=order) - - def _process_case_samples(self, order: OrderIn) -> dict: - """Process samples to be analyzed.""" - project_data = lims_map = None - - # submit new samples to lims - new_samples = [sample for sample in order.samples if sample.internal_id is None] - if new_samples: - project_data, lims_map = self.lims.process_lims( - lims_order=order, new_samples=new_samples - ) - - status_data = self.order_to_status(order=order) - samples = [sample for family in status_data["families"] for sample in family["samples"]] - if lims_map: - self._fill_in_sample_ids(samples=samples, lims_map=lims_map) - - new_cases: list[Case] = self.store_items_in_status( - customer_id=status_data["customer"], - order=status_data["order"], - ordered=project_data["date"] if project_data else datetime.now(), - ticket_id=order.ticket, - items=status_data["families"], - ) - return {"project": project_data, "records": new_cases} - - @staticmethod - def _group_cases(samples: list[Of1508Sample]) -> dict: - """Group samples in cases.""" - cases = {} - for sample in samples: - case_id = sample.family_name - if case_id not in cases: - cases[case_id] = [] - cases[case_id].append(sample) - return cases - - @staticmethod - def _get_single_value(case_name, case_samples, value_key, value_default=None): - values = set(getattr(sample, value_key) or value_default for sample in case_samples) - if len(values) > 1: - raise ValueError(f"different sample {value_key} values: {case_name} - {values}") - single_value = values.pop() - return single_value - - def order_to_status(self, order: OrderIn) -> dict: - """Converts order input to status interface input for MIP-DNA, MIP-RNA and Balsamic.""" - status_data = {"customer": order.customer, "order": order.name, "families": []} - cases = self._group_cases(order.samples) - - for case_name, case_samples in cases.items(): - case_internal_id: str = self._get_single_value( - case_name, case_samples, "case_internal_id" - ) - cohorts: set[str] = { - cohort for sample in case_samples for cohort in sample.cohorts if cohort - } - data_analysis = self._get_single_value(case_name, case_samples, "data_analysis") - data_delivery = self._get_single_value(case_name, case_samples, "data_delivery") - - panels: set[str] = set() - if data_analysis in [Workflow.MIP_DNA, Workflow.TOMTE]: - panels: set[str] = { - panel for sample in case_samples for panel in sample.panels if panel - } - - priority = self._get_single_value( - case_name, case_samples, "priority", Priority.standard.name - ) - synopsis: str = self._get_single_value(case_name, case_samples, "synopsis") - - case = { - "cohorts": list(cohorts), - "data_analysis": data_analysis, - "data_delivery": data_delivery, - "internal_id": case_internal_id, - "name": case_name, - "panels": list(panels), - "priority": priority, - "samples": [ - { - "age_at_sampling": sample.age_at_sampling, - "application": sample.application, - "capture_kit": sample.capture_kit, - "comment": sample.comment, - "control": sample.control, - "father": sample.father, - "internal_id": sample.internal_id, - "mother": sample.mother, - "name": sample.name, - "phenotype_groups": list(sample.phenotype_groups), - "phenotype_terms": list(sample.phenotype_terms), - "reference_genome": ( - sample.reference_genome if hasattr(sample, "reference_genome") else None - ), - "sex": sample.sex, - "status": sample.status if hasattr(sample, "status") else None, - "subject_id": sample.subject_id, - "tumour": sample.tumour, - } - for sample in case_samples - ], - "synopsis": synopsis, - } - - status_data["families"].append(case) - return status_data - - def store_items_in_status( - self, customer_id: str, order: str, ordered: datetime, ticket_id: str, items: list[dict] - ) -> list[Case]: - """Store cases, samples and their relationship in the Status database.""" - customer: Customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - new_cases: list[Case] = [] - status_db_order = Order( - customer=customer, - order_date=datetime.now(), - ticket_id=int(ticket_id), - ) - for case in items: - status_db_case: Case = self.status_db.get_case_by_internal_id( - internal_id=case["internal_id"] - ) - if not status_db_case: - new_case: Case = self._create_case( - case=case, customer_obj=customer, ticket=ticket_id - ) - new_cases.append(new_case) - self._update_case_panel(panels=case["panels"], case=new_case) - status_db_case: Case = new_case - else: - self._append_ticket(ticket_id=ticket_id, case=status_db_case) - self._update_action(action=CaseActions.ANALYZE, case=status_db_case) - self._update_case_panel(panels=case["panels"], case=status_db_case) - case_samples: dict[str, Sample] = {} - status_db_order.cases.append(status_db_case) - for sample in case["samples"]: - existing_sample: Sample = self.status_db.get_sample_by_internal_id( - internal_id=sample["internal_id"] - ) - if not existing_sample: - new_sample: Sample = self._create_sample( - case=case, - customer_obj=customer, - order=order, - ordered=ordered, - sample=sample, - ticket=ticket_id, - ) - case_samples[sample["name"]] = new_sample - else: - case_samples[sample["name"]] = existing_sample - - for sample in case["samples"]: - sample_mother: Sample = case_samples.get(sample.get(Pedigree.MOTHER)) - sample_father: Sample = case_samples.get(sample.get(Pedigree.FATHER)) - with self.status_db.session.no_autoflush: - case_sample: CaseSample = self.status_db.get_case_sample_link( - case_internal_id=status_db_case.internal_id, - sample_internal_id=sample["internal_id"], - ) - if not case_sample: - case_sample: CaseSample = self._create_link( - case_obj=status_db_case, - family_samples=case_samples, - father_obj=sample_father, - mother_obj=sample_mother, - sample=sample, - ) - - self._update_relationship( - father_obj=sample_father, - link_obj=case_sample, - mother_obj=sample_mother, - sample=sample, - ) - self.status_db.session.add_all(new_cases) - self.status_db.session.add(status_db_order) - self.status_db.session.commit() - return new_cases - - @staticmethod - def _update_case_panel(panels: list[str], case: Case) -> None: - """Update case panels.""" - case.panels = panels - - @staticmethod - def _append_ticket(ticket_id: str, case: Case) -> None: - """Add a ticket to the case.""" - case.tickets = f"{case.tickets},{ticket_id}" - - @staticmethod - def _update_action(action: str, case: Case) -> None: - """Update action of a case.""" - case.action = action - - @staticmethod - def _update_relationship(father_obj, link_obj, mother_obj, sample): - link_obj.status = sample["status"] or link_obj.status - link_obj.mother = mother_obj or link_obj.mother - link_obj.father = father_obj or link_obj.father - - def _create_link(self, case_obj, family_samples, father_obj, mother_obj, sample): - link_obj = self.status_db.relate_sample( - case=case_obj, - sample=family_samples[sample["name"]], - status=sample["status"], - mother=mother_obj, - father=father_obj, - ) - self.status_db.session.add(link_obj) - return link_obj - - def _create_sample(self, case, customer_obj, order, ordered, sample, ticket): - sample_obj = self.status_db.add_sample( - name=sample["name"], - comment=sample["comment"], - control=sample["control"], - internal_id=sample["internal_id"], - order=order, - ordered=ordered, - original_ticket=ticket, - tumour=sample["tumour"], - age_at_sampling=sample["age_at_sampling"], - capture_kit=sample["capture_kit"], - phenotype_groups=sample["phenotype_groups"], - phenotype_terms=sample["phenotype_terms"], - priority=case["priority"], - reference_genome=sample["reference_genome"], - sex=sample["sex"], - subject_id=sample["subject_id"], - ) - sample_obj.customer = customer_obj - with self.status_db.session.no_autoflush: - application_tag = sample["application"] - sample_obj.application_version: ApplicationVersion = ( - self.status_db.get_current_application_version_by_tag(tag=application_tag) - ) - self.status_db.session.add(sample_obj) - return sample_obj - - def _create_case(self, case: dict, customer_obj: Customer, ticket: str): - case_obj = self.status_db.add_case( - cohorts=case["cohorts"], - data_analysis=Workflow(case["data_analysis"]), - data_delivery=DataDelivery(case["data_delivery"]), - name=case["name"], - priority=case["priority"], - synopsis=case["synopsis"], - ticket=ticket, - ) - case_obj.customer = customer_obj - return case_obj - - @staticmethod - def _is_rerun_of_existing_case(sample: Of1508Sample) -> bool: - return sample.case_internal_id is not None diff --git a/cg/services/orders/store_order_services/store_fastq_order_service.py b/cg/services/orders/store_order_services/store_fastq_order_service.py deleted file mode 100644 index 18ec1d0044..0000000000 --- a/cg/services/orders/store_order_services/store_fastq_order_service.py +++ /dev/null @@ -1,153 +0,0 @@ -import logging -from datetime import datetime - -from cg.constants import DataDelivery, GenePanelMasterList, Priority, Workflow -from cg.constants.constants import CustomerId -from cg.constants.sequencing import SeqLibraryPrepCategory -from cg.exc import OrderError -from cg.models.orders.order import OrderIn -from cg.models.orders.sample_base import StatusEnum -from cg.services.orders.order_lims_service.order_lims_service import OrderLimsService -from cg.services.orders.store_order_services.constants import MAF_ORDER_ID -from cg.services.orders.submitters.order_submitter import StoreOrderService -from cg.store.models import ApplicationVersion, Case, CaseSample, Customer, Order, Sample -from cg.store.store import Store - -LOG = logging.getLogger(__name__) - - -class StoreFastqOrderService(StoreOrderService): - """Storing service for FASTQ orders.""" - - def __init__(self, status_db: Store, lims_service: OrderLimsService): - self.status_db = status_db - self.lims = lims_service - - def store_order(self, order: OrderIn) -> dict: - """Submit a batch of samples for FASTQ delivery.""" - - project_data, lims_map = self.lims.process_lims(lims_order=order, new_samples=order.samples) - status_data = self.order_to_status(order) - self._fill_in_sample_ids(samples=status_data["samples"], lims_map=lims_map) - new_samples = self.store_items_in_status( - customer_id=status_data["customer"], - order=status_data["order"], - ordered=project_data["date"], - ticket_id=order.ticket, - items=status_data["samples"], - ) - return {"project": project_data, "records": new_samples} - - @staticmethod - def order_to_status(order: OrderIn) -> dict: - """Convert order input to status for fastq-only orders.""" - status_data = { - "customer": order.customer, - "order": order.name, - "samples": [ - { - "application": sample.application, - "capture_kit": sample.capture_kit, - "comment": sample.comment, - "data_analysis": sample.data_analysis, - "data_delivery": sample.data_delivery, - "name": sample.name, - "priority": sample.priority, - "sex": sample.sex, - "subject_id": sample.subject_id, - "tumour": sample.tumour, - "volume": sample.volume, - } - for sample in order.samples - ], - } - return status_data - - def create_maf_case(self, sample_obj: Sample, order: Order, case: Case) -> None: - """Add a MAF case to the Status database.""" - maf_order = self.status_db.get_order_by_id(MAF_ORDER_ID) - case: Case = self.status_db.add_case( - comment=f"MAF case for {case.internal_id} original order id {order.id}", - data_analysis=Workflow(Workflow.MIP_DNA), - data_delivery=DataDelivery(DataDelivery.NO_DELIVERY), - name="_".join([sample_obj.internal_id, "MAF"]), - panels=[GenePanelMasterList.OMIM_AUTO], - priority=Priority.research, - ticket=sample_obj.original_ticket, - ) - case.customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=CustomerId.CG_INTERNAL_CUSTOMER - ) - relationship: CaseSample = self.status_db.relate_sample( - case=case, sample=sample_obj, status=StatusEnum.unknown - ) - maf_order.cases.append(case) - self.status_db.session.add_all([case, relationship]) - - def store_items_in_status( - self, customer_id: str, order: str, ordered: datetime, ticket_id: str, items: list[dict] - ) -> list[Sample]: - """Store fastq samples in the status database including family connection and delivery""" - customer: Customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - if not customer: - raise OrderError(f"Unknown customer: {customer_id}") - new_samples = [] - case: Case = self.status_db.get_case_by_name_and_customer( - customer=customer, case_name=ticket_id - ) - submitted_case: dict = items[0] - status_db_order = Order( - customer=customer, - order_date=datetime.now(), - ticket_id=int(ticket_id), - ) - with self.status_db.session.no_autoflush: - for sample in items: - new_sample = self.status_db.add_sample( - name=sample["name"], - sex=sample["sex"] or "unknown", - comment=sample["comment"], - internal_id=sample.get("internal_id"), - order=order, - ordered=ordered, - original_ticket=ticket_id, - priority=sample["priority"], - tumour=sample["tumour"], - capture_kit=sample["capture_kit"], - subject_id=sample["subject_id"], - ) - new_sample.customer: Customer = customer - application_tag: str = sample["application"] - application_version: ApplicationVersion = ( - self.status_db.get_current_application_version_by_tag(tag=application_tag) - ) - if application_version is None: - raise OrderError(f"Invalid application: {sample['application']}") - new_sample.application_version: ApplicationVersion = application_version - new_samples.append(new_sample) - if not case: - case = self.status_db.add_case( - data_analysis=Workflow(submitted_case["data_analysis"]), - data_delivery=DataDelivery(submitted_case["data_delivery"]), - name=ticket_id, - panels=None, - priority=submitted_case["priority"], - ticket=ticket_id, - ) - if ( - not new_sample.is_tumour - and new_sample.prep_category == SeqLibraryPrepCategory.WHOLE_GENOME_SEQUENCING - ): - self.create_maf_case(sample_obj=new_sample, order=status_db_order, case=case) - case.customer = customer - new_relationship = self.status_db.relate_sample( - case=case, sample=new_sample, status=StatusEnum.unknown - ) - self.status_db.session.add_all([case, new_relationship]) - status_db_order.cases.append(case) - self.status_db.session.add(status_db_order) - self.status_db.session.add_all(new_samples) - self.status_db.session.commit() - return new_samples diff --git a/cg/services/orders/store_order_services/store_metagenome_order.py b/cg/services/orders/store_order_services/store_metagenome_order.py deleted file mode 100644 index 38c00e3ef2..0000000000 --- a/cg/services/orders/store_order_services/store_metagenome_order.py +++ /dev/null @@ -1,138 +0,0 @@ -import logging -from datetime import datetime - -from cg.constants import DataDelivery, Sex, Workflow -from cg.exc import OrderError -from cg.models.orders.order import OrderIn -from cg.models.orders.sample_base import StatusEnum -from cg.services.orders.order_lims_service.order_lims_service import OrderLimsService -from cg.services.orders.submitters.order_submitter import StoreOrderService -from cg.store.models import ( - ApplicationVersion, - Case, - CaseSample, - Customer, - Order, - Sample, -) -from cg.store.store import Store - -LOG = logging.getLogger(__name__) - - -class StoreMetagenomeOrderService(StoreOrderService): - """Storing service for metagenome orders.""" - - def __init__(self, status_db: Store, lims_service: OrderLimsService): - self.status_db = status_db - self.lims = lims_service - - def store_order(self, order: OrderIn) -> dict: - """Submit a batch of metagenome samples.""" - project_data, lims_map = self.lims.process_lims(lims_order=order, new_samples=order.samples) - status_data = self.order_to_status(order) - self._fill_in_sample_ids(samples=status_data["families"][0]["samples"], lims_map=lims_map) - new_samples = self.store_items_in_status( - customer_id=status_data["customer"], - order=status_data["order"], - ordered=project_data["date"], - ticket_id=order.ticket, - items=status_data["families"], - ) - return {"project": project_data, "records": new_samples} - - @staticmethod - def order_to_status(order: OrderIn) -> dict: - """Convert order input to status for metagenome orders.""" - return { - "customer": order.customer, - "order": order.name, - "families": [ - { - "data_analysis": order.samples[0].data_analysis, - "data_delivery": order.samples[0].data_delivery, - "priority": order.samples[0].priority, - "samples": [ - { - "application": sample.application, - "comment": sample.comment, - "control": sample.control, - "name": sample.name, - "priority": sample.priority, - "volume": sample.volume, - } - for sample in order.samples - ], - } - ], - } - - def store_items_in_status( - self, - customer_id: str, - order: str, - ordered: datetime, - ticket_id: str, - items: list[dict], - ) -> list[Sample]: - """Store samples in the status database.""" - customer: Customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - if customer is None: - raise OrderError(f"unknown customer: {customer_id}") - new_samples = [] - case: Case = self.status_db.get_case_by_name_and_customer( - customer=customer, case_name=str(ticket_id) - ) - case_dict: dict = items[0] - status_db_order = Order( - customer=customer, - order_date=datetime.now(), - ticket_id=int(ticket_id), - ) - with self.status_db.session.no_autoflush: - for sample in case_dict["samples"]: - new_sample = self.status_db.add_sample( - name=sample["name"], - sex=Sex.UNKNOWN, - comment=sample["comment"], - control=sample["control"], - internal_id=sample.get("internal_id"), - order=order, - ordered=ordered, - original_ticket=ticket_id, - priority=sample["priority"], - ) - new_sample.customer: Customer = customer - application_tag: str = sample["application"] - application_version: ApplicationVersion = ( - self.status_db.get_current_application_version_by_tag(tag=application_tag) - ) - if application_version is None: - raise OrderError(f"Invalid application: {sample['application']}") - new_sample.application_version: ApplicationVersion = application_version - new_samples.append(new_sample) - - if not case: - case = self.status_db.add_case( - data_analysis=Workflow(case_dict["data_analysis"]), - data_delivery=DataDelivery(case_dict["data_delivery"]), - name=str(ticket_id), - panels=None, - priority=case_dict["priority"], - ticket=ticket_id, - ) - case.customer = customer - self.status_db.session.add(case) - self.status_db.session.commit() - - new_relationship: CaseSample = self.status_db.relate_sample( - case=case, sample=new_sample, status=StatusEnum.unknown - ) - self.status_db.session.add(new_relationship) - status_db_order.cases.append(case) - self.status_db.session.add(status_db_order) - self.status_db.session.add_all(new_samples) - self.status_db.session.commit() - return new_samples diff --git a/cg/services/orders/store_order_services/store_microbial_fastq_order_service.py b/cg/services/orders/store_order_services/store_microbial_fastq_order_service.py deleted file mode 100644 index 3ff3e68048..0000000000 --- a/cg/services/orders/store_order_services/store_microbial_fastq_order_service.py +++ /dev/null @@ -1,137 +0,0 @@ -from datetime import datetime - -from cg.constants import DataDelivery, SexOptions, Workflow -from cg.models.orders.order import OrderIn -from cg.models.orders.sample_base import StatusEnum -from cg.services.orders.order_lims_service.order_lims_service import OrderLimsService -from cg.services.orders.submitters.order_submitter import StoreOrderService -from cg.store.exc import EntryNotFoundError -from cg.store.models import Case, CaseSample, Customer, Order, Sample -from cg.store.store import Store - - -class StoreMicrobialFastqOrderService(StoreOrderService): - - def __init__(self, status_db: Store, lims_service: OrderLimsService): - self.status_db = status_db - self.lims = lims_service - - def store_order(self, order: OrderIn) -> dict: - project_data, lims_map = self.lims.process_lims(lims_order=order, new_samples=order.samples) - status_data: dict = self.order_to_status(order) - self._fill_in_sample_ids(samples=status_data["samples"], lims_map=lims_map) - new_samples: list[Sample] = self.store_items_in_status( - customer_id=status_data["customer"], - order=status_data["order"], - ordered=project_data["date"] if project_data else datetime.now(), - ticket_id=order.ticket, - items=status_data["samples"], - ) - return {"project": project_data, "records": new_samples} - - @staticmethod - def order_to_status(order: OrderIn) -> dict: - """Convert order input for microbial samples.""" - return { - "customer": order.customer, - "order": order.name, - "comment": order.comment, - "samples": [ - { - "application": sample.application, - "comment": sample.comment, - "internal_id": sample.internal_id, - "data_analysis": sample.data_analysis, - "data_delivery": sample.data_delivery, - "name": sample.name, - "priority": sample.priority, - "volume": sample.volume, - "control": sample.control, - } - for sample in order.samples - ], - } - - def store_items_in_status( - self, - customer_id: str, - order: str, - ordered: datetime, - ticket_id: str, - items: list[dict], - ) -> list[Sample]: - customer: Customer = self._get_customer(customer_id) - new_samples: list[Sample] = [] - status_db_order = Order( - customer=customer, - order_date=datetime.now(), - ticket_id=int(ticket_id), - ) - for sample in items: - case_name: str = f'{sample["name"]}-case' - case: Case = self._create_case_for_sample( - sample=sample, customer=customer, case_name=case_name, ticket_id=ticket_id - ) - db_sample: Sample = self._create_sample( - sample_dict=sample, - order=order, - ordered=ordered, - ticket_id=ticket_id, - customer=customer, - ) - db_sample = self._add_application_to_sample( - sample=db_sample, application_tag=sample["application"] - ) - case_sample: CaseSample = self.status_db.relate_sample( - case=case, sample=db_sample, status=StatusEnum.unknown - ) - status_db_order.cases.append(case) - self.status_db.add_multiple_items_to_store([case, db_sample, case_sample]) - new_samples.append(db_sample) - self.status_db.session.add(status_db_order) - self.status_db.commit_to_store() - return new_samples - - def _get_customer(self, customer_id: str) -> Customer: - if customer := self.status_db.get_customer_by_internal_id(customer_id): - return customer - raise EntryNotFoundError(f"could not find customer: {customer_id}") - - def _create_case_for_sample( - self, sample: dict, customer: Customer, case_name: str, ticket_id: str - ) -> Case: - if self.status_db.get_case_by_name_and_customer(case_name=case_name, customer=customer): - raise ValueError(f"Case already exists: {case_name}.") - case: Case = self.status_db.add_case( - data_analysis=Workflow.RAW_DATA, - data_delivery=DataDelivery.FASTQ, - name=case_name, - priority=sample["priority"], - ticket=ticket_id, - ) - case.customer = customer - return case - - def _create_sample( - self, sample_dict: dict, order, ordered, ticket_id: str, customer: Customer - ) -> Sample: - - return self.status_db.add_sample( - name=sample_dict["name"], - customer=customer, - sex=SexOptions.UNKNOWN, - comment=sample_dict["comment"], - internal_id=sample_dict["internal_id"], - order=order, - ordered=ordered, - original_ticket=ticket_id, - priority=sample_dict["priority"], - ) - - def _add_application_to_sample(self, sample: Sample, application_tag: str) -> Sample: - if application_version := self.status_db.get_current_application_version_by_tag( - tag=application_tag - ): - sample.application_version = application_version - return sample - raise EntryNotFoundError(f"Invalid application: {application_tag}") diff --git a/cg/services/orders/store_order_services/store_microbial_order.py b/cg/services/orders/store_order_services/store_microbial_order.py deleted file mode 100644 index 7d79f99fba..0000000000 --- a/cg/services/orders/store_order_services/store_microbial_order.py +++ /dev/null @@ -1,187 +0,0 @@ -import logging -from datetime import datetime - -from cg.constants import DataDelivery, Sex, Workflow -from cg.models.orders.order import OrderIn -from cg.models.orders.samples import MicrobialSample -from cg.services.orders.order_lims_service.order_lims_service import OrderLimsService -from cg.services.orders.submitters.order_submitter import StoreOrderService -from cg.store.models import ( - ApplicationVersion, - Case, - CaseSample, - Customer, - Order, - Organism, - Sample, -) -from cg.store.store import Store - -LOG = logging.getLogger(__name__) - - -class StoreMicrobialOrderService(StoreOrderService): - """ - Storing service for microbial orders. - These include: - - Mutant samples - - Microsalt samples - - Sars-Cov-2 samples - """ - - def __init__(self, status_db: Store, lims_service: OrderLimsService): - self.status = status_db - self.lims = lims_service - - def store_order(self, order: OrderIn) -> dict: - self._fill_in_sample_verified_organism(order.samples) - # submit samples to LIMS - project_data, lims_map = self.lims.process_lims(lims_order=order, new_samples=order.samples) - # prepare order for status database - status_data = self.order_to_status(order) - self._fill_in_sample_ids( - samples=status_data["samples"], lims_map=lims_map, id_key="internal_id" - ) - - # submit samples to Status - samples = self.store_items_in_status( - customer_id=status_data["customer"], - order=status_data["order"], - ordered=project_data["date"] if project_data else datetime.now(), - ticket_id=order.ticket, - items=status_data["samples"], - comment=status_data["comment"], - data_analysis=Workflow(status_data["data_analysis"]), - data_delivery=DataDelivery(status_data["data_delivery"]), - ) - return {"project": project_data, "records": samples} - - @staticmethod - def order_to_status(order: OrderIn) -> dict: - """Convert order input for microbial samples.""" - - status_data = { - "customer": order.customer, - "order": order.name, - "comment": order.comment, - "data_analysis": order.samples[0].data_analysis, - "data_delivery": order.samples[0].data_delivery, - "samples": [ - { - "application": sample.application, - "comment": sample.comment, - "control": sample.control, - "name": sample.name, - "organism_id": sample.organism, - "priority": sample.priority, - "reference_genome": sample.reference_genome, - "volume": sample.volume, - } - for sample in order.samples - ], - } - return status_data - - def store_items_in_status( - self, - comment: str, - customer_id: str, - data_analysis: Workflow, - data_delivery: DataDelivery, - order: str, - ordered: datetime, - items: list[dict], - ticket_id: str, - ) -> [Sample]: - """Store microbial samples in the status database.""" - - sample_objs = [] - - customer: Customer = self.status.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - new_samples = [] - status_db_order = Order( - customer=customer, - order_date=datetime.now(), - ticket_id=int(ticket_id), - ) - - with self.status.session.no_autoflush: - for sample_data in items: - case: Case = self.status.get_case_by_name_and_customer( - customer=customer, case_name=ticket_id - ) - - if not case: - case: Case = self.status.add_case( - data_analysis=data_analysis, - data_delivery=data_delivery, - name=ticket_id, - panels=None, - ticket=ticket_id, - ) - case.customer = customer - self.status.session.add(case) - self.status.session.commit() - - application_tag: str = sample_data["application"] - application_version: ApplicationVersion = ( - self.status.get_current_application_version_by_tag(tag=application_tag) - ) - organism: Organism = self.status.get_organism_by_internal_id( - sample_data["organism_id"] - ) - - if not organism: - organism: Organism = self.status.add_organism( - internal_id=sample_data["organism_id"], - name=sample_data["organism_id"], - reference_genome=sample_data["reference_genome"], - ) - self.status.session.add(organism) - self.status.session.commit() - - if comment: - case.comment = f"Order comment: {comment}" - - new_sample = self.status.add_sample( - name=sample_data["name"], - sex=Sex.UNKNOWN, - comment=sample_data["comment"], - control=sample_data["control"], - internal_id=sample_data.get("internal_id"), - order=order, - ordered=ordered, - original_ticket=ticket_id, - priority=sample_data["priority"], - application_version=application_version, - customer=customer, - organism=organism, - reference_genome=sample_data["reference_genome"], - ) - - priority = new_sample.priority - sample_objs.append(new_sample) - link: CaseSample = self.status.relate_sample( - case=case, sample=new_sample, status="unknown" - ) - self.status.session.add(link) - new_samples.append(new_sample) - - case.priority = priority - status_db_order.cases.append(case) - self.status.session.add(status_db_order) - self.status.session.add_all(new_samples) - self.status.session.commit() - return sample_objs - - def _fill_in_sample_verified_organism(self, samples: list[MicrobialSample]): - for sample in samples: - organism_id = sample.organism - reference_genome = sample.reference_genome - organism: Organism = self.status.get_organism_by_internal_id(internal_id=organism_id) - is_verified = ( - organism and organism.reference_genome == reference_genome and organism.verified - ) - sample.verified_organism = is_verified diff --git a/cg/services/orders/store_order_services/store_pacbio_order_service.py b/cg/services/orders/store_order_services/store_pacbio_order_service.py deleted file mode 100644 index 66e1c6080e..0000000000 --- a/cg/services/orders/store_order_services/store_pacbio_order_service.py +++ /dev/null @@ -1,113 +0,0 @@ -import logging -from datetime import datetime - -from cg.constants import DataDelivery, Workflow -from cg.models.orders.order import OrderIn -from cg.models.orders.sample_base import SexEnum, StatusEnum -from cg.services.orders.order_lims_service.order_lims_service import OrderLimsService -from cg.services.orders.submitters.order_submitter import StoreOrderService -from cg.store.models import ApplicationVersion, CaseSample, Customer, Order, Sample -from cg.store.store import Store - -LOG = logging.getLogger(__name__) - - -class StorePacBioOrderService(StoreOrderService): - """Storing service for PacBio Long Read orders.""" - - def __init__(self, status_db: Store, lims_service: OrderLimsService): - self.status_db = status_db - self.lims = lims_service - - def store_order(self, order: OrderIn) -> dict: - """Submit a batch of samples for PacBio Long Read delivery.""" - - project_data, lims_map = self.lims.process_lims(lims_order=order, new_samples=order.samples) - status_data: dict = self.order_to_status(order) - self._fill_in_sample_ids(samples=status_data["samples"], lims_map=lims_map) - new_samples = self._store_samples_in_statusdb( - customer_id=status_data["customer"], - order=status_data["order"], - ordered=project_data["date"], - ticket_id=order.ticket, - samples=status_data["samples"], - ) - return {"project": project_data, "records": new_samples} - - @staticmethod - def order_to_status(order: OrderIn) -> dict: - """Convert order input to status for PacBio-only orders.""" - status_data = { - "customer": order.customer, - "order": order.name, - "samples": [ - { - "application": sample.application, - "comment": sample.comment, - "data_analysis": sample.data_analysis, - "data_delivery": sample.data_delivery, - "name": sample.name, - "priority": sample.priority, - "sex": sample.sex, - "tumour": sample.tumour, - "volume": sample.volume, - "subject_id": sample.subject_id, - } - for sample in order.samples - ], - } - return status_data - - def _store_samples_in_statusdb( - self, customer_id: str, order: str, ordered: datetime, ticket_id: str, samples: list[dict] - ) -> list[Sample]: - """Store PacBio samples and cases in the StatusDB.""" - customer: Customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - status_db_order = Order( - customer=customer, - order_date=datetime.now(), - ticket_id=int(ticket_id), - ) - new_samples = [] - with self.status_db.session.no_autoflush: - for sample in samples: - sample_name: str = sample["name"] - new_sample = self.status_db.add_sample( - name=sample_name, - sex=sample["sex"] or SexEnum.unknown, - comment=sample["comment"], - internal_id=sample.get("internal_id"), - order=order, - ordered=ordered, - original_ticket=ticket_id, - priority=sample["priority"], - tumour=sample["tumour"], - subject_id=sample["subject_id"], - ) - new_sample.customer = customer - application_tag: str = sample["application"] - application_version: ApplicationVersion = ( - self.status_db.get_current_application_version_by_tag(tag=application_tag) - ) - new_sample.application_version = application_version - new_samples.append(new_sample) - case = self.status_db.add_case( - data_analysis=Workflow(sample["data_analysis"]), - data_delivery=DataDelivery(sample["data_delivery"]), - name=f"{sample_name}-case", - priority=sample["priority"], - ticket=ticket_id, - ) - case.customer = customer - new_relationship: CaseSample = self.status_db.relate_sample( - case=case, sample=new_sample, status=StatusEnum.unknown - ) - status_db_order.cases.append(case) - self.status_db.session.add_all([case, new_relationship]) - - self.status_db.session.add(status_db_order) - self.status_db.session.add_all(new_samples) - self.status_db.session.commit() - return new_samples diff --git a/cg/services/orders/store_order_services/store_pool_order.py b/cg/services/orders/store_order_services/store_pool_order.py deleted file mode 100644 index 6cb6d13a4c..0000000000 --- a/cg/services/orders/store_order_services/store_pool_order.py +++ /dev/null @@ -1,210 +0,0 @@ -import logging -from datetime import datetime - -from cg.constants import DataDelivery, Workflow -from cg.exc import OrderError -from cg.models.orders.order import OrderIn -from cg.models.orders.sample_base import SexEnum -from cg.models.orders.samples import RmlSample -from cg.services.orders.order_lims_service.order_lims_service import OrderLimsService -from cg.services.orders.submitters.order_submitter import StoreOrderService -from cg.store.models import ( - ApplicationVersion, - Case, - CaseSample, - Customer, - Order, - Pool, - Sample, -) -from cg.store.store import Store - -LOG = logging.getLogger(__name__) - - -class StorePoolOrderService(StoreOrderService): - """ - Storing service for pool orders. - These include: - - Fluffy / NIPT samples - - RML samples - """ - - def __init__(self, status_db: Store, lims_service: OrderLimsService): - self.status_db = status_db - self.lims = lims_service - - def store_order(self, order: OrderIn) -> dict: - status_data = self.order_to_status(order) - project_data, lims_map = self.lims.process_lims(lims_order=order, new_samples=order.samples) - samples = [sample for pool in status_data["pools"] for sample in pool["samples"]] - self._fill_in_sample_ids(samples=samples, lims_map=lims_map, id_key="internal_id") - new_records = self.store_items_in_status( - customer_id=status_data["customer"], - order=status_data["order"], - ordered=project_data["date"], - ticket_id=order.ticket, - items=status_data["pools"], - ) - return {"project": project_data, "records": new_records} - - @staticmethod - def order_to_status(order: OrderIn) -> dict: - """Convert input to pools""" - - status_data = { - "customer": order.customer, - "order": order.name, - "comment": order.comment, - "pools": [], - } - - # group pools - pools = {} - - for sample in order.samples: - pool_name = sample.pool - application = sample.application - data_analysis = sample.data_analysis - data_delivery = sample.data_delivery - priority = sample.priority - - if pool_name not in pools: - pools[pool_name] = {} - pools[pool_name]["name"] = pool_name - pools[pool_name]["applications"] = set() - pools[pool_name]["priorities"] = set() - pools[pool_name]["samples"] = [] - - pools[pool_name]["samples"].append(sample) - pools[pool_name]["applications"].add(application) - pools[pool_name]["priorities"].add(priority) - - # each pool must only have same of some values - for pool in pools.values(): - applications = pool["applications"] - priorities = pool["priorities"] - pool_name = pool["name"] - if len(applications) > 1: - raise OrderError(f"different applications in pool: {pool_name} - {applications}") - if len(priorities) > 1: - raise OrderError(f"different priorities in pool: {pool_name} - {priorities}") - - for pool in pools.values(): - pool_name = pool["name"] - applications = pool["applications"] - application = applications.pop() - pool_samples = pool["samples"] - priorities = pool["priorities"] - priority = priorities.pop() - - status_data["pools"].append( - { - "name": pool_name, - "application": application, - "data_analysis": data_analysis, - "data_delivery": data_delivery, - "priority": priority, - "samples": [ - { - "comment": sample.comment, - "control": sample.control, - "name": sample.name, - } - for sample in pool_samples - ], - } - ) - return status_data - - def store_items_in_status( - self, customer_id: str, order: str, ordered: datetime, ticket_id: str, items: list[dict] - ) -> list[Pool]: - """Store pools in the status database.""" - customer: Customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - status_db_order = Order( - customer=customer, - order_date=datetime.now(), - ticket_id=int(ticket_id), - ) - new_pools: list[Pool] = [] - new_samples: list[Sample] = [] - for pool in items: - with self.status_db.session.no_autoflush: - application_version: ApplicationVersion = ( - self.status_db.get_current_application_version_by_tag(tag=pool["application"]) - ) - priority: str = pool["priority"] - case_name: str = self.create_case_name(ticket=ticket_id, pool_name=pool["name"]) - case: Case = self.status_db.get_case_by_name_and_customer( - customer=customer, case_name=case_name - ) - if not case: - data_analysis: Workflow = Workflow(pool["data_analysis"]) - data_delivery: DataDelivery = DataDelivery(pool["data_delivery"]) - case = self.status_db.add_case( - data_analysis=data_analysis, - data_delivery=data_delivery, - name=case_name, - panels=None, - priority=priority, - ticket=ticket_id, - ) - case.customer = customer - self.status_db.session.add(case) - - new_pool: Pool = self.status_db.add_pool( - application_version=application_version, - customer=customer, - name=pool["name"], - order=order, - ordered=ordered, - ticket=ticket_id, - ) - sex: SexEnum = SexEnum.unknown - for sample in pool["samples"]: - new_sample = self.status_db.add_sample( - name=sample["name"], - sex=sex, - comment=sample["comment"], - control=sample.get("control"), - internal_id=sample.get("internal_id"), - order=order, - ordered=ordered, - original_ticket=ticket_id, - priority=priority, - application_version=application_version, - customer=customer, - no_invoice=True, - ) - new_samples.append(new_sample) - link: CaseSample = self.status_db.relate_sample( - case=case, sample=new_sample, status="unknown" - ) - self.status_db.session.add(link) - status_db_order.cases.append(case) - new_pools.append(new_pool) - self.status_db.session.add(status_db_order) - self.status_db.session.add_all(new_pools) - self.status_db.session.commit() - return new_pools - - def _validate_case_names_are_available( - self, customer_id: str, samples: list[RmlSample], ticket: str - ): - """Validate names of all samples are not already in use.""" - customer: Customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - for sample in samples: - case_name: str = self.create_case_name(pool_name=sample.pool, ticket=ticket) - if self.status_db.get_case_by_name_and_customer(customer=customer, case_name=case_name): - raise OrderError( - f"Case name {case_name} already in use for customer {customer.name}" - ) - - @staticmethod - def create_case_name(ticket: str, pool_name: str) -> str: - return f"{ticket}-{pool_name}" diff --git a/cg/services/orders/submitters/__init__.py b/cg/services/orders/storing/__init__.py similarity index 100% rename from cg/services/orders/submitters/__init__.py rename to cg/services/orders/storing/__init__.py diff --git a/cg/services/orders/store_order_services/constants.py b/cg/services/orders/storing/constants.py similarity index 100% rename from cg/services/orders/store_order_services/constants.py rename to cg/services/orders/storing/constants.py diff --git a/cg/services/orders/storing/implementations/__init__.py b/cg/services/orders/storing/implementations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/orders/storing/implementations/case_order_service.py b/cg/services/orders/storing/implementations/case_order_service.py new file mode 100644 index 0000000000..f2f8808a19 --- /dev/null +++ b/cg/services/orders/storing/implementations/case_order_service.py @@ -0,0 +1,229 @@ +import logging +from datetime import datetime + +from cg.constants.constants import CaseActions, DataDelivery, Workflow +from cg.constants.pedigree import Pedigree +from cg.services.orders.constants import ORDER_TYPE_WORKFLOW_MAP +from cg.services.orders.lims_service.service import OrderLimsService +from cg.services.orders.storing.service import StoreOrderService +from cg.services.orders.validation.models.case import Case +from cg.services.orders.validation.models.existing_case import ExistingCase +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.models.sample_aliases import SampleInCase +from cg.store.models import ApplicationVersion +from cg.store.models import Case as DbCase +from cg.store.models import CaseSample, Customer +from cg.store.models import Order as DbOrder +from cg.store.models import Sample as DbSample +from cg.store.store import Store + +LOG = logging.getLogger(__name__) + + +class StoreCaseOrderService(StoreOrderService): + """ + Service for storing generic orders in StatusDB and Lims. + This class is used to store orders for the following workflows: + - Balsamic + - Balsamic UMI + - MIP DNA + - MIP RNA + - RNAFusion + - Tomte + """ + + def __init__( + self, + status_db: Store, + lims_service: OrderLimsService, + ): + self.status_db = status_db + self.lims = lims_service + + def store_order(self, order: OrderWithCases) -> dict: + """Submit a batch of samples for sequencing and analysis.""" + project_data = lims_map = None + if new_samples := [sample for _, _, sample in order.enumerated_new_samples]: + project_data, lims_map = self.lims.process_lims( + samples=new_samples, + customer=order.customer, + ticket=order._generated_ticket_id, + order_name=order.name, + workflow=ORDER_TYPE_WORKFLOW_MAP[order.order_type], + delivery_type=order.delivery_type, + skip_reception_control=order.skip_reception_control, + ) + if lims_map: + self._fill_in_sample_ids(samples=new_samples, lims_map=lims_map) + + new_cases: list[DbCase] = self.store_order_data_in_status_db(order) + return {"project": project_data, "records": new_cases} + + def store_order_data_in_status_db(self, order: OrderWithCases) -> list[DbCase]: + """Store cases, samples and their relationship in the Status database.""" + new_cases: list[DbCase] = [] + db_order = self._create_db_order(order) + for case in order.cases: + if case.is_new: + db_case: DbCase = self._create_db_case( + case=case, + customer=db_order.customer, + ticket=str(order._generated_ticket_id), + workflow=ORDER_TYPE_WORKFLOW_MAP[order.order_type], + delivery_type=order.delivery_type, + ) + new_cases.append(db_case) + self._update_case_panel(panels=getattr(case, "panels", []), case=db_case) + case_samples: dict[str, DbSample] = self._create_db_sample_dict( + case=case, order=order, customer=db_order.customer + ) + self._create_links(case=case, db_case=db_case, case_samples=case_samples) + + else: + db_case: DbCase = self._update_existing_case( + existing_case=case, ticket_id=order._generated_ticket_id + ) + + db_order.cases.append(db_case) + self.status_db.session.add_all(new_cases) + self.status_db.session.add(db_order) + self.status_db.session.commit() + return new_cases + + @staticmethod + def _update_case_panel(panels: list[str], case: DbCase) -> None: + """Update case panels.""" + case.panels = panels + + @staticmethod + def _append_ticket(ticket_id: str, case: DbCase) -> None: + """Add a ticket to the case.""" + case.tickets = f"{case.tickets},{ticket_id}" + + @staticmethod + def _update_action(action: str, case: DbCase) -> None: + """Update action of a case.""" + case.action = action + + def _create_link( + self, + case: DbCase, + db_sample: DbSample, + father: DbSample, + mother: DbSample, + sample: SampleInCase, + ) -> CaseSample: + return self.status_db.relate_sample( + case=case, + sample=db_sample, + status=getattr(sample, "status", None), + mother=mother, + father=father, + ) + + def _create_db_sample( + self, + case: Case, + customer: Customer, + order_name: str, + ordered: datetime, + sample: SampleInCase, + ticket: str, + ): + application_tag = sample.application + application_version: ApplicationVersion = ( + self.status_db.get_current_application_version_by_tag(tag=application_tag) + ) + db_sample: DbSample = self.status_db.add_sample( + application_version=application_version, + internal_id=sample._generated_lims_id, + order=order_name, + ordered=ordered, + original_ticket=ticket, + priority=case.priority, + **sample.model_dump(exclude={"application", "container", "container_name"}), + ) + db_sample.customer = customer + self.status_db.session.add(db_sample) + return db_sample + + def _create_db_case( + self, + case: Case, + customer: Customer, + ticket: str, + workflow: Workflow, + delivery_type: DataDelivery, + ) -> DbCase: + db_case: DbCase = self.status_db.add_case( + ticket=ticket, + data_analysis=workflow, + data_delivery=delivery_type, + **case.model_dump(exclude={"samples"}), + ) + db_case.customer = customer + return db_case + + def _create_db_order(self, order: OrderWithCases) -> DbOrder: + customer: Customer = self.status_db.get_customer_by_internal_id( + customer_internal_id=order.customer + ) + return DbOrder( + customer=customer, + order_date=datetime.now(), + ticket_id=order._generated_ticket_id, + ) + + def _update_existing_case(self, existing_case: ExistingCase, ticket_id: int) -> DbCase: + status_db_case = self.status_db.get_case_by_internal_id(existing_case.internal_id) + self._append_ticket(ticket_id=str(ticket_id), case=status_db_case) + self._update_action(action=CaseActions.ANALYZE, case=status_db_case) + self._update_case_panel(panels=getattr(existing_case, "panels", []), case=status_db_case) + return status_db_case + + def _create_links(self, case: Case, db_case: DbCase, case_samples: dict[str, DbSample]) -> None: + """Creates entries in the CaseSample table. + Input: + - case: Case, a case within the customer submitted order. + - db_case: DbCase, Database case entry corresponding to the 'case' parameter. + - case_samples: dict with keys being sample names in the provided 'case' and values being + the corresponding database entries in the Sample table.""" + for sample in case.samples: + if sample.is_new: + db_sample: DbSample = case_samples.get(sample.name) + else: + db_sample: DbSample = self.status_db.get_sample_by_internal_id(sample.internal_id) + sample_mother_name: str | None = getattr(sample, Pedigree.MOTHER, None) + db_sample_mother: DbSample | None = case_samples.get(sample_mother_name) + sample_father_name: str = getattr(sample, Pedigree.FATHER, None) + db_sample_father: DbSample | None = case_samples.get(sample_father_name) + case_sample: CaseSample = self._create_link( + case=db_case, + db_sample=db_sample, + father=db_sample_father, + mother=db_sample_mother, + sample=sample, + ) + self.status_db.add_item_to_store(case_sample) + + def _create_db_sample_dict( + self, case: Case, order: OrderWithCases, customer: Customer + ) -> dict[str, DbSample]: + """Constructs a dict containing all the samples in the case. Keys are sample names + and the values are the database entries for the samples.""" + case_samples: dict[str, DbSample] = {} + for sample in case.samples: + if sample.is_new: + with self.status_db.session.no_autoflush: + db_sample: DbSample = self._create_db_sample( + case=case, + customer=customer, + order_name=order.name, + ordered=datetime.now(), + sample=sample, + ticket=str(order._generated_ticket_id), + ) + else: + db_sample: DbSample = self.status_db.get_sample_by_internal_id(sample.internal_id) + case_samples[db_sample.name] = db_sample + return case_samples diff --git a/cg/services/orders/storing/implementations/fastq_order_service.py b/cg/services/orders/storing/implementations/fastq_order_service.py new file mode 100644 index 0000000000..ea6df4656b --- /dev/null +++ b/cg/services/orders/storing/implementations/fastq_order_service.py @@ -0,0 +1,146 @@ +import logging +from datetime import datetime + +from cg.constants import DataDelivery, GenePanelMasterList, Priority, Workflow +from cg.constants.constants import CustomerId +from cg.constants.sequencing import SeqLibraryPrepCategory +from cg.models.orders.sample_base import SexEnum, StatusEnum +from cg.services.orders.constants import ORDER_TYPE_WORKFLOW_MAP +from cg.services.orders.lims_service.service import OrderLimsService +from cg.services.orders.storing.constants import MAF_ORDER_ID +from cg.services.orders.storing.service import StoreOrderService +from cg.services.orders.validation.workflows.fastq.models.order import FastqOrder +from cg.services.orders.validation.workflows.fastq.models.sample import FastqSample +from cg.store.models import ApplicationVersion, Case, CaseSample, Customer, Order, Sample +from cg.store.store import Store + +LOG = logging.getLogger(__name__) + + +class StoreFastqOrderService(StoreOrderService): + """Storing service for FASTQ orders.""" + + def __init__(self, status_db: Store, lims_service: OrderLimsService): + self.status_db = status_db + self.lims = lims_service + + def store_order(self, order: FastqOrder) -> dict: + """Submit a batch of samples for FASTQ delivery.""" + project_data, lims_map = self.lims.process_lims( + samples=order.samples, + ticket=order._generated_ticket_id, + order_name=order.name, + workflow=Workflow.RAW_DATA, + customer=order.customer, + delivery_type=DataDelivery(order.delivery_type), + skip_reception_control=order.skip_reception_control, + ) + self._fill_in_sample_ids(samples=order.samples, lims_map=lims_map) + new_samples: list[Sample] = self.store_order_data_in_status_db(order=order) + return {"records": new_samples, "project": project_data} + + def store_order_data_in_status_db(self, order: FastqOrder) -> list[Sample]: + """ + Store all order data in the Status database for a FASTQ order. Return the samples. + The stored data objects are: + - Order + - Samples + - One Case containing all samples + - For each Sample, a relationship between the sample and the Case + - For each non-tumour WGS Sample, a MAF Case and a relationship between the Sample and the + MAF Case + """ + db_order: Order = self._create_db_order(order=order) + db_case: Case = self._create_db_case(order=order, db_order=db_order) + new_samples = [] + with self.status_db.session.no_autoflush: + for sample in order.samples: + db_sample: Sample = self._create_db_sample( + sample=sample, + order_name=order.name, + ticket_id=str(db_order.ticket_id), + customer=db_order.customer, + ) + self._create_maf_case(db_sample=db_sample, db_order=db_order, db_case=db_case) + case_sample: CaseSample = self.status_db.relate_sample( + case=db_case, sample=db_sample, status=StatusEnum.unknown + ) + self.status_db.add_multiple_items_to_store([db_sample, case_sample]) + new_samples.append(db_sample) + db_order.cases.append(db_case) + self.status_db.add_multiple_items_to_store([db_order, db_case]) + self.status_db.commit_to_store() + return new_samples + + def _create_db_order(self, order: FastqOrder) -> Order: + """Return an Order database object.""" + ticket_id: int = order._generated_ticket_id + customer: Customer = self.status_db.get_customer_by_internal_id( + customer_internal_id=order.customer + ) + return self.status_db.add_order(customer=customer, ticket_id=ticket_id) + + def _create_db_case(self, order: FastqOrder, db_order: Order) -> Case: + """Return a Case database object.""" + priority: str = order.samples[0].priority + case: Case = self.status_db.add_case( + data_analysis=ORDER_TYPE_WORKFLOW_MAP[order.order_type], + data_delivery=DataDelivery(order.delivery_type), + name=str(db_order.ticket_id), + priority=priority, + ticket=str(db_order.ticket_id), + ) + case.customer = db_order.customer + return case + + def _create_db_sample( + self, sample: FastqSample, order_name: str, customer: Customer, ticket_id: str + ) -> Sample: + """Return a Sample database object.""" + application_version: ApplicationVersion = ( + self.status_db.get_current_application_version_by_tag(tag=sample.application) + ) + return self.status_db.add_sample( + name=sample.name, + sex=sample.sex or SexEnum.unknown, + comment=sample.comment, + internal_id=sample._generated_lims_id, + ordered=datetime.now(), + original_ticket=ticket_id, + priority=sample.priority, + tumour=sample.tumour, + capture_kit=sample.capture_kit, + subject_id=sample.subject_id, + customer=customer, + application_version=application_version, + order=order_name, + ) + + def _create_maf_case(self, db_sample: Sample, db_order: Order, db_case: Case) -> None: + """ + Add a MAF case and a relationship with the given sample to the current Status database + transaction. This is done only if the given sample is non-tumour and WGS. + This function does not commit to the database. + """ + if ( + not db_sample.is_tumour + and db_sample.prep_category == SeqLibraryPrepCategory.WHOLE_GENOME_SEQUENCING + ): + maf_order: Order = self.status_db.get_order_by_id(MAF_ORDER_ID) + maf_case: Case = self.status_db.add_case( + comment=f"MAF case for {db_case.internal_id} original order id {db_order.id}", + data_analysis=Workflow.MIP_DNA, + data_delivery=DataDelivery.NO_DELIVERY, + name="_".join([db_sample.name, "MAF"]), + panels=[GenePanelMasterList.OMIM_AUTO], + priority=Priority.research, + ticket=db_sample.original_ticket, + ) + maf_case.customer = self.status_db.get_customer_by_internal_id( + customer_internal_id=CustomerId.CG_INTERNAL_CUSTOMER + ) + maf_case_sample: CaseSample = self.status_db.relate_sample( + case=maf_case, sample=db_sample, status=StatusEnum.unknown + ) + maf_order.cases.append(maf_case) + self.status_db.add_multiple_items_to_store([maf_case, maf_case_sample]) diff --git a/cg/services/orders/storing/implementations/metagenome_order_service.py b/cg/services/orders/storing/implementations/metagenome_order_service.py new file mode 100644 index 0000000000..325a049c5a --- /dev/null +++ b/cg/services/orders/storing/implementations/metagenome_order_service.py @@ -0,0 +1,106 @@ +import logging +from datetime import datetime + +from cg.constants import DataDelivery, Sex +from cg.models.orders.sample_base import PriorityEnum, StatusEnum +from cg.services.orders.constants import ORDER_TYPE_WORKFLOW_MAP +from cg.services.orders.lims_service.service import OrderLimsService +from cg.services.orders.storing.service import StoreOrderService +from cg.services.orders.validation.workflows.metagenome.models.order import MetagenomeOrder +from cg.services.orders.validation.workflows.metagenome.models.sample import MetagenomeSample +from cg.services.orders.validation.workflows.taxprofiler.models.order import TaxprofilerOrder +from cg.services.orders.validation.workflows.taxprofiler.models.sample import TaxprofilerSample +from cg.store.models import ApplicationVersion +from cg.store.models import Case as DbCase +from cg.store.models import CaseSample, Customer +from cg.store.models import Order as DbOrder +from cg.store.models import Sample as DbSample +from cg.store.store import Store + +LOG = logging.getLogger(__name__) + +OrderMetagenome = MetagenomeOrder | TaxprofilerOrder +SampleMetagenome = MetagenomeSample | TaxprofilerSample + + +class StoreMetagenomeOrderService(StoreOrderService): + """Storing service for Metagenome or Taxprofiler orders.""" + + def __init__(self, status_db: Store, lims_service: OrderLimsService): + self.status_db = status_db + self.lims = lims_service + + def store_order(self, order: OrderMetagenome) -> dict: + """Submit a batch of metagenome samples.""" + project_data, lims_map = self.lims.process_lims( + samples=order.samples, + customer=order.customer, + ticket=order._generated_ticket_id, + order_name=order.name, + workflow=ORDER_TYPE_WORKFLOW_MAP[order.order_type], + delivery_type=DataDelivery(order.delivery_type), + skip_reception_control=order.skip_reception_control, + ) + self._fill_in_sample_ids(samples=order.samples, lims_map=lims_map) + new_samples = self.store_order_data_in_status_db(order) + return {"project": project_data, "records": new_samples} + + def store_order_data_in_status_db( + self, + order: OrderMetagenome, + ) -> list[DbSample]: + """Store samples in the StatusDB database.""" + customer: Customer = self.status_db.get_customer_by_internal_id(order.customer) + new_samples = [] + db_order: DbOrder = self.status_db.add_order( + customer=customer, ticket_id=order._generated_ticket_id + ) + priority: PriorityEnum = order.samples[0].priority + db_case = self._create_db_case(order=order, customer=customer, priority=priority) + db_order.cases.append(db_case) + with self.status_db.session.no_autoflush: + for sample in order.samples: + db_sample = self._create_db_sample(order=order, sample=sample, customer=customer) + new_relationship: CaseSample = self.status_db.relate_sample( + case=db_case, sample=db_sample, status=StatusEnum.unknown + ) + self.status_db.add_item_to_store(new_relationship) + new_samples.append(db_sample) + self.status_db.add_item_to_store(db_case) + self.status_db.add_item_to_store(db_order) + self.status_db.add_multiple_items_to_store(new_samples) + self.status_db.commit_to_store() + return new_samples + + def _create_db_case( + self, order: OrderMetagenome, customer: Customer, priority: PriorityEnum + ) -> DbCase: + db_case: DbCase = self.status_db.add_case( + data_analysis=ORDER_TYPE_WORKFLOW_MAP[order.order_type], + data_delivery=DataDelivery(order.delivery_type), + name=str(order._generated_ticket_id), + priority=priority, + ticket=str(order._generated_ticket_id), + ) + db_case.customer = customer + return db_case + + def _create_db_sample( + self, sample: SampleMetagenome, order: OrderMetagenome, customer: Customer + ) -> DbSample: + db_sample: DbSample = self.status_db.add_sample( + name=sample.name, + sex=Sex.UNKNOWN, + comment=sample.comment, + control=sample.control, + order=order.name, + ordered=datetime.now(), + original_ticket=order._generated_ticket_id, + priority=sample.priority, + ) + db_sample.customer = customer + application_version: ApplicationVersion = ( + self.status_db.get_current_application_version_by_tag(sample.application) + ) + db_sample.application_version = application_version + return db_sample diff --git a/cg/services/orders/storing/implementations/microbial_fastq_order_service.py b/cg/services/orders/storing/implementations/microbial_fastq_order_service.py new file mode 100644 index 0000000000..69dd9cdfdd --- /dev/null +++ b/cg/services/orders/storing/implementations/microbial_fastq_order_service.py @@ -0,0 +1,114 @@ +from datetime import datetime + +from cg.constants import DataDelivery, SexOptions, Workflow +from cg.models.orders.sample_base import StatusEnum +from cg.services.orders.constants import ORDER_TYPE_WORKFLOW_MAP +from cg.services.orders.lims_service.service import OrderLimsService +from cg.services.orders.storing.service import StoreOrderService +from cg.services.orders.validation.workflows.microbial_fastq.models.order import MicrobialFastqOrder +from cg.services.orders.validation.workflows.microbial_fastq.models.sample import ( + MicrobialFastqSample, +) +from cg.store.models import ApplicationVersion, Case, CaseSample, Customer, Order, Sample +from cg.store.store import Store + + +class StoreMicrobialFastqOrderService(StoreOrderService): + + def __init__(self, status_db: Store, lims_service: OrderLimsService): + self.status_db = status_db + self.lims = lims_service + + def store_order(self, order: MicrobialFastqOrder) -> dict: + """Store the order in the statusDB and LIMS, return the database samples and LIMS info.""" + project_data, lims_map = self.lims.process_lims( + samples=order.samples, + ticket=order._generated_ticket_id, + order_name=order.name, + workflow=Workflow.RAW_DATA, + customer=order.customer, + delivery_type=DataDelivery(order.delivery_type), + skip_reception_control=order.skip_reception_control, + ) + self._fill_in_sample_ids(samples=order.samples, lims_map=lims_map) + new_samples: list[Sample] = self.store_order_data_in_status_db(order=order) + return {"records": new_samples, "project": project_data} + + def store_order_data_in_status_db(self, order: MicrobialFastqOrder) -> list[Sample]: + """ + Store all order data in the Status database for a Microbial FASTQ order. Return the samples. + The stored data objects are: + - Order + - Samples + - For each Sample, a Case + - For each Sample, a relationship between the Sample and its Case + """ + db_order = self._create_db_order(order=order) + new_samples = [] + with self.status_db.session.no_autoflush: + for sample in order.samples: + case: Case = self._create_db_case_for_sample( + sample=sample, customer=db_order.customer, order=order + ) + db_sample: Sample = self._create_db_sample( + sample=sample, + order_name=order.name, + ticket_id=str(db_order.ticket_id), + customer=db_order.customer, + ) + case_sample: CaseSample = self.status_db.relate_sample( + case=case, sample=db_sample, status=StatusEnum.unknown + ) + self.status_db.add_multiple_items_to_store([case, db_sample, case_sample]) + db_order.cases.append(case) + new_samples.append(db_sample) + self.status_db.add_item_to_store(db_order) + self.status_db.commit_to_store() + return new_samples + + def _create_db_order(self, order: MicrobialFastqOrder) -> Order: + """Return an Order database object.""" + ticket_id: int = order._generated_ticket_id + customer: Customer = self.status_db.get_customer_by_internal_id( + customer_internal_id=order.customer + ) + return self.status_db.add_order(customer=customer, ticket_id=ticket_id) + + def _create_db_case_for_sample( + self, sample: MicrobialFastqSample, customer: Customer, order: MicrobialFastqOrder + ) -> Case: + """Return a Case database object for a MicrobialFastqSample.""" + case_name: str = f"{sample.name}-case" + case: Case = self.status_db.add_case( + data_analysis=ORDER_TYPE_WORKFLOW_MAP[order.order_type], + data_delivery=DataDelivery(order.delivery_type), + name=case_name, + priority=sample.priority, + ticket=str(order._generated_ticket_id), + ) + case.customer = customer + return case + + def _create_db_sample( + self, + sample: MicrobialFastqSample, + order_name: str, + ticket_id: str, + customer: Customer, + ) -> Sample: + """Return a Sample database object.""" + application_version: ApplicationVersion = ( + self.status_db.get_current_application_version_by_tag(tag=sample.application) + ) + return self.status_db.add_sample( + name=sample.name, + customer=customer, + application_version=application_version, + sex=SexOptions.UNKNOWN, + comment=sample.comment, + internal_id=sample._generated_lims_id, + order=order_name, + ordered=datetime.now(), + original_ticket=ticket_id, + priority=sample.priority, + ) diff --git a/cg/services/orders/storing/implementations/microbial_order_service.py b/cg/services/orders/storing/implementations/microbial_order_service.py new file mode 100644 index 0000000000..4922db7146 --- /dev/null +++ b/cg/services/orders/storing/implementations/microbial_order_service.py @@ -0,0 +1,148 @@ +import logging +from datetime import datetime + +from cg.constants import DataDelivery, Sex +from cg.services.orders.constants import ORDER_TYPE_WORKFLOW_MAP +from cg.services.orders.lims_service.service import OrderLimsService +from cg.services.orders.storing.service import StoreOrderService +from cg.services.orders.validation.workflows.microsalt.models.order import MicrosaltOrder +from cg.services.orders.validation.workflows.microsalt.models.sample import MicrosaltSample +from cg.services.orders.validation.workflows.mutant.models.order import MutantOrder +from cg.services.orders.validation.workflows.mutant.models.sample import MutantSample +from cg.store.models import ApplicationVersion +from cg.store.models import Case as DbCase +from cg.store.models import CaseSample, Customer +from cg.store.models import Order as DbOrder +from cg.store.models import Organism +from cg.store.models import Sample as DbSample +from cg.store.store import Store + +LOG = logging.getLogger(__name__) + +MicrobialOrder = MicrosaltOrder | MutantOrder +MicrobialSample = MicrosaltSample | MutantSample + + +class StoreMicrobialOrderService(StoreOrderService): + """ + Storing service for microbial orders. + These include: + - Mutant samples + - Microsalt samples + """ + + def __init__(self, status_db: Store, lims_service: OrderLimsService): + self.status = status_db + self.lims = lims_service + + def store_order(self, order: MicrobialOrder) -> dict: + self._fill_in_sample_verified_organism(order.samples) + project_data, lims_map = self.lims.process_lims( + samples=order.samples, + customer=order.customer, + ticket=order._generated_ticket_id, + order_name=order.name, + workflow=ORDER_TYPE_WORKFLOW_MAP[order.order_type], + delivery_type=DataDelivery(order.delivery_type), + skip_reception_control=order.skip_reception_control, + ) + self._fill_in_sample_ids(samples=order.samples, lims_map=lims_map) + + samples = self.store_order_data_in_status_db(order) + return {"project": project_data, "records": samples} + + def store_order_data_in_status_db(self, order: MicrobialOrder) -> list[DbSample]: + """Store microbial samples in the status database.""" + + customer: Customer = self.status.get_customer_by_internal_id(order.customer) + new_samples = [] + db_order: DbOrder = self.status.add_order( + customer=customer, + ticket_id=order._generated_ticket_id, + ) + db_case: DbCase = self._create_case(customer=customer, order=order) + + with self.status.no_autoflush_context(): + for sample in order.samples: + organism: Organism = self._ensure_organism(sample) + db_sample = self._create_db_sample( + customer=customer, + order_name=order.name, + organism=organism, + sample=sample, + ticket_id=order._generated_ticket_id, + ) + link: CaseSample = self.status.relate_sample( + case=db_case, sample=db_sample, status="unknown" + ) + self.status.add_item_to_store(link) + new_samples.append(db_sample) + db_order.cases.append(db_case) + + self.status.add_item_to_store(db_case) + self.status.add_item_to_store(db_order) + self.status.add_multiple_items_to_store(new_samples) + self.status.commit_to_store() + return new_samples + + def _fill_in_sample_verified_organism(self, samples: list[MicrobialSample]): + for sample in samples: + organism_id = sample.organism + reference_genome = sample.reference_genome + organism: Organism = self.status.get_organism_by_internal_id(internal_id=organism_id) + is_verified: bool = ( + organism and organism.reference_genome == reference_genome and organism.verified + ) + sample._verified_organism = is_verified + + def _create_case(self, customer: Customer, order: MicrobialOrder) -> DbCase: + case: DbCase = self.status.add_case( + data_analysis=ORDER_TYPE_WORKFLOW_MAP[order.order_type], + data_delivery=DataDelivery(order.delivery_type), + name=str(order._generated_ticket_id), + panels=None, + ticket=str(order._generated_ticket_id), + priority=order.samples[0].priority, + ) + case.customer = customer + return case + + def _ensure_organism(self, sample: MicrobialSample) -> Organism: + organism: Organism = self.status.get_organism_by_internal_id(sample.organism) + if not organism: + organism: Organism = self.status.add_organism( + internal_id=sample.organism, + name=sample.organism, + reference_genome=sample.reference_genome, + ) + self.status.add_item_to_store(organism) + self.status.commit_to_store() + return organism + + def _create_db_sample( + self, + customer: Customer, + order_name: str, + organism: Organism, + sample: MicrobialSample, + ticket_id: int, + ) -> DbSample: + application_tag: str = sample.application + application_version: ApplicationVersion = ( + self.status.get_current_application_version_by_tag(tag=application_tag) + ) + return self.status.add_sample( + name=sample.name, + sex=Sex.UNKNOWN, + comment=sample.comment, + control=sample.control, + internal_id=sample._generated_lims_id, + order=order_name, + ordered=datetime.now(), + original_ticket=str(ticket_id), + priority=sample.priority, + application_version=application_version, + customer=customer, + organism=organism, + reference_genome=sample.reference_genome, + ) diff --git a/cg/services/orders/storing/implementations/pacbio_order_service.py b/cg/services/orders/storing/implementations/pacbio_order_service.py new file mode 100644 index 0000000000..536a03b124 --- /dev/null +++ b/cg/services/orders/storing/implementations/pacbio_order_service.py @@ -0,0 +1,116 @@ +import logging +from datetime import datetime + +from cg.constants import DataDelivery, Workflow +from cg.models.orders.sample_base import StatusEnum +from cg.services.orders.constants import ORDER_TYPE_WORKFLOW_MAP +from cg.services.orders.lims_service.service import OrderLimsService +from cg.services.orders.storing.service import StoreOrderService +from cg.services.orders.validation.workflows.pacbio_long_read.models.order import PacbioOrder +from cg.services.orders.validation.workflows.pacbio_long_read.models.sample import PacbioSample +from cg.store.models import ApplicationVersion, Case, CaseSample, Customer, Order, Sample +from cg.store.store import Store + +LOG = logging.getLogger(__name__) + + +class StorePacBioOrderService(StoreOrderService): + """Storing service for PacBio Long Read orders.""" + + def __init__(self, status_db: Store, lims_service: OrderLimsService): + self.status_db = status_db + self.lims = lims_service + + def store_order(self, order: PacbioOrder) -> dict: + """Store the order in the statusDB and LIMS, return the database samples and LIMS info.""" + project_data, lims_map = self.lims.process_lims( + samples=order.samples, + ticket=order._generated_ticket_id, + order_name=order.name, + workflow=Workflow.RAW_DATA, + customer=order.customer, + delivery_type=DataDelivery(order.delivery_type), + skip_reception_control=order.skip_reception_control, + ) + self._fill_in_sample_ids(samples=order.samples, lims_map=lims_map) + new_samples = self.store_order_data_in_status_db(order=order) + return {"project": project_data, "records": new_samples} + + def store_order_data_in_status_db(self, order: PacbioOrder) -> list[Sample]: + """ + Store all order data in the Status database for a Pacbio order. Return the samples. + The stored data objects are: + - Order + - Samples + - For each Sample, a Case + - For each Sample, a relationship between the Sample and its Case + """ + status_db_order: Order = self._create_db_order(order=order) + new_samples = [] + with self.status_db.no_autoflush_context(): + for sample in order.samples: + case: Case = self._create_db_case_for_sample( + sample=sample, + customer=status_db_order.customer, + order=order, + ) + db_sample: Sample = self._create_db_sample( + sample=sample, + order_name=order.name, + customer=status_db_order.customer, + ticket_id=str(status_db_order.ticket_id), + ) + case_sample: CaseSample = self.status_db.relate_sample( + case=case, sample=db_sample, status=StatusEnum.unknown + ) + self.status_db.add_multiple_items_to_store([case, case_sample, db_sample]) + status_db_order.cases.append(case) + new_samples.append(db_sample) + self.status_db.add_item_to_store(status_db_order) + self.status_db.commit_to_store() + return new_samples + + def _create_db_order(self, order: PacbioOrder) -> Order: + """Return an Order database object.""" + ticket_id: int = order._generated_ticket_id + customer: Customer = self.status_db.get_customer_by_internal_id( + customer_internal_id=order.customer + ) + return self.status_db.add_order(customer=customer, ticket_id=ticket_id) + + def _create_db_case_for_sample( + self, sample: PacbioSample, customer: Customer, order: PacbioOrder + ) -> Case: + """Return a Case database object for a PacbioSample.""" + case_name: str = f"{sample.name}-case" + case: Case = self.status_db.add_case( + data_analysis=ORDER_TYPE_WORKFLOW_MAP[order.order_type], + data_delivery=DataDelivery(order.delivery_type), + name=case_name, + priority=sample.priority, + ticket=str(order._generated_ticket_id), + ) + case.customer = customer + return case + + def _create_db_sample( + self, sample: PacbioSample, order_name: str, customer: Customer, ticket_id: str + ) -> Sample: + """Return a Sample database object.""" + application_version: ApplicationVersion = ( + self.status_db.get_current_application_version_by_tag(tag=sample.application) + ) + return self.status_db.add_sample( + name=sample.name, + customer=customer, + application_version=application_version, + sex=sample.sex, + comment=sample.comment, + internal_id=sample._generated_lims_id, + order=order_name, + ordered=datetime.now(), + original_ticket=ticket_id, + priority=sample.priority, + tumour=sample.tumour, + subject_id=sample.subject_id, + ) diff --git a/cg/services/orders/storing/implementations/pool_order_service.py b/cg/services/orders/storing/implementations/pool_order_service.py new file mode 100644 index 0000000000..dd6b59f8d5 --- /dev/null +++ b/cg/services/orders/storing/implementations/pool_order_service.py @@ -0,0 +1,173 @@ +import logging +from datetime import datetime + +from cg.models.orders.sample_base import PriorityEnum, SexEnum, StatusEnum +from cg.services.orders.constants import ORDER_TYPE_WORKFLOW_MAP +from cg.services.orders.lims_service.service import OrderLimsService +from cg.services.orders.storing.service import StoreOrderService +from cg.services.orders.validation.models.order_aliases import OrderWithIndexedSamples +from cg.services.orders.validation.models.sample_aliases import IndexedSample +from cg.store.models import ApplicationVersion, Case, CaseSample, Customer, Order, Pool, Sample +from cg.store.store import Store + +LOG = logging.getLogger(__name__) + + +class StorePoolOrderService(StoreOrderService): + """ + Service for storing generic orders in StatusDB and Lims. + This class is used to store orders for the following workflows: + - Fluffy / NIPT samples + - RML samples + """ + + def __init__(self, status_db: Store, lims_service: OrderLimsService): + self.status_db = status_db + self.lims = lims_service + + def store_order(self, order: OrderWithIndexedSamples) -> dict: + project_data, lims_map = self.lims.process_lims( + samples=order.samples, + customer=order.customer, + ticket=order._generated_ticket_id, + order_name=order.name, + workflow=ORDER_TYPE_WORKFLOW_MAP[order.order_type], + delivery_type=order.delivery_type, + skip_reception_control=order.skip_reception_control, + ) + self._fill_in_sample_ids(samples=order.samples, lims_map=lims_map) + new_records: list[Pool] = self.store_order_data_in_status_db(order=order) + return {"project": project_data, "records": new_records} + + def store_order_data_in_status_db(self, order: OrderWithIndexedSamples) -> list[Pool]: + """Store pools in the status database.""" + db_order: Order = self._create_db_order(order=order) + new_pools: list[Pool] = [] + with self.status_db.no_autoflush_context(): + for pool in order.pools.items(): + db_case: Case = self._create_db_case_for_pool( + order=order, + pool=pool, + customer=db_order.customer, + ticket_id=str(db_order.ticket_id), + ) + db_pool: Pool = self._create_db_pool( + pool=pool, + order_name=order.name, + ticket_id=str(db_order.ticket_id), + customer=db_order.customer, + ) + for sample in pool[1]: + db_sample: Sample = self._create_db_sample( + sample=sample, + order_name=order.name, + ticket_id=str(db_order.ticket_id), + customer=db_order.customer, + application_version=db_pool.application_version, + ) + case_sample: CaseSample = self.status_db.relate_sample( + case=db_case, sample=db_sample, status=StatusEnum.unknown + ) + self.status_db.add_multiple_items_to_store([db_sample, case_sample]) + new_pools.append(db_pool) + db_order.cases.append(db_case) + self.status_db.add_multiple_items_to_store([db_pool, db_case]) + self.status_db.add_item_to_store(db_order) + self.status_db.commit_to_store() + return new_pools + + @staticmethod + def create_case_name(ticket: str, pool_name: str) -> str: + return f"{ticket}-{pool_name}" + + def _get_application_version_from_pool_samples( + self, pool_samples: list[IndexedSample] + ) -> ApplicationVersion: + """ + Return the application version for a pool by taking the app tag of the first sample of + the pool. The validation guarantees that all samples in a pool have the same application. + """ + app_tag: str = pool_samples[0].application + application_version: ApplicationVersion = ( + self.status_db.get_current_application_version_by_tag(tag=app_tag) + ) + return application_version + + @staticmethod + def _get_priority_from_pool_samples(pool_samples: list[IndexedSample]) -> PriorityEnum: + """ + Return the priority of the pool by taking the priority of the first sample of the pool. + The validation guarantees that all samples in a pool have the same priority. + """ + return pool_samples[0].priority + + def _create_db_order(self, order: OrderWithIndexedSamples) -> Order: + """Return an Order database object.""" + ticket_id: int = order._generated_ticket_id + customer: Customer = self.status_db.get_customer_by_internal_id( + customer_internal_id=order.customer + ) + return self.status_db.add_order(customer=customer, ticket_id=ticket_id) + + def _create_db_case_for_pool( + self, + order: OrderWithIndexedSamples, + pool: tuple[str, list[IndexedSample]], + customer: Customer, + ticket_id: str, + ) -> Case: + """Return a Case database object for a pool.""" + case_name: str = self.create_case_name(ticket=ticket_id, pool_name=pool[0]) + case = self.status_db.add_case( + data_analysis=ORDER_TYPE_WORKFLOW_MAP[order.order_type], + data_delivery=order.delivery_type, + name=case_name, + priority=self._get_priority_from_pool_samples(pool_samples=pool[1]), + ticket=ticket_id, + ) + case.customer = customer + return case + + def _create_db_pool( + self, + pool: tuple[str, list[IndexedSample]], + order_name: str, + ticket_id: str, + customer: Customer, + ) -> Pool: + """Return a Pool database object.""" + application_version: ApplicationVersion = self._get_application_version_from_pool_samples( + pool_samples=pool[1] + ) + return self.status_db.add_pool( + application_version=application_version, + customer=customer, + name=pool[0], + order=order_name, + ordered=datetime.now(), + ticket=ticket_id, + ) + + def _create_db_sample( + self, + sample: IndexedSample, + order_name: str, + ticket_id: str, + customer: Customer, + application_version: ApplicationVersion, + ) -> Sample: + """Return a Sample database object.""" + return self.status_db.add_sample( + name=sample.name, + customer=customer, + application_version=application_version, + sex=SexEnum.unknown, + comment=sample.comment, + control=sample.control, + internal_id=sample._generated_lims_id, + order=order_name, + ordered=datetime.now(), + original_ticket=ticket_id, + priority=sample.priority, + no_invoice=True, + ) diff --git a/cg/services/orders/storing/service.py b/cg/services/orders/storing/service.py new file mode 100644 index 0000000000..4c8b4fe6f5 --- /dev/null +++ b/cg/services/orders/storing/service.py @@ -0,0 +1,31 @@ +"""Abstract base classes for order submitters.""" + +import logging +from abc import ABC, abstractmethod + +from cg.services.orders.lims_service.service import OrderLimsService +from cg.services.orders.validation.models.order import Order +from cg.services.orders.validation.models.sample import Sample +from cg.store.store import Store + +LOG = logging.getLogger(__name__) + + +class StoreOrderService(ABC): + @abstractmethod + def __init__(self, status_db: Store, lims_service: OrderLimsService): + self.status_db = status_db + self.lims = lims_service + + @abstractmethod + def store_order(self, order: Order): + pass + + @staticmethod + def _fill_in_sample_ids(samples: list[Sample], lims_map: dict) -> None: + """Fill in LIMS sample ids.""" + for sample in samples: + LOG.debug(f"{sample.name}: link sample to LIMS") + internal_id = lims_map[sample.name] + LOG.info(f"{sample.name} -> {internal_id}: connect sample to LIMS") + sample._generated_lims_id = internal_id diff --git a/cg/services/orders/storing/service_registry.py b/cg/services/orders/storing/service_registry.py new file mode 100644 index 0000000000..a0b56ae11b --- /dev/null +++ b/cg/services/orders/storing/service_registry.py @@ -0,0 +1,126 @@ +from cg.apps.lims import LimsAPI +from cg.models.orders.constants import OrderType +from cg.services.orders.lims_service.service import OrderLimsService +from cg.services.orders.storing.implementations.case_order_service import StoreCaseOrderService +from cg.services.orders.storing.implementations.fastq_order_service import StoreFastqOrderService +from cg.services.orders.storing.implementations.metagenome_order_service import ( + StoreMetagenomeOrderService, +) +from cg.services.orders.storing.implementations.microbial_fastq_order_service import ( + StoreMicrobialFastqOrderService, +) +from cg.services.orders.storing.implementations.microbial_order_service import ( + StoreMicrobialOrderService, +) +from cg.services.orders.storing.implementations.pacbio_order_service import StorePacBioOrderService +from cg.services.orders.storing.implementations.pool_order_service import StorePoolOrderService +from cg.services.orders.storing.service import StoreOrderService +from cg.store.store import Store + + +class StoringServiceRegistry: + """ + A registry for StoreOrderService instances, keyed by OrderType. + """ + + def __init__(self): + self._registry = {} + + def register(self, order_type: OrderType, storing_service: StoreOrderService): + """Register a StoreOrderService instance for a given OrderType.""" + self._registry[order_type] = storing_service + + def get_storing_service(self, order_type: OrderType) -> StoreOrderService: + """Fetch the registered StoreOrderService for the given OrderType.""" + if storing_service := self._registry.get(order_type): + return storing_service + raise ValueError(f"No StoreOrderService registered for order type: {order_type}") + + +order_service_mapping = { + OrderType.BALSAMIC: ( + OrderLimsService, + StoreCaseOrderService, + ), + OrderType.BALSAMIC_QC: ( + OrderLimsService, + StoreCaseOrderService, + ), + OrderType.BALSAMIC_UMI: ( + OrderLimsService, + StoreCaseOrderService, + ), + OrderType.FASTQ: ( + OrderLimsService, + StoreFastqOrderService, + ), + OrderType.FLUFFY: ( + OrderLimsService, + StorePoolOrderService, + ), + OrderType.METAGENOME: ( + OrderLimsService, + StoreMetagenomeOrderService, + ), + OrderType.MICROBIAL_FASTQ: ( + OrderLimsService, + StoreMicrobialFastqOrderService, + ), + OrderType.MICROSALT: ( + OrderLimsService, + StoreMicrobialOrderService, + ), + OrderType.MIP_DNA: ( + OrderLimsService, + StoreCaseOrderService, + ), + OrderType.MIP_RNA: ( + OrderLimsService, + StoreCaseOrderService, + ), + OrderType.PACBIO_LONG_READ: ( + OrderLimsService, + StorePacBioOrderService, + ), + OrderType.RML: ( + OrderLimsService, + StorePoolOrderService, + ), + OrderType.RNAFUSION: ( + OrderLimsService, + StoreCaseOrderService, + ), + OrderType.SARS_COV_2: ( + OrderLimsService, + StoreMicrobialOrderService, + ), + OrderType.TAXPROFILER: ( + OrderLimsService, + StoreMetagenomeOrderService, + ), + OrderType.TOMTE: ( + OrderLimsService, + StoreCaseOrderService, + ), +} + + +def build_storing_service( + lims: LimsAPI, status_db: Store, order_type: OrderType +) -> StoreOrderService: + """Build a StoreOrderService instance for the given OrderType.""" + lims_service, store_service = order_service_mapping[order_type] + return store_service(status_db, lims_service(lims)) + + +def setup_storing_service_registry(lims: LimsAPI, status_db: Store) -> StoringServiceRegistry: + """Set up the StoringServiceRegistry with all StoreOrderService instances.""" + registry = StoringServiceRegistry() + for order_type in order_service_mapping.keys(): + registry.register( + order_type=order_type, + storing_service=build_storing_service( + lims=lims, status_db=status_db, order_type=order_type + ), + ) + return registry diff --git a/cg/services/orders/submitter/service.py b/cg/services/orders/submitter/service.py new file mode 100644 index 0000000000..9b0daf6e65 --- /dev/null +++ b/cg/services/orders/submitter/service.py @@ -0,0 +1,46 @@ +"""Unified interface to handle sample submissions. + +This service will update information in Status and/or LIMS as required. + +The normal entry for information is through the REST API which will pass a JSON +document with all information about samples in the submission. The input will +be validated and if passing all checks be accepted as new samples. +""" + +from cg.models.orders.constants import OrderType +from cg.services.orders.storing.service import StoreOrderService +from cg.services.orders.storing.service_registry import StoringServiceRegistry +from cg.services.orders.submitter.ticket_handler import TicketHandler +from cg.services.orders.validation.models.order import Order +from cg.services.orders.validation.service import OrderValidationService +from cg.store.models import User + + +class OrderSubmitter: + """Orders API for accepting new samples into the system.""" + + def __init__( + self, + ticket_handler: TicketHandler, + storing_registry: StoringServiceRegistry, + validation_service: OrderValidationService, + ): + super().__init__() + self.ticket_handler = ticket_handler + self.storing_registry = storing_registry + self.validation_service = validation_service + + def submit(self, order_type: OrderType, raw_order: dict, user: User) -> dict: + """Submit a batch of samples. + + Main entry point for the class towards interfaces that implements it. + """ + storing_service: StoreOrderService = self.storing_registry.get_storing_service(order_type) + order: Order = self.validation_service.parse_and_validate( + raw_order=raw_order, order_type=order_type, user_id=user.id + ) + ticket_number: int = self.ticket_handler.create_ticket( + order=order, user_name=user.name, user_mail=user.email, order_type=order_type + ) + order._generated_ticket_id = ticket_number + return storing_service.store_order(order) diff --git a/cg/meta/orders/ticket_handler.py b/cg/services/orders/submitter/ticket_handler.py similarity index 56% rename from cg/meta/orders/ticket_handler.py rename to cg/services/orders/submitter/ticket_handler.py index 99ae8693c3..54ff979156 100644 --- a/cg/meta/orders/ticket_handler.py +++ b/cg/services/orders/submitter/ticket_handler.py @@ -5,8 +5,11 @@ from cg.clients.freshdesk.freshdesk_client import FreshdeskClient from cg.clients.freshdesk.models import TicketCreate, TicketResponse -from cg.models.orders.order import OrderIn -from cg.models.orders.samples import Of1508Sample +from cg.models.orders.constants import OrderType +from cg.services.orders.constants import ORDER_TYPE_WORKFLOW_MAP +from cg.services.orders.validation.models.order import Order +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.models.order_with_samples import OrderWithSamples from cg.store.models import Customer, Sample from cg.store.store import Store @@ -25,13 +28,13 @@ def __init__(self, db: Store, client: FreshdeskClient, system_email_id: int, env self.env: str = env def create_ticket( - self, order: OrderIn, user_name: str, user_mail: str, project: str + self, order: Order, user_name: str, user_mail: str, order_type: OrderType ) -> int | None: """Create a ticket and return the ticket number""" message: str = self.create_new_ticket_header( message=self.create_xml_sample_list(order=order, user_name=user_name), order=order, - project=project, + order_type=order_type, ) with TemporaryDirectory() as temp_dir: @@ -44,7 +47,7 @@ def create_ticket( name=user_name, subject=order.name, type="Order", - tags=[order.samples[0].data_analysis], + tags=[ORDER_TYPE_WORKFLOW_MAP[order_type]], custom_fields={ "cf_environment": self.env, }, @@ -57,29 +60,29 @@ def create_ticket( return ticket_response.id - def create_attachment_file(self, order: OrderIn, temp_dir: str) -> Path: + def create_attachment_file(self, order: Order, temp_dir: str) -> Path: """Create a single attachment file for the ticket""" order_file_path = Path(temp_dir) / "order.json" with order_file_path.open("w") as order_file: order_file.write(order.json()) return order_file_path - def create_xml_sample_list(self, order: OrderIn, user_name: str) -> str: + def create_xml_sample_list(self, order: Order, user_name: str) -> str: message = "" - for sample in order.samples: - message = self.add_sample_name_to_message(message=message, sample_name=sample.name) - message = self.add_sample_apptag_to_message( - message=message, application=sample.application - ) - if isinstance(sample, Of1508Sample): - message = self.add_sample_case_name_to_message( - message=message, case_name=sample.family_name + if isinstance(order, OrderWithCases): + message = self.create_case_xml_sample_list(order=order, message=message) + else: + for sample in order.samples: + message = self.add_sample_name_to_message(message=message, sample_name=sample.name) + message = self.add_sample_apptag_to_message( + message=message, application=sample.application + ) + message = self.add_sample_priority_to_message( + message=message, priority=sample.priority ) - message = self.add_existing_sample_info_to_message( - message=message, customer_id=order.customer, internal_id=sample.internal_id + message = self.add_sample_comment_to_message( + message=message, comment=sample.comment ) - message = self.add_sample_priority_to_message(message=message, priority=sample.priority) - message = self.add_sample_comment_to_message(message=message, comment=sample.comment) message += self.NEW_LINE message = self.add_order_comment_to_message(message=message, comment=order.comment) @@ -89,8 +92,13 @@ def create_xml_sample_list(self, order: OrderIn, user_name: str) -> str: return message @staticmethod - def create_new_ticket_header(message: str, order: OrderIn, project: str) -> str: - return f"New order with {len(order.samples)} {project} samples:" + message + def create_new_ticket_header(message: str, order: Order, order_type: OrderType) -> str: + nr_samples = ( + len(order.samples) + if isinstance(order, OrderWithSamples) + else len(order.enumerated_new_samples) + ) + return f"New order with {nr_samples} new {order_type} samples:" + message def add_sample_name_to_message(self, message: str, sample_name: str) -> str: message += f"{self.NEW_LINE}{sample_name}" @@ -109,10 +117,8 @@ def add_sample_case_name_to_message(message: str, case_name: str | None) -> str: return message def add_existing_sample_info_to_message( - self, message: str, customer_id: str, internal_id: str | None + self, message: str, customer_id: str, internal_id: str, case_name: str ) -> str: - if not internal_id: - return message existing_sample: Sample = self.status_db.get_sample_by_internal_id(internal_id=internal_id) @@ -120,7 +126,7 @@ def add_existing_sample_info_to_message( if existing_sample.customer_id != customer_id: sample_customer = " from " + existing_sample.customer.internal_id - message += f" (already existing sample{sample_customer})" + message += f"{existing_sample.name}, application: {existing_sample.application_version.application.tag}, case: {case_name} (already existing sample{sample_customer}), priority: {existing_sample.priority}" return message @staticmethod @@ -168,3 +174,41 @@ def replace_empty_string_with_none(cls, obj: Any) -> Any: else: obj[key] = cls.replace_empty_string_with_none(item) return obj + + def create_case_xml_sample_list(self, order, message: str) -> str: + for case in order.cases: + if not case.is_new: + db_case = self.status_db.get_case_by_internal_id(case.internal_id) + for sample in db_case.samples: + message = self.add_existing_sample_info_to_message( + message=message, + customer_id=sample.customer.internal_id, + internal_id=sample.internal_id, + case_name=db_case.name, + ) + else: + for sample in case.samples: + if not sample.is_new: + message = self.add_existing_sample_info_to_message( + message=message, + customer_id=order.customer, + internal_id=sample.internal_id, + case_name=case.name, + ) + else: + message = self.add_sample_name_to_message( + message=message, sample_name=sample.name + ) + message = self.add_sample_apptag_to_message( + message=message, application=sample.application + ) + message = self.add_sample_case_name_to_message( + message=message, case_name=case.name + ) + message = self.add_sample_priority_to_message( + message=message, priority=case.priority + ) + message = self.add_sample_comment_to_message( + message=message, comment=sample.comment + ) + return message diff --git a/cg/services/orders/submitters/case_order_submitter.py b/cg/services/orders/submitters/case_order_submitter.py deleted file mode 100644 index d493965a47..0000000000 --- a/cg/services/orders/submitters/case_order_submitter.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Module for a generic order submitter.""" - -from cg.models.orders.order import OrderIn -from cg.services.orders.store_order_services.store_case_order import StoreCaseOrderService -from cg.services.orders.submitters.order_submitter import OrderSubmitter -from cg.services.orders.validate_order_services.validate_case_order import ( - ValidateCaseOrderService, -) - - -class CaseOrderSubmitter(OrderSubmitter): - """ - Class for submitting generic orders. - This class is used to submit orders for the following workflows: - - Balsamic - - Balsamic QC - - Balsamic UMI - - MIP DNA - - MIP RNA - - Tomte - """ - - def __init__( - self, - order_validation_service: ValidateCaseOrderService, - order_store_service: StoreCaseOrderService, - ): - self.order_validation_service = order_validation_service - self.order_store_service = order_store_service - - def submit_order(self, order_in: OrderIn) -> dict: - """Submit a generic order.""" - self.order_validation_service.validate_order(order_in) - return self.order_store_service.store_order(order_in) diff --git a/cg/services/orders/submitters/fastq_order_submitter.py b/cg/services/orders/submitters/fastq_order_submitter.py deleted file mode 100644 index 58025d939b..0000000000 --- a/cg/services/orders/submitters/fastq_order_submitter.py +++ /dev/null @@ -1,24 +0,0 @@ -from cg.models.orders.order import OrderIn -from cg.services.orders.store_order_services.store_fastq_order_service import StoreFastqOrderService -from cg.services.orders.submitters.order_submitter import OrderSubmitter -from cg.services.orders.validate_order_services.validate_fastq_order import ( - ValidateFastqOrderService, -) - - -class FastqOrderSubmitter(OrderSubmitter): - """Submitter for fastq orders.""" - - def __init__( - self, - order_validation_service: ValidateFastqOrderService, - order_store_service: StoreFastqOrderService, - ): - self.order_validation_service = order_validation_service - self.order_store_service = order_store_service - - def submit_order(self, order_in: OrderIn) -> dict: - """Submit a fastq order.""" - self.order_validation_service.validate_order(order_in) - result: dict = self.order_store_service.store_order(order_in) - return result diff --git a/cg/services/orders/submitters/metagenome_order_submitter.py b/cg/services/orders/submitters/metagenome_order_submitter.py deleted file mode 100644 index d7ee19611b..0000000000 --- a/cg/services/orders/submitters/metagenome_order_submitter.py +++ /dev/null @@ -1,24 +0,0 @@ -from cg.services.orders.store_order_services.store_metagenome_order import ( - StoreMetagenomeOrderService, -) -from cg.services.orders.submitters.order_submitter import OrderSubmitter -from cg.services.orders.validate_order_services.validate_metagenome_order import ( - ValidateMetagenomeOrderService, -) - - -class MetagenomeOrderSubmitter(OrderSubmitter): - """Class for submitting metagenome and taxprofiler orders.""" - - def __init__( - self, - order_validation_service: ValidateMetagenomeOrderService, - order_store_service: StoreMetagenomeOrderService, - ): - self.order_validation_service = order_validation_service - self.order_store_service = order_store_service - - def submit_order(self, order_in) -> dict: - """Submit a metagenome order.""" - self.order_validation_service.validate_order(order_in) - return self.order_store_service.store_order(order_in) diff --git a/cg/services/orders/submitters/microbial_order_submitter.py b/cg/services/orders/submitters/microbial_order_submitter.py deleted file mode 100644 index 9168630029..0000000000 --- a/cg/services/orders/submitters/microbial_order_submitter.py +++ /dev/null @@ -1,32 +0,0 @@ -from cg.services.orders.store_order_services.store_microbial_fastq_order_service import ( - StoreMicrobialFastqOrderService, -) -from cg.services.orders.store_order_services.store_microbial_order import StoreMicrobialOrderService -from cg.services.orders.submitters.order_submitter import OrderSubmitter -from cg.services.orders.validate_order_services.validate_microbial_order import ( - ValidateMicrobialOrderService, -) - - -class MicrobialOrderSubmitter(OrderSubmitter): - """ - Class for submitting microbial orders. - This class is used to submit orders for the following workflows: - - Sars-Cov-2 - - Microsalt - - Microbial fastq - - Mutant - """ - - def __init__( - self, - order_validation_service: ValidateMicrobialOrderService, - order_store_service: StoreMicrobialOrderService | StoreMicrobialFastqOrderService, - ): - self.order_validation_service = order_validation_service - self.order_store_service = order_store_service - - def submit_order(self, order_in) -> dict: - """Submit a microbial order.""" - self.order_validation_service.validate_order(order_in) - return self.order_store_service.store_order(order_in) diff --git a/cg/services/orders/submitters/order_submitter.py b/cg/services/orders/submitters/order_submitter.py deleted file mode 100644 index 1b25cdf807..0000000000 --- a/cg/services/orders/submitters/order_submitter.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Abstract base classes for order submitters.""" - -import logging -from abc import ABC, abstractmethod - -from cg.models.orders.order import OrderIn -from cg.services.orders.order_lims_service.order_lims_service import OrderLimsService -from cg.store.store import Store - -LOG = logging.getLogger(__name__) - - -class ValidateOrderService(ABC): - @abstractmethod - def __init__(self, status_db: Store): - self.status_db = status_db - - @abstractmethod - def validate_order(self, order_in: OrderIn): - pass - - -class StoreOrderService(ABC): - @abstractmethod - def __init__(self, status_db: Store, lims_service: OrderLimsService): - self.status_db = status_db - self.lims = lims_service - - @abstractmethod - def store_order(self, order_in: OrderIn): - pass - - @staticmethod - def _fill_in_sample_ids(samples: list[dict], lims_map: dict, id_key: str = "internal_id"): - """Fill in LIMS sample ids.""" - for sample in samples: - LOG.debug(f"{sample['name']}: link sample to LIMS") - if not sample.get(id_key): - internal_id = lims_map[sample["name"]] - LOG.info(f"{sample['name']} -> {internal_id}: connect sample to LIMS") - sample[id_key] = internal_id - - -class OrderSubmitter(ABC): - @abstractmethod - def __init__( - self, - validate_order_service: ValidateOrderService, - store_order_service: StoreOrderService, - ): - self.order_validation_service = validate_order_service - self.order_store_service = store_order_service - - @abstractmethod - def submit_order(self, order_in: OrderIn) -> dict: - pass diff --git a/cg/services/orders/submitters/order_submitter_registry.py b/cg/services/orders/submitters/order_submitter_registry.py deleted file mode 100644 index 2267d5cd94..0000000000 --- a/cg/services/orders/submitters/order_submitter_registry.py +++ /dev/null @@ -1,195 +0,0 @@ -from cg.apps.lims import LimsAPI -from cg.models.orders.constants import OrderType -from cg.services.orders.order_lims_service.order_lims_service import OrderLimsService -from cg.services.orders.store_order_services.store_case_order import ( - StoreCaseOrderService, -) -from cg.services.orders.store_order_services.store_fastq_order_service import ( - StoreFastqOrderService, -) -from cg.services.orders.store_order_services.store_metagenome_order import ( - StoreMetagenomeOrderService, -) -from cg.services.orders.store_order_services.store_microbial_fastq_order_service import ( - StoreMicrobialFastqOrderService, -) -from cg.services.orders.store_order_services.store_microbial_order import ( - StoreMicrobialOrderService, -) -from cg.services.orders.store_order_services.store_pacbio_order_service import ( - StorePacBioOrderService, -) -from cg.services.orders.store_order_services.store_pool_order import ( - StorePoolOrderService, -) -from cg.services.orders.submitters.case_order_submitter import CaseOrderSubmitter -from cg.services.orders.submitters.fastq_order_submitter import FastqOrderSubmitter -from cg.services.orders.submitters.metagenome_order_submitter import ( - MetagenomeOrderSubmitter, -) -from cg.services.orders.submitters.microbial_order_submitter import ( - MicrobialOrderSubmitter, -) -from cg.services.orders.submitters.order_submitter import OrderSubmitter -from cg.services.orders.submitters.pacbio_order_submitter import PacbioOrderSubmitter -from cg.services.orders.submitters.pool_order_submitter import PoolOrderSubmitter -from cg.services.orders.validate_order_services.validate_case_order import ( - ValidateCaseOrderService, -) -from cg.services.orders.validate_order_services.validate_fastq_order import ( - ValidateFastqOrderService, -) -from cg.services.orders.validate_order_services.validate_metagenome_order import ( - ValidateMetagenomeOrderService, -) -from cg.services.orders.validate_order_services.validate_microbial_order import ( - ValidateMicrobialOrderService, -) -from cg.services.orders.validate_order_services.validate_pacbio_order import ( - ValidatePacbioOrderService, -) -from cg.services.orders.validate_order_services.validate_pool_order import ( - ValidatePoolOrderService, -) -from cg.store.store import Store - - -class OrderSubmitterRegistry: - """ - A registry for OrderSubmitter instances, keyed by OrderType. - """ - - def __init__(self): - self._registry = {} - - def register(self, order_type: OrderType, order_submitter: OrderSubmitter): - """Register an OrderSubmitter instance for a given OrderType.""" - self._registry[order_type] = order_submitter - - def get_order_submitter(self, order_type: OrderType) -> OrderSubmitter: - """Fetch the registered OrderSubmitter for the given OrderType.""" - if order_submitter := self._registry.get(order_type): - return order_submitter - raise ValueError(f"No OrderSubmitter registered for order type: {order_type}") - - -order_service_mapping = { - OrderType.BALSAMIC: ( - OrderLimsService, - ValidateCaseOrderService, - StoreCaseOrderService, - CaseOrderSubmitter, - ), - OrderType.BALSAMIC_QC: ( - OrderLimsService, - ValidateCaseOrderService, - StoreCaseOrderService, - CaseOrderSubmitter, - ), - OrderType.BALSAMIC_UMI: ( - OrderLimsService, - ValidateCaseOrderService, - StoreCaseOrderService, - CaseOrderSubmitter, - ), - OrderType.FASTQ: ( - OrderLimsService, - ValidateFastqOrderService, - StoreFastqOrderService, - FastqOrderSubmitter, - ), - OrderType.FLUFFY: ( - OrderLimsService, - ValidatePoolOrderService, - StorePoolOrderService, - PoolOrderSubmitter, - ), - OrderType.METAGENOME: ( - OrderLimsService, - ValidateMetagenomeOrderService, - StoreMetagenomeOrderService, - MetagenomeOrderSubmitter, - ), - OrderType.MICROBIAL_FASTQ: ( - OrderLimsService, - ValidateMicrobialOrderService, - StoreMicrobialFastqOrderService, - MicrobialOrderSubmitter, - ), - OrderType.MICROSALT: ( - OrderLimsService, - ValidateMicrobialOrderService, - StoreMicrobialOrderService, - MicrobialOrderSubmitter, - ), - OrderType.MIP_DNA: ( - OrderLimsService, - ValidateCaseOrderService, - StoreCaseOrderService, - CaseOrderSubmitter, - ), - OrderType.MIP_RNA: ( - OrderLimsService, - ValidateCaseOrderService, - StoreCaseOrderService, - CaseOrderSubmitter, - ), - OrderType.PACBIO_LONG_READ: ( - OrderLimsService, - ValidatePacbioOrderService, - StorePacBioOrderService, - PacbioOrderSubmitter, - ), - OrderType.RML: ( - OrderLimsService, - ValidatePoolOrderService, - StorePoolOrderService, - PoolOrderSubmitter, - ), - OrderType.RNAFUSION: ( - OrderLimsService, - ValidateCaseOrderService, - StoreCaseOrderService, - CaseOrderSubmitter, - ), - OrderType.SARS_COV_2: ( - OrderLimsService, - ValidateMicrobialOrderService, - StoreMicrobialOrderService, - MicrobialOrderSubmitter, - ), - OrderType.TAXPROFILER: ( - OrderLimsService, - ValidateMetagenomeOrderService, - StoreMetagenomeOrderService, - MetagenomeOrderSubmitter, - ), - OrderType.TOMTE: ( - OrderLimsService, - ValidateCaseOrderService, - StoreCaseOrderService, - CaseOrderSubmitter, - ), -} - - -def build_submitter(lims: LimsAPI, status_db: Store, order_type: OrderType) -> OrderSubmitter: - """Build an OrderSubmitter instance for the given OrderType.""" - lims_service, validation_service, store_service, submitter_class = order_service_mapping[ - order_type - ] - return submitter_class( - order_validation_service=validation_service(status_db), - order_store_service=store_service(status_db, lims_service(lims)), - ) - - -def setup_order_submitter_registry(lims: LimsAPI, status_db: Store) -> OrderSubmitterRegistry: - """Set up the OrderSubmitterRegistry with all OrderSubmitter instances.""" - registry = OrderSubmitterRegistry() - for order_type in order_service_mapping.keys(): - registry.register( - order_type=order_type, - order_submitter=build_submitter(lims=lims, status_db=status_db, order_type=order_type), - ) - return registry diff --git a/cg/services/orders/submitters/pacbio_order_submitter.py b/cg/services/orders/submitters/pacbio_order_submitter.py deleted file mode 100644 index 03bb274581..0000000000 --- a/cg/services/orders/submitters/pacbio_order_submitter.py +++ /dev/null @@ -1,26 +0,0 @@ -from cg.models.orders.order import OrderIn -from cg.services.orders.store_order_services.store_pacbio_order_service import ( - StorePacBioOrderService, -) -from cg.services.orders.submitters.order_submitter import OrderSubmitter -from cg.services.orders.validate_order_services.validate_pacbio_order import ( - ValidatePacbioOrderService, -) - - -class PacbioOrderSubmitter(OrderSubmitter): - """Submitter for Pacbio orders.""" - - def __init__( - self, - order_validation_service: ValidatePacbioOrderService, - order_store_service: StorePacBioOrderService, - ): - self.order_validation_service = order_validation_service - self.order_store_service = order_store_service - - def submit_order(self, order_in: OrderIn) -> dict: - """Submit a fastq order.""" - self.order_validation_service.validate_order(order_in) - result: dict = self.order_store_service.store_order(order_in) - return result diff --git a/cg/services/orders/submitters/pool_order_submitter.py b/cg/services/orders/submitters/pool_order_submitter.py deleted file mode 100644 index 6d9b3290ef..0000000000 --- a/cg/services/orders/submitters/pool_order_submitter.py +++ /dev/null @@ -1,26 +0,0 @@ -from cg.models.orders.order import OrderIn -from cg.services.orders.store_order_services.store_pool_order import StorePoolOrderService -from cg.services.orders.submitters.order_submitter import OrderSubmitter -from cg.services.orders.validate_order_services.validate_pool_order import ValidatePoolOrderService - - -class PoolOrderSubmitter(OrderSubmitter): - """ - Class for submitting pool orders. - This class is used to submit orders for the following workflows: - - Fluffy - - RML (Ready made libraries) - - """ - - def __init__( - self, - order_validation_service: ValidatePoolOrderService, - order_store_service: StorePoolOrderService, - ): - self.order_validation_service = order_validation_service - self.order_store_service = order_store_service - - def submit_order(self, order_in: OrderIn) -> dict: - self.order_validation_service.validate_order(order_in) - return self.order_store_service.store_order(order_in) diff --git a/cg/services/orders/validate_order_services/validate_case_order.py b/cg/services/orders/validate_order_services/validate_case_order.py deleted file mode 100644 index 01dc68a184..0000000000 --- a/cg/services/orders/validate_order_services/validate_case_order.py +++ /dev/null @@ -1,102 +0,0 @@ -from cg.exc import OrderError -from cg.models.orders.constants import OrderType -from cg.models.orders.order import OrderIn -from cg.models.orders.samples import Of1508Sample, OrderInSample -from cg.services.orders.submitters.order_submitter import ValidateOrderService -from cg.store.models import Sample, Customer -from cg.store.store import Store - - -class ValidateCaseOrderService(ValidateOrderService): - - def __init__(self, status_db: Store): - self.status_db = status_db - - def validate_order(self, order: OrderIn) -> None: - self._validate_subject_sex(samples=order.samples, customer_id=order.customer) - self._validate_samples_available_to_customer( - samples=order.samples, customer_id=order.customer - ) - self._validate_case_names_are_unique(samples=order.samples, customer_id=order.customer) - if order.order_type == OrderType.RNAFUSION: - self._validate_only_one_sample_per_case(samples=order.samples) - - def _validate_subject_sex(self, samples: [Of1508Sample], customer_id: str): - """Validate that sex is consistent with existing samples, skips samples of unknown sex - - Args: - samples (list[dict]): Samples to validate - customer_id (str): Customer that the samples belong to - Returns: - Nothing - """ - sample: Of1508Sample - for sample in samples: - subject_id: str = sample.subject_id - if not subject_id: - continue - new_gender: str = sample.sex - if new_gender == "unknown": - continue - - existing_samples: list[Sample] = self.status_db.get_samples_by_customer_and_subject_id( - customer_internal_id=customer_id, subject_id=subject_id - ) - existing_sample: Sample - for existing_sample in existing_samples: - previous_gender = existing_sample.sex - if previous_gender == "unknown": - continue - - if previous_gender != new_gender: - raise OrderError( - f"Sample gender inconsistency for subject_id: {subject_id}: previous gender {previous_gender}, new gender {new_gender}" - ) - - def _validate_samples_available_to_customer( - self, samples: list[OrderInSample], customer_id: str - ) -> None: - """Validate that the customer have access to all samples""" - sample: Of1508Sample - for sample in samples: - if not sample.internal_id: - continue - - existing_sample: Sample = self.status_db.get_sample_by_internal_id( - internal_id=sample.internal_id - ) - - data_customer: Customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - - if existing_sample.customer not in data_customer.collaborators: - raise OrderError(f"Sample not available: {sample.name}") - - def _validate_case_names_are_unique( - self, samples: list[OrderInSample], customer_id: str - ) -> None: - """Validate that the names of all cases are unused for all samples""" - - customer: Customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - - sample: Of1508Sample - for sample in samples: - if self._is_rerun_of_existing_case(sample=sample): - continue - if self.status_db.get_case_by_name_and_customer( - customer=customer, case_name=sample.family_name - ): - raise OrderError(f"Case name {sample.family_name} already in use") - - @staticmethod - def _is_rerun_of_existing_case(sample: Of1508Sample) -> bool: - return sample.case_internal_id is not None - - @staticmethod - def _validate_only_one_sample_per_case(samples: list[Of1508Sample]) -> None: - """Validates that each case contains only one sample.""" - if len({sample.family_name for sample in samples}) != len(samples): - raise OrderError("Each case in an RNAFUSION order must have exactly one sample.") diff --git a/cg/services/orders/validate_order_services/validate_fastq_order.py b/cg/services/orders/validate_order_services/validate_fastq_order.py deleted file mode 100644 index cbfe5728a7..0000000000 --- a/cg/services/orders/validate_order_services/validate_fastq_order.py +++ /dev/null @@ -1,12 +0,0 @@ -from cg.models.orders.order import OrderIn -from cg.services.orders.submitters.order_submitter import ValidateOrderService -from cg.store.store import Store - - -class ValidateFastqOrderService(ValidateOrderService): - - def __init__(self, status_db: Store): - self.status_db = status_db - - def validate_order(self, order: OrderIn) -> None: - pass diff --git a/cg/services/orders/validate_order_services/validate_metagenome_order.py b/cg/services/orders/validate_order_services/validate_metagenome_order.py deleted file mode 100644 index 330abc985e..0000000000 --- a/cg/services/orders/validate_order_services/validate_metagenome_order.py +++ /dev/null @@ -1,30 +0,0 @@ -from cg.exc import OrderError -from cg.models.orders.order import OrderIn -from cg.models.orders.samples import MetagenomeSample -from cg.services.orders.submitters.order_submitter import ValidateOrderService -from cg.store.models import Customer -from cg.store.store import Store - - -class ValidateMetagenomeOrderService(ValidateOrderService): - - def __init__(self, status_db: Store): - self.status_db = status_db - - def validate_order(self, order: OrderIn) -> None: - self._validate_sample_names_are_unique(samples=order.samples, customer_id=order.customer) - - def _validate_sample_names_are_unique( - self, samples: list[MetagenomeSample], customer_id: str - ) -> None: - """Validate that the names of all samples are unused.""" - customer: Customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - for sample in samples: - if sample.control: - continue - if self.status_db.get_sample_by_customer_and_name( - customer_entry_id=[customer.id], sample_name=sample.name - ): - raise OrderError(f"Sample name {sample.name} already in use") diff --git a/cg/services/orders/validate_order_services/validate_microbial_order.py b/cg/services/orders/validate_order_services/validate_microbial_order.py deleted file mode 100644 index 1590e6b984..0000000000 --- a/cg/services/orders/validate_order_services/validate_microbial_order.py +++ /dev/null @@ -1,43 +0,0 @@ -from cg.exc import OrderError -from cg.models.orders.constants import OrderType -from cg.models.orders.order import OrderIn -from cg.models.orders.samples import MicrobialFastqSample, SarsCov2Sample -from cg.services.orders.submitters.order_submitter import ValidateOrderService -from cg.store.models import Customer -from cg.store.store import Store - - -class ValidateMicrobialOrderService(ValidateOrderService): - - def __init__(self, status_db: Store): - self.status_db = status_db - - def validate_order(self, order: OrderIn) -> None: - if order.order_type == OrderType.SARS_COV_2: - self._validate_sample_names_are_available( - samples=order.samples, customer_id=order.customer, is_sars_cov_2=True - ) - elif order.order_type == OrderType.MICROBIAL_FASTQ: - self._validate_sample_names_are_available( - samples=order.samples, customer_id=order.customer, is_sars_cov_2=False - ) - - def _validate_sample_names_are_available( - self, - samples: list[SarsCov2Sample] | list[MicrobialFastqSample], - customer_id: str, - is_sars_cov_2: bool, - ) -> None: - """Validate names of all samples are not already in use.""" - customer: Customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - for sample in samples: - if is_sars_cov_2 and sample.control: - continue - if self.status_db.get_sample_by_customer_and_name( - customer_entry_id=[customer.id], sample_name=sample.name - ): - raise OrderError( - f"Sample name {sample.name} already in use for customer {customer.name}" - ) diff --git a/cg/services/orders/validate_order_services/validate_pacbio_order.py b/cg/services/orders/validate_order_services/validate_pacbio_order.py deleted file mode 100644 index 30af38e669..0000000000 --- a/cg/services/orders/validate_order_services/validate_pacbio_order.py +++ /dev/null @@ -1,45 +0,0 @@ -from cg.exc import OrderError -from cg.models.orders.order import OrderIn -from cg.models.orders.samples import PacBioSample -from cg.services.orders.submitters.order_submitter import ValidateOrderService -from cg.store.models import ApplicationVersion, Customer -from cg.store.store import Store - - -class ValidatePacbioOrderService(ValidateOrderService): - - def __init__(self, status_db: Store): - self.status_db = status_db - - def validate_order(self, order: OrderIn) -> None: - self._validate_customer_exists(order.customer) - self._validate_applications_exist(order.samples) - self._validate_sample_names_available(samples=order.samples, customer_id=order.customer) - - def _validate_customer_exists(self, customer_id: str) -> None: - customer: Customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - if not customer: - raise OrderError(f"Unknown customer: {customer_id}") - - def _validate_applications_exist(self, samples: list[PacBioSample]) -> None: - for sample in samples: - application_tag = sample.application - application_version: ApplicationVersion = ( - self.status_db.get_current_application_version_by_tag(tag=application_tag) - ) - if application_version is None: - raise OrderError(f"Invalid application: {sample.application}") - - def _validate_sample_names_available( - self, samples: list[PacBioSample], customer_id: str - ) -> None: - customer: Customer = self.status_db.get_customer_by_internal_id(customer_id) - for sample in samples: - if self.status_db.get_sample_by_customer_and_name( - customer_entry_id=[customer.id], sample_name=sample.name - ): - raise OrderError( - f"Sample name already used in a previous order by the same customer: {sample.name}" - ) diff --git a/cg/services/orders/validate_order_services/validate_pool_order.py b/cg/services/orders/validate_order_services/validate_pool_order.py deleted file mode 100644 index 4206a16e29..0000000000 --- a/cg/services/orders/validate_order_services/validate_pool_order.py +++ /dev/null @@ -1,35 +0,0 @@ -from cg.exc import OrderError -from cg.models.orders.order import OrderIn -from cg.models.orders.samples import RmlSample -from cg.services.orders.submitters.order_submitter import ValidateOrderService -from cg.store.models import Customer -from cg.store.store import Store - - -class ValidatePoolOrderService(ValidateOrderService): - - def __init__(self, status_db: Store): - self.status_db = status_db - - def validate_order(self, order: OrderIn) -> None: - self._validate_case_names_are_available( - customer_id=order.customer, samples=order.samples, ticket=order.ticket - ) - - def _validate_case_names_are_available( - self, customer_id: str, samples: list[RmlSample], ticket: str - ): - """Validate names of all samples are not already in use.""" - customer: Customer = self.status_db.get_customer_by_internal_id( - customer_internal_id=customer_id - ) - for sample in samples: - case_name: str = self.create_case_name(pool_name=sample.pool, ticket=ticket) - if self.status_db.get_case_by_name_and_customer(customer=customer, case_name=case_name): - raise OrderError( - f"Case name {case_name} already in use for customer {customer.name}" - ) - - @staticmethod - def create_case_name(ticket: str, pool_name: str) -> str: - return f"{ticket}-{pool_name}" diff --git a/cg/services/orders/validation/__init__.py b/cg/services/orders/validation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/orders/validation/constants.py b/cg/services/orders/validation/constants.py new file mode 100644 index 0000000000..c621a8b404 --- /dev/null +++ b/cg/services/orders/validation/constants.py @@ -0,0 +1,48 @@ +from enum import StrEnum, auto + + +class TissueBlockEnum(StrEnum): + SMALL: str = auto() + LARGE: str = auto() + BLANK: str = "" + + +class ElutionBuffer(StrEnum): + """The choices of buffers.""" + + OTHER = "Other" + TRIS_HCL = "Tris-HCl" + WATER = "Nuclease-free water" + + +ALLOWED_SKIP_RC_BUFFERS = [ElutionBuffer.TRIS_HCL, ElutionBuffer.WATER] + +MINIMUM_VOLUME, MAXIMUM_VOLUME = 20, 130 + + +class ExtractionMethod(StrEnum): + EZ1 = "EZ1" + MAELSTROM = "Maelstrom" + MAGNAPURE_96 = "MagNaPure 96" + QIAGEN_MAGATTRACT = "Qiagen MagAttract" + QIASYMPHONE = "QIAsymphony" + OTHER = 'Other (specify in "Comments")' + + +class IndexEnum(StrEnum): + AVIDA_INDEX_PLATE = "Avida Index plate" + AVIDA_INDEX_STRIP = "Avida Index strip" + IDT_DS_B = "IDT DupSeq 10 bp Set B" + IDT_DS_F = "IDT DupSeq 10 bp Set F" + IDT_XGEN_UDI = "IDT xGen UDI Adapters" + KAPA_UDI_NIPT = "KAPA UDI NIPT" + NEXTERA_XT = "Nextera XT Dual" + NEXTFLEX_UDI_96 = "NEXTflex® Unique Dual Index Barcodes 1 - 96" + NEXTFLEX_V2_UDI_96 = "NEXTflex® v2 UDI Barcodes 1 - 96" + TEN_X_TN_A = "10X Genomics Dual Index kit TN Set A" + TEN_X_TT_A = "10X Genomics Dual Index kit TT Set A" + TWIST_UDI_A = "TWIST UDI Set A" + TWIST_UDI_B = "TWIST UDI Set B" + TWIST_UDI_C = "TWIST UDI Set C" + TRUSEQ_DNA_HT = "TruSeq DNA HT Dual-index (D7-D5)" + NO_INDEX = "NoIndex" diff --git a/cg/services/orders/validation/errors/__init__.py b/cg/services/orders/validation/errors/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/orders/validation/errors/case_errors.py b/cg/services/orders/validation/errors/case_errors.py new file mode 100644 index 0000000000..3054f1376f --- /dev/null +++ b/cg/services/orders/validation/errors/case_errors.py @@ -0,0 +1,58 @@ +from cg.services.orders.validation.errors.order_errors import OrderError + + +class CaseError(OrderError): + case_index: int + + +class RepeatedCaseNameError(CaseError): + field: str = "name" + message: str = "Case name already used" + + +class InvalidGenePanelsError(CaseError): + def __init__(self, case_index: int, panels: list[str]): + message = "Invalid panels: " + ",".join(panels) + super(CaseError, self).__init__(field="panels", case_index=case_index, message=message) + + +class RepeatedGenePanelsError(CaseError): + field: str = "panels" + message: str = "Gene panels must be unique" + + +class CaseNameNotAvailableError(CaseError): + field: str = "name" + message: str = "Case name already used in a previous order" + + +class CaseDoesNotExistError(CaseError): + field: str = "internal_id" + message: str = "The case does not exist" + + +class CaseOutsideOfCollaborationError(CaseError): + field: str = "internal_id" + message: str = "Case does not belong to collaboration" + + +class MultipleSamplesInCaseError(CaseError): + field: str = "sample_errors" + message: str = "Multiple samples in the same case not allowed" + + +class MoreThanTwoSamplesInCaseError(CaseError): + field: str = "sample_errors" + message: str = "More than two samples in the same case not allowed" + + +class NumberOfNormalSamplesError(CaseError): + field: str = "sample_errors" + + +class DoubleNormalError(NumberOfNormalSamplesError): + message: str = "Only one non-tumour sample is allowed per case" + + +class DoubleTumourError(NumberOfNormalSamplesError): + message: str = "Only one tumour sample is allowed per case" diff --git a/cg/services/orders/validation/errors/case_sample_errors.py b/cg/services/orders/validation/errors/case_sample_errors.py new file mode 100644 index 0000000000..f68b341361 --- /dev/null +++ b/cg/services/orders/validation/errors/case_sample_errors.py @@ -0,0 +1,172 @@ +from cg.services.orders.validation.constants import MAXIMUM_VOLUME, MINIMUM_VOLUME +from cg.services.orders.validation.errors.case_errors import CaseError +from cg.services.orders.validation.errors.sample_errors import SampleError + + +class CaseSampleError(CaseError, SampleError): + pass + + +class OccupiedWellError(CaseSampleError): + field: str = "well_position" + message: str = "Well is already occupied" + + +class ApplicationArchivedError(CaseSampleError): + field: str = "application" + message: str = "Chosen application is archived" + + +class ApplicationNotValidError(CaseSampleError): + field: str = "application" + message: str = "Chosen application does not exist" + + +class ApplicationNotCompatibleError(CaseSampleError): + field: str = "application" + message: str = "Application is not allowed for the chosen workflow" + + +class SampleNameRepeatedError(CaseSampleError): + field: str = "name" + message: str = "Sample name already used" + + +class SampleNameSameAsCaseNameError(CaseSampleError): + field: str = "name" + message: str = "Sample name can not be the same as any case name in order" + + +class InvalidFatherSexError(CaseSampleError): + field: str = "father" + message: str = "Father must be male" + + +class FatherNotInCaseError(CaseSampleError): + field: str = "father" + message: str = "Father must be in the same case" + + +class InvalidMotherSexError(CaseSampleError): + field: str = "mother" + message: str = "Mother must be female" + + +class PedigreeError(CaseSampleError): + message: str = "Invalid pedigree relationship" + + +class DescendantAsMotherError(PedigreeError): + field: str = "mother" + message: str = "Descendant sample cannot be mother" + + +class DescendantAsFatherError(PedigreeError): + field: str = "father" + message: str = "Descendant sample cannot be father" + + +class SampleIsOwnMotherError(PedigreeError): + field: str = "mother" + message: str = "Sample cannot be its own mother" + + +class SampleIsOwnFatherError(PedigreeError): + field: str = "father" + message: str = "Sample cannot be its own father" + + +class MotherNotInCaseError(CaseSampleError): + field: str = "mother" + message: str = "Mother must be in the same case" + + +class SampleDoesNotExistError(CaseSampleError): + field: str = "internal_id" + message: str = "The sample does not exist" + + +class SubjectIdSameAsCaseNameError(CaseSampleError): + field: str = "subject_id" + message: str = "Subject id must be different from the case name" + + +class ConcentrationRequiredIfSkipRCError(CaseSampleError): + field: str = "concentration_ng_ul" + message: str = "Concentration is required when skipping reception control" + + +class SubjectIdSameAsSampleNameError(CaseSampleError): + field: str = "subject_id" + message: str = "Subject id must be different from the sample name" + + +class InvalidConcentrationIfSkipRCError(CaseSampleError): + def __init__(self, case_index: int, sample_index: int, allowed_interval: tuple[float, float]): + field: str = "concentration_ng_ul" + message: str = ( + f"Concentration must be between {allowed_interval[0]} ng/μL and {allowed_interval[1]} ng/μL if reception control should be skipped" + ) + super(CaseSampleError, self).__init__( + case_index=case_index, sample_index=sample_index, field=field, message=message + ) + + +class WellPositionMissingError(CaseSampleError): + field: str = "well_position" + message: str = "Well position is required for well plates" + + +class ContainerNameMissingError(CaseSampleError): + field: str = "container_name" + message: str = "Container name is required for well plates" + + +class InvalidVolumeError(CaseSampleError): + field: str = "volume" + message: str = f"Volume must be between {MINIMUM_VOLUME}-{MAXIMUM_VOLUME} μL" + + +class VolumeRequiredError(CaseSampleError): + field: str = "volume" + message: str = "Volume is required" + + +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" + + +class CaptureKitMissingError(CaseSampleError): + field: str = "capture_kit" + message: str = "Bait set is required for TGS analyses" + + +class WellFormatError(CaseSampleError): + field: str = "well_position" + message: str = "Well position must follow the format A-H:1-12" + + +class ContainerNameRepeatedError(CaseSampleError): + field: str = "container_name" + message: str = "Tube names must be unique among samples" + + +class StatusUnknownError(CaseSampleError): + field: str = "status" + message: str = "Samples in case cannot all have status unknown" + + +class BufferMissingError(CaseSampleError): + field: str = "elution_buffer" + message: str = "Buffer must be specified with this application" + + +class SampleOutsideOfCollaborationError(CaseSampleError): + field: str = "internal_id" + message: str = "Sample cannot be outside of collaboration" diff --git a/cg/services/orders/validation/errors/order_errors.py b/cg/services/orders/validation/errors/order_errors.py new file mode 100644 index 0000000000..64f68e8609 --- /dev/null +++ b/cg/services/orders/validation/errors/order_errors.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel + + +class OrderError(BaseModel): + field: str + message: str + + +class UserNotAssociatedWithCustomerError(OrderError): + field: str = "customer" + message: str = "User does not belong to customer" + + +class CustomerCannotSkipReceptionControlError(OrderError): + field: str = "skip_reception_control" + message: str = "Customer cannot skip reception control" + + +class CustomerDoesNotExistError(OrderError): + field: str = "customer" + message: str = "Customer does not exist" + + +class OrderNameRequiredError(OrderError): + field: str = "name" + message: str = "Order name is required" diff --git a/cg/services/orders/validation/errors/sample_errors.py b/cg/services/orders/validation/errors/sample_errors.py new file mode 100644 index 0000000000..c0b48d4a12 --- /dev/null +++ b/cg/services/orders/validation/errors/sample_errors.py @@ -0,0 +1,142 @@ +from cg.services.orders.validation.constants import MAXIMUM_VOLUME, MINIMUM_VOLUME, IndexEnum +from cg.services.orders.validation.errors.order_errors import OrderError +from cg.services.orders.validation.index_sequences import INDEX_SEQUENCES + + +class SampleError(OrderError): + sample_index: int + + +class ApplicationNotValidError(SampleError): + field: str = "application" + message: str = "Chosen application does not exist" + + +class ApplicationArchivedError(SampleError): + field: str = "application" + message: str = "Chosen application is archived" + + +class ApplicationNotCompatibleError(SampleError): + field: str = "application" + message: str = "Chosen application is not compatible with workflow" + + +class OccupiedWellError(SampleError): + field: str = "well_position" + message: str = "Well is already occupied" + + +class WellPositionMissingError(SampleError): + field: str = "well_position" + message: str = "Well position is required for well plates" + + +class WellPositionRmlMissingError(SampleError): + field: str = "well_position_rml" + message: str = "Well position is required for RML plates" + + +class SampleNameRepeatedError(SampleError): + field: str = "name" + message: str = "Sample name repeated" + + +class InvalidVolumeError(SampleError): + field: str = "volume" + message: str = f"Volume must be between {MINIMUM_VOLUME}-{MAXIMUM_VOLUME} μL" + + +class VolumeRequiredError(SampleError): + field: str = "volume" + message: str = "Volume is required" + + +class SampleNameNotAvailableError(SampleError): + field: str = "name" + message: str = "Sample name already used in previous order" + + +class SampleNameNotAvailableControlError(SampleError): + field: str = "name" + message: str = "Sample name already in use. Only control samples are allowed repeated names" + + +class ContainerNameRepeatedError(SampleError): + field: str = "container_name" + message: str = "Tube names must be unique among samples" + + +class WellFormatError(SampleError): + field: str = "well_position" + message: str = "Well position must follow the format A-H:1-12" + + +class WellFormatRmlError(SampleError): + field: str = "well_position_rml" + message: str = "Well position must follow the format A-H:1-12" + + +class ContainerNameMissingError(SampleError): + field: str = "container_name" + message: str = "Container must have a name" + + +class BufferInvalidError(SampleError): + field: str = "elution_buffer" + message: str = "Buffer must be Tris-HCl or Nuclease-free water when skipping reception control." + + +class ConcentrationRequiredError(SampleError): + field: str = "concentration_ng_ul" + message: str = "Concentration is required when skipping reception control." + + +class ConcentrationInvalidIfSkipRCError(SampleError): + def __init__(self, sample_index: int, allowed_interval: tuple[float, float]): + field: str = "concentration_ng_ul" + message: str = ( + f"Concentration must be between {allowed_interval[0]} ng/μL and " + f"{allowed_interval[1]} ng/μL if reception control should be skipped" + ) + super(SampleError, self).__init__(sample_index=sample_index, field=field, message=message) + + +class PoolApplicationError(SampleError): + def __init__(self, sample_index: int, pool_name: str): + field: str = "application" + message: str = f"Multiple applications detected in pool {pool_name}" + super(SampleError, self).__init__(sample_index=sample_index, field=field, message=message) + + +class PoolPriorityError(SampleError): + def __init__(self, sample_index: int, pool_name: str): + field: str = "priority" + message: str = f"Multiple priorities detected in pool {pool_name}" + super(SampleError, self).__init__(sample_index=sample_index, field=field, message=message) + + +class IndexNumberMissingError(SampleError): + field: str = "index_number" + message: str = "Index number is required" + + +class IndexNumberOutOfRangeError(SampleError): + def __init__(self, sample_index: int, index: IndexEnum): + field: str = "index_number" + maximum: int = len(INDEX_SEQUENCES[index]) + message: str = f"Index number must be a number between 1 and {maximum}" + super(SampleError, self).__init__(sample_index=sample_index, field=field, message=message) + + +class IndexSequenceMissingError(SampleError): + field: str = "index_sequence" + message: str = "Index sequence is required" + + +class IndexSequenceMismatchError(SampleError): + def __init__(self, sample_index: int, index: IndexEnum, index_number): + field: str = "index_number" + allowed_sequence: str = INDEX_SEQUENCES[index][index_number - 1] + message: str = f"Index and index number indicate sequence {allowed_sequence}" + super(SampleError, self).__init__(sample_index=sample_index, field=field, message=message) diff --git a/cg/services/orders/validation/errors/validation_errors.py b/cg/services/orders/validation/errors/validation_errors.py new file mode 100644 index 0000000000..a0e2be4f4a --- /dev/null +++ b/cg/services/orders/validation/errors/validation_errors.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel + +from cg.services.orders.validation.errors.case_errors import CaseError +from cg.services.orders.validation.errors.case_sample_errors import CaseSampleError +from cg.services.orders.validation.errors.order_errors import OrderError +from cg.services.orders.validation.errors.sample_errors import SampleError + + +class ValidationErrors(BaseModel): + order_errors: list[OrderError] = [] + case_errors: list[CaseError] = [] + sample_errors: list[SampleError] = [] + case_sample_errors: list[CaseSampleError] = [] + + @property + def is_empty(self) -> bool: + """Return True if there are no errors in any of the attributes.""" + return all(not getattr(self, field) for field in self.model_fields) + + def get_error_message(self) -> str: + """Gets a string documenting all errors.""" + error_string = "" + for error in self.order_errors: + error_string += f"Problem with {error.field}: {error.message} \n" + for error in self.case_errors: + error_string += ( + f"Problem with {error.field} in case {error.case_index}: {error.message} \n" + ) + for error in self.case_sample_errors: + error_string += f"Problem with {error.field} in case {error.case_index} sample {error.sample_index}: {error.message} \n" + for error in self.sample_errors: + error_string += ( + f"Problem with {error.field} in sample {error.sample_index}: {error.message} \n" + ) + return error_string diff --git a/cg/services/orders/validation/index_sequences.py b/cg/services/orders/validation/index_sequences.py new file mode 100644 index 0000000000..a602c96654 --- /dev/null +++ b/cg/services/orders/validation/index_sequences.py @@ -0,0 +1,1731 @@ +from cg.services.orders.validation.constants import IndexEnum + +INDEX_SEQUENCES = { + IndexEnum.AVIDA_INDEX_PLATE: [ + "A01-clear (TACGATAC-CACGTGAG)", + "B01-clear (CGTTCGTC-GCAGTTAG)", + "C01-clear (GCGAATTA-CTTGATCA)", + "D01-clear (GCATGCCT-CGTAACTG)", + "E01-clear (TCGAGCAT-CGTTAACG)", + "F01-clear (AAGGTCGA-GCCAGTAG)", + "G01-clear (CGTAACTG-TGTCGTCG)", + "H01-clear (TCGATACA-TTAGTGCG)", + "A02-clear (CGTAGTTA-GTAGACTG)", + "B02-clear (GAGTGCGT-TCGCGTCA)", + "C02-clear (CGAATTCA-TTGGCATG)", + "D02-clear (TCTGAGTC-ATCATGCG)", + "E02-clear (AGCTACAT-GATCCATG)", + "F02-clear (CACACATA-CTCGTACG)", + "G02-clear (AGAACCGT-TACGGTTG)", + "H02-clear (ATACCTAC-TCCTTGAG)", + "A03-clear (AAGAGACA-TGCTTACG)", + "B03-clear (AGTCTTCA-CAGTATCA)", + "C03-clear (GATCGCCT-TGTATCAC)", + "D03-clear (GTAGTCAT-AACTCTTG)", + "E03-clear (TCTATGCG-GCGGTATG)", + "F03-clear (GAGGCTCT-CTAGTGTA)", + "G03-clear (GCCTTCAT-AACGGTCA)", + "H03-clear (CTGAGCTA-GCACAGTA)", + "A04-clear (TCAACTGC-GCTACGCA)", + "B04-clear (GTGCGCTT-CATCGACG)", + "C04-clear (TGCGCTCT-GTCGCCTA)", + "D04-clear (AATACGCG-CAGGTCTG)", + "E04-clear (ATGGTAGC-GTCGATCG)", + "F04-clear (GAGCACTG-GAATTGTG)", + "G04-clear (CATTGCAT-CGACGGTA)", + "H04-clear (AACACCGA-CGATGTCG)", + "A05-clear (CATGAATG-TCTCCTTG)", + "B05-clear (CTGCCTTA-GCATGGAG)", + "C05-clear (GCCAGTAG-GTTCGAGC)", + "D05-clear (CGGTCTCT-GAGATCGC)", + "E05-clear (CAACCGTG-AAGTGGAC)", + "F05-clear (CATCAGTC-TTATCTCG)", + "G05-clear (GATGCCAA-TTAGTTGC)", + "H05-clear (TGTCGTCG-GTTATCGA)", + "A06-clear (GTCGAAGT-CAGTGAGC)", + "B06-clear (TCCGAACT-TCATACCG)", + "C06-clear (CGCCAATT-ATGGTAGC)", + "D06-clear (CAAGTTAC-TATTCCTG)", + "E06-clear (TGCCTCGA-CAGTTGCG)", + "F06-clear (AACCTAAC-CGTCTGTG)", + "G06-clear (GCCAACAA-GTAATACG)", + "H06-clear (CTTGATCA-CAATGCTG)", + "A07-clear (GTACGGAC-GCACGATG)", + "B07-clear (GAGCATAC-GTACCTTG)", + "C07-clear (TGGATTGA-TAAGAGTG)", + "D07-clear (AGCAGTGA-CACAGGTG)", + "E07-clear (TCGACAAC-CTCTCGTG)", + "F07-clear (AAGTGGAC-TACGTAGC)", + "G07-clear (GTCCACCT-AGTTGCCG)", + "H07-clear (GACTTGAC-GCATCATA)", + "A08-clear (TGCACATC-GAGTGTAG)", + "B08-clear (AAGTACTC-GATAGGAC)", + "C08-clear (CACAGACT-TCTGTCAG)", + "D08-clear (GAAGGTAT-CGACTACG)", + "E08-clear (CGCGCAAT-CAGAGCAG)", + "F08-clear (CAATAGAC-ATGACTCG)", + "G08-clear (AGATACGA-CATACTTG)", + "H08-clear (TCACAAGA-TTGTGTAC)", + "A09-clear (TCGCCTGT-TCTGAGTC)", + "B09-clear (AGAGTAAG-GAGACACG)", + "C09-clear (ATATTCCG-GACAATTG)", + "D09-clear (CACAACTT-AGGTGAGA)", + "E09-clear (CGCTTCCA-TCGAACTG)", + "F09-clear (TAAGAGTG-AGAAGACG)", + "G09-clear (AATGGAAC-GTTGCGGA)", + "H09-clear (TCAGCATC-AAGGAGCG)", + "A10-clear (AGTACGTT-AGAGTAAG)", + "B10-clear (AATGCTAG-ATACTCGC)", + "C10-clear (TCTAGAGC-GATAGAGA)", + "D10-clear (GCCGATAT-AGACCTGA)", + "E10-clear (TCAGCTCA-TATGCCGC)", + "F10-clear (CGTCTGTG-TTAACGTG)", + "G10-clear (CAGTCATT-TCGAAGGA)", + "H10-clear (GCACACAT-AATACGCG)", + "A11-clear (ATTGAGCT-TCACTGTG)", + "B11-clear (TTGGATCT-TGCCGTTA)", + "C11-clear (CAGCAATA-ATTAGGAG)", + "D11-clear (TCTACCTC-ATATTCCG)", + "E11-clear (CTTAAGAC-GACTGACG)", + "F11-clear (TCCTGAGA-GTAGAGCA)", + "G11-clear (CTAGTGTA-AACAGCCG)", + "H11-clear (TTCCAACA-GCTTAGCG)", + "A12-clear (CTTGCGAT-CTGAGCTA)", + "B12-clear (GTTCATTC-GTCGGTAA)", + "C12-clear (CTTCACGT-GAATCGCA)", + "D12-clear (AACGCATT-ATAGTGAC)", + "E12-clear (CGTCAGAT-CGTAGTTA)", + "F12-clear (TCTGTCAG-CGTAAGCA)", + "G12-clear (TAGGACAT-CATCGGAA)", + "H12-clear (GCTTAGCG-ATAAGCTG)", + "A01-blue (CTGAATAG-TCCTGAGA)", + "B01-blue (AAGTGTCT-GCAGGTTC)", + "C01-blue (GAATCGCA-TCTAGAGC)", + "D01-blue (CAATCCGA-CACATTCG)", + "E01-blue (GCCTCGTT-AGGCAATG)", + "F01-blue (CATCCTGT-GCTTCCAG)", + "G01-blue (CTGTTGTT-CAACCGTG)", + "H01-blue (CTGACCGT-AGAGGTTA)", + "A02-blue (CAATGCTG-CATGAATG)", + "B02-blue (GTTGGTGT-AGTTCATG)", + "C02-blue (TAGACCAA-CAAGGTGA)", + "D02-blue (TCAAGCTA-CTTCGTAC)", + "E02-blue (TATGCCGC-ATTCCGTG)", + "F02-blue (TCGCAGAT-TTGATGGC)", + "G02-blue (GAACATGT-GACGTGCA)", + "H02-blue (GAGTGTAG-CTTAAGAC)", + "A03-blue (AAGGCTTA-TGCCTCGA)", + "B03-blue (TTAACGTG-TTGGAGAA)", + "C03-blue (CGATACCT-CTTATTGC)", + "D03-blue (GTCAGAAT-CGATATGC)", + "E03-blue (CGAATCAT-TGAAGTAC)", + "F03-blue (GAACGCAA-GCTTGCTA)", + "G03-blue (TGTTGGTT-GATTCGGC)", + "H03-blue (TCCAGACG-TGGCTTAG)", + "A04-blue (CATAGCCA-AGTAACGC)", + "B04-blue (CAAGGTGA-ATGGTTAG)", + "C04-blue (TCACCGAC-GTGTTGAG)", + "D04-blue (GAGCTGTA-GCCGTAAC)", + "E04-blue (GTCTCTTC-GAAGCCTC)", + "F04-blue (GAATCCGA-TTACACGC)", + "G04-blue (CGTCTGCA-GCCGATAT)", + "H04-blue (CGAACTGA-TCGCCTGT)", + ], + IndexEnum.AVIDA_INDEX_STRIP: [ + "1-black (TACGATAC-CACGTGAG)", + "2-black (CGTTCGTC-GCAGTTAG)", + "3-black (GCGAATTA-CTTGATCA)", + "4-black (GCATGCCT-CGTAACTG)", + "5-black (TCGAGCAT-CGTTAACG)", + "6-black (AAGGTCGA-GCCAGTAG)", + "7-black (CGTAACTG-TGTCGTCG)", + "8-black (TCGATACA-TTAGTGCG)", + "9-blue (CGTAGTTA-GTAGACTG)", + "10-blue (GAGTGCGT-TCGCGTCA)", + "11-blue (CGAATTCA-TTGGCATG)", + "12-blue (TCTGAGTC-ATCATGCG)", + "13-blue (AGCTACAT-GATCCATG)", + "14-blue (CACACATA-CTCGTACG)", + "15-blue (AGAACCGT-TACGGTTG)", + "16-blue (ATACCTAC-TCCTTGAG)", + "17-red (AAGAGACA-TGCTTACG)", + "18-red (AGTCTTCA-CAGTATCA)", + "19-red (GATCGCCT-TGTATCAC)", + "20-red (GTAGTCAT-AACTCTTG)", + "21-red (TCTATGCG-GCGGTATG)", + "22-red (GAGGCTCT-CTAGTGTA)", + "23-red (GCCTTCAT-AACGGTCA)", + "24-red (CTGAGCTA-GCACAGTA)", + "25-white (TCAACTGC-GCTACGCA)", + "26-white (GTGCGCTT-CATCGACG)", + "27-white (TGCGCTCT-GTCGCCTA)", + "28-white (AATACGCG-CAGGTCTG)", + "29-white (ATGGTAGC-GTCGATCG)", + "30-white (GAGCACTG-GAATTGTG)", + "31-white (CATTGCAT-CGACGGTA)", + "32-white (AACACCGA-CGATGTCG)", + ], + IndexEnum.IDT_DS_B: [ + "A01 IDT_10nt_541 (ATTCCACACT-AACAAGACCA)", + "B01 IDT_10nt_553 (TACTAGAGGA-AGACCGGTTC)", + "C01 IDT_10nt_568 (TGTGAGCGAA-AACTCCGATC)", + "D01 IDT_10nt_581 (GATGAAGGAT-AGCCTTATTC)", + "E01 IDT_10nt_595 (AACAACCTCA-CTTAGTCCAA)", + "F01 IDT_10nt_607 (AGATCTTCCA-AGATCAACAC)", + "G01 IDT_10nt_620 (ACCGACGTGA-CATGACCGTT)", + "H01 IDT_10nt_635 (AATCTCAGGC-GAGTTAGAGA)", + "A02 IDT_10nt_542 (AGTTAGCTGG-CATTCTCATC)", + "B02 IDT_10nt_554 (CGATGAGACT-GCACGACTAA)", + "C02 IDT_10nt_569 (CTAGTCAGAA-CAGCATTCAA)", + "D02 IDT_10nt_582 (CGATAGCAGG-AATGCTACGA)", + "E02 IDT_10nt_596 (TATGGTGATG-GCTACTTGAA)", + "F02 IDT_10nt_608 (ACCAGACGGT-GATGAAGACG)", + "G02 IDT_10nt_621 (GTTGGACGGT-ACAGTGTCCT)", + "H02 IDT_10nt_637 (CCGGTTCATG-CAAGACGTCT)", + "A03 IDT_10nt_543 (TTGGCCATAC-TGCACTAGGC)", + "B03 IDT_10nt_555 (TCGCTTAAGC-ACACGCCTAG)", + "C03 IDT_10nt_570 (AAGACAGCTT-AGGTCAGCGA)", + "D03 IDT_10nt_583 (TATCACTCTC-AGAACAAGGT)", + "E03 IDT_10nt_597 (GCTGCTAACA-CACAAGAGCT)", + "F03 IDT_10nt_609 (AGCTATCTTC-CTAGCGACAC)", + "G03 IDT_10nt_622 (AGGTGGCTAC-CGTTACGTGG)", + "H03 IDT_10nt_639 (AGAGGAATCG-GATAGTCTGG)", + "A04 IDT_10nt_544 (TGCCACTGTA-TGACCTGCTG)", + "B04 IDT_10nt_556 (GACAATAGAG-GTGTACACTA)", + "C04 IDT_10nt_571 (AGACGGCATC-AGGTTACCTT)", + "D04 IDT_10nt_584 (ACTGGTGTCG-ACAGGACTTG)", + "E04 IDT_10nt_598 (TTAGGAGGAA-ACGCAAGTTC)", + "F04 IDT_10nt_610 (ACAGAAGGTT-GATATCTCCA)", + "G04 IDT_10nt_623 (GAGAGCTAAC-GCAAGTATTG)", + "H04 IDT_10nt_641 (TTCAGGTCGT-AACCAACCAA)", + "A05 IDT_10nt_545 (CTTGTCTTGC-CACATTGGTT)", + "B05 IDT_10nt_557 (AACGACGCAT-CGTTGTAGTT)", + "C05 IDT_10nt_573 (TTGGCCAGTT-CAGGTAGCAT)", + "D05 IDT_10nt_586 (ATTGCGGCTG-TTGTAGGACA)", + "E05 IDT_10nt_599 (AGAGACTTAC-CCAACTCTTA)", + "F05 IDT_10nt_611 (GCTGCCGATA-ACGTAGGATA)", + "G05 IDT_10nt_624 (GAATGTCCAA-TACGGTATTC)", + "H05 IDT_10nt_642 (CTATACACCA-CCGTAGTTGG)", + "A06 IDT_10nt_546 (ATACCTGTGA-AGCACAATGC)", + "B06 IDT_10nt_558 (TCAGATTCAC-TCGCACTCCA)", + "C06 IDT_10nt_574 (AGAGATGGTA-TTCCGTAAGC)", + "D06 IDT_10nt_587 (ATGGAGACGG-GAACTCTCAG)", + "E06 IDT_10nt_600 (GTGTACGTCG-CCTGCTAGGA)", + "F06 IDT_10nt_612 (TCACCGTCAC-TAAGGAGTTG)", + "G06 IDT_10nt_625 (TGTGAAGCTA-TGAATCGCGT)", + "H06 IDT_10nt_643 (GTGTTATCTC-AGTCTGGTGT)", + "A07 IDT_10nt_547 (AACACAGCCG-ACCAAGGTCT)", + "B07 IDT_10nt_560 (CCGACTCCTT-CAAGGACAGT)", + "C07 IDT_10nt_575 (GACGAACGTA-TGAAGGACGA)", + "D07 IDT_10nt_588 (CCACTGTGAC-CATCTGTCCA)", + "E07 IDT_10nt_601 (GAGCATCCAT-GTGGACGTGA)", + "F07 IDT_10nt_613 (GTTATTAGGC-GTTCATCGAG)", + "G07 IDT_10nt_627 (TTATCGCTGA-CCAATCCTAA)", + "H07 IDT_10nt_644 (CGCTTAAGTA-GTGCAGTAAT)", + "A08 IDT_10nt_548 (TTACGGAACA-GACAGAGTCA)", + "B08 IDT_10nt_561 (CTATAGCGAG-CTAGTCGACT)", + "C08 IDT_10nt_576 (ACGCGGACTT-CGAGGCAATA)", + "D08 IDT_10nt_589 (AGAATCCTTC-ACACCTGGCA)", + "E08 IDT_10nt_602 (CGGAACGAAG-AGGCGATGGT)", + "F08 IDT_10nt_614 (TATCGTTGTG-TGCGGACACA)", + "G08 IDT_10nt_628 (AACACAACGA-TTGGCTGCGT)", + "H08 IDT_10nt_645 (CACGGTTGGT-ATTCAGCTAG)", + "A09 IDT_10nt_549 (TCAGCACCGT-CCTATTCAGT)", + "B09 IDT_10nt_563 (TCAAGTTCCT-CTAGAACTTG)", + "C09 IDT_10nt_577 (AGACCTAGCT-GATACTTGTG)", + "D09 IDT_10nt_590 (GCCTTACTTA-GTTAAGGTGC)", + "E09 IDT_10nt_603 (GCCTAGGACT-AGCCGTTCTC)", + "F09 IDT_10nt_615 (ATCTTCCTAG-TACACGCGCA)", + "G09 IDT_10nt_630 (TTCCACCTGG-AGAAGCTCAT)", + "H09 IDT_10nt_646 (TTATCTGTGC-ATACTGAGTG)", + "A10 IDT_10nt_550 (ACTGTCAATC-GTTAAGGACG)", + "B10 IDT_10nt_564 (TTCACACAGT-AACACTGAAG)", + "C10 IDT_10nt_578 (CTGCGTACTC-TTGCCATCAG)", + "D10 IDT_10nt_591 (CAGAACGTGG-CGGTGAAGTC)", + "E10 IDT_10nt_604 (TGGATATGGC-ATACGCGCCA)", + "F10 IDT_10nt_616 (TATAACTCCG-AGGACACATA)", + "G10 IDT_10nt_631 (CTCTCATGCG-CAACCGACGT)", + "H10 IDT_10nt_647 (CAACCGATTA-TAACTCAGCA)", + "A11 IDT_10nt_551 (AATGTGGCGT-CTACCTGACA)", + "B11 IDT_10nt_566 (GCTACTGTCG-ATGGAGCAAG)", + "C11 IDT_10nt_579 (CGCAGAACTT-GACGTCGATA)", + "D11 IDT_10nt_592 (CCAAGACACT-CCATGTCGGT)", + "E11 IDT_10nt_605 (GTTCCACCGA-TAACAGCAGG)", + "F11 IDT_10nt_617 (GACGTTCTCT-TAGCTCTACT)", + "G11 IDT_10nt_632 (GCATACTCTA-TATCTCGCTA)", + "H11 IDT_10nt_648 (GAGCTCCACA-TTCTGTTCTG)", + "A12 IDT_10nt_552 (TCGTGGCTAG-TACTGCTGGC)", + "B12 IDT_10nt_567 (AGGACTGAAC-CGTCGCTTCA)", + "C12 IDT_10nt_580 (GCTGAGAGCT-CACCTATCAC)", + "D12 IDT_10nt_593 (GAGCTTATGA-AATGACTGGT)", + "E12 IDT_10nt_606 (AATGGTACTG-TAACTGCTGT)", + "F12 IDT_10nt_618 (CAGCCTTGAG-ATGCTACTCA)", + "G12 IDT_10nt_634 (TCACCAGGAC-GAGACAAGGA)", + "H12 IDT_10nt_649 (TAATCTACGG-TAAGTGCCAG)", + ], + IndexEnum.IDT_DS_F: [ + "A01 IDT_10nt_1402 (ACCTATACCT-AATGACCTGA)", + "B01 IDT_10nt_1418 (TCAATGGCGG-TCACCGTATA)", + "C01 IDT_10nt_1432 (ACCGCAATTA-TTGGTCAATC)", + "D01 IDT_10nt_1447 (CATTGAAGGA-ACGAACCATT)", + "E01 IDT_10nt_1461 (GCTCACGTTC-CTGAAGCTTA)", + "F01 IDT_10nt_1474 (CTCAGGTGTC-CAGTCTTCGG)", + "G01 IDT_10nt_1487 (TGCGTAAGGT-CTACCACGAA)", + "H01 IDT_10nt_1501 (TCTCGTATGC-AGTACGTGAA)", + "A02 IDT_10nt_1403 (CAAGGCATGC-TTGTACGGCT)", + "B02 IDT_10nt_1419 (CAGATTAGTC-ACATCCTCAC)", + "C02 IDT_10nt_1433 (CACCAAGAAC-AGTAACCACT)", + "D02 IDT_10nt_1448 (AGGATCAAGA-TCGGCTATGA)", + "E02 IDT_10nt_1462 (TGCCAACCAC-TATCAGTGCA)", + "F02 IDT_10nt_1475 (AGGCGAATTC-TAGTGAATCG)", + "G02 IDT_10nt_1488 (ACCGGAGATG-GCAGAGCACA)", + "H02 IDT_10nt_1503 (ACCTCACATA-TACAGCCTTG)", + "A03 IDT_10nt_1404 (CGGTAATGAA-GATCGCCTCA)", + "B03 IDT_10nt_1421 (ACCTCTGACA-TGTTACCTCA)", + "C03 IDT_10nt_1434 (TCGAGCATAG-CAGATCTAGG)", + "D03 IDT_10nt_1449 (TCTCAGCGGT-GCGAGCCATA)", + "E03 IDT_10nt_1463 (TTAGTGGTCA-TGGATGGATA)", + "F03 IDT_10nt_1477 (AGATTGTGCG-TTCCAGAGAA)", + "G03 IDT_10nt_1491 (GAGGACGATC-AGCTACCAAT)", + "H03 IDT_10nt_1504 (AGCAGCACAA-CCTACTTCTA)", + "A04 IDT_10nt_1406 (GTTACTGGAC-GCCGAATATC)", + "B04 IDT_10nt_1422 (AACTCATGTC-GAAGGCCTAA)", + "C04 IDT_10nt_1435 (ATGCTCTCTT-ATCCAGGTTA)", + "D04 IDT_10nt_1450 (CTTAACAAGG-CATGTTCTGT)", + "E04 IDT_10nt_1464 (ACTGTATCAC-AGAGTAGTCA)", + "F04 IDT_10nt_1478 (CTCTCCAACG-TATGGAAGTG)", + "G04 IDT_10nt_1492 (TAGGTGATCT-ATAGAACACG)", + "H04 IDT_10nt_1506 (TAAGTCTGGC-CTCCAGCTTC)", + "A05 IDT_10nt_1408 (GCAGGTAATA-GCTCTGAAGG)", + "B05 IDT_10nt_1423 (TGGAATACCA-TACGAATCGA)", + "C05 IDT_10nt_1437 (CGACTTATGC-AACCTCAGCG)", + "D05 IDT_10nt_1452 (TTACGTGCCG-CAACTCGTAA)", + "E05 IDT_10nt_1465 (ACGGAGTTGA-GTTCCGACGT)", + "F05 IDT_10nt_1479 (ACTTAGCCAT-GATCGTGCTC)", + "G05 IDT_10nt_1493 (CACCGGTGAT-GCCAATACAA)", + "H05 IDT_10nt_1507 (AGGAATTGTC-GTCAACTCGG)", + "A06 IDT_10nt_1410 (GTGTAGATGT-GTTCATGACT)", + "B06 IDT_10nt_1424 (TGTTCGCGAC-AGCTTAGCTC)", + "C06 IDT_10nt_1438 (CTCGTTACAA-AATGCACCAA)", + "D06 IDT_10nt_1453 (TGATGGAATC-CCTTCATAGG)", + "E06 IDT_10nt_1466 (GTAACTTAGC-ACGCGAGTCA)", + "F06 IDT_10nt_1480 (AACACCGCAA-CCACATGACG)", + "G06 IDT_10nt_1494 (GTAACCATCA-CCATAGTCGC)", + "H06 IDT_10nt_1508 (AGGCAACGGA-ATCCAAGTAG)", + "A07 IDT_10nt_1411 (TGATTGGAGA-CTTGGCCTCT)", + "B07 IDT_10nt_1425 (GATGAGCGGT-TGTTAGAGGT)", + "C07 IDT_10nt_1439 (AATCATGGAG-GCCACTTCCT)", + "D07 IDT_10nt_1454 (ACGGAAGCAA-CGGTTGCAGT)", + "E07 IDT_10nt_1467 (ACTTGCCTGT-CCAAGAAGAT)", + "F07 IDT_10nt_1481 (CCAGTCACAC-CACGACTAGC)", + "G07 IDT_10nt_1495 (CTCTGAGGTA-CCATATACGA)", + "H07 IDT_10nt_1509 (CTAACGAGGT-CGAAGCCAAG)", + "A08 IDT_10nt_1412 (CAAGTGCGTC-TCATCGGAAT)", + "B08 IDT_10nt_1426 (AATGTCGGTT-GTTCCTCATT)", + "C08 IDT_10nt_1440 (AATGGCTCGG-CTGGTATTAG)", + "D08 IDT_10nt_1456 (GTCTGATCGT-GCTTGAGACG)", + "E08 IDT_10nt_1468 (TCGTCCTGAC-CTGTGAAGAA)", + "F08 IDT_10nt_1482 (CCTCCAACCA-GCCTGTACTG)", + "G08 IDT_10nt_1496 (TCGGTACCTC-GTGGTGACTG)", + "H08 IDT_10nt_1510 (GATTGAGTGT-ACACAACATG)", + "A09 IDT_10nt_1413 (AGAAGATGCA-ACCTTAGCCG)", + "B09 IDT_10nt_1427 (TTCTCAAGTG-TGGCTTATCA)", + "C09 IDT_10nt_1442 (GCATATCACT-ATCTCCAGAT)", + "D09 IDT_10nt_1457 (CTACGTCTAG-GTGGACCTTG)", + "E09 IDT_10nt_1469 (AACAGTAGCA-TACGCAACGG)", + "F09 IDT_10nt_1483 (CGTGTTAGAG-TCTTACCGAA)", + "G09 IDT_10nt_1497 (CCTTGGTGCA-ACGTGTAGTA)", + "H09 IDT_10nt_1511 (GACAGCAGCT-ATGACTCCTC)", + "A10 IDT_10nt_1415 (ACTCACCGAG-CTCCTGCGTA)", + "B10 IDT_10nt_1428 (CCATTGTCAA-ACTTAAGGAC)", + "C10 IDT_10nt_1443 (CACGTTCGTG-AGAAGTAGGA)", + "D10 IDT_10nt_1458 (GTTGTCGTAT-GAGGAGTATG)", + "E10 IDT_10nt_1470 (TAGTGATAGC-GCAGCATAAT)", + "F10 IDT_10nt_1484 (CACTCTGAAC-GTGATAAGCT)", + "G10 IDT_10nt_1498 (GCGCAATTGT-GCATCGGTTC)", + "H10 IDT_10nt_1512 (GTAGGAACCG-ATGGTCAGTA)", + "A11 IDT_10nt_1416 (ATTAGGCACG-ACAAGCAATG)", + "B11 IDT_10nt_1429 (TCCTCGGATA-CGCGGAGATT)", + "C11 IDT_10nt_1444 (AGATCGAAGT-TGGACGTGCT)", + "D11 IDT_10nt_1459 (GAACTACACT-GCTTACACTT)", + "E11 IDT_10nt_1472 (CGAACTTGAG-TCCACTGCGT)", + "F11 IDT_10nt_1485 (GTGGAACAAT-AGACAGGAAT)", + "G11 IDT_10nt_1499 (ATTCTTCCGG-GAACGGCCAT)", + "H11 IDT_10nt_1513 (ATGAGGTAAC-GCGTACGACA)", + "A12 IDT_10nt_1417 (GAAGTATCGT-GCTGCTCTAT)", + "B12 IDT_10nt_1430 (AAGACGACGC-TTATGGTGGT)", + "C12 IDT_10nt_1445 (CTCAGGACAT-AGGCCTCCAA)", + "D12 IDT_10nt_1460 (AACCGAACCT-GTCGATGATT)", + "E12 IDT_10nt_1473 (GTGTACCGTT-TTGAGCCTGG)", + "F12 IDT_10nt_1486 (CTAAGTTGGT-AGATTCAGCA)", + "G12 IDT_10nt_1500 (CCATGCAGTT-GAGACAGAGC)", + "H12 IDT_10nt_1514 (TTACTGCCTT-GCTCGGAACT)", + ], + IndexEnum.IDT_XGEN_UDI: [ + "A1 xGen UDI Index 1 (CTGATCGT-ATATGCGC)", + "B1 xGen UDI Index 2 (ACTCTCGA-TGGTACAG)", + "C1 xGen UDI Index 3 (TGAGCTAG-AACCGTTC)", + "D1 xGen UDI Index 4 (GAGACGAT-TAACCGGT)", + "E1 xGen UDI Index 5 (CTTGTCGA-GAACATCG)", + "F1 xGen UDI Index 6 (TTCCAAGG-CCTTGTAG)", + "G1 xGen UDI Index 7 (CGCATGAT-TCAGGCTT)", + "H1 xGen UDI Index 8 (ACGGAACA-GTTCTCGT)", + "A2 xGen UDI Index 9 (CGGCTAAT-AGAACGAG)", + "B2 xGen UDI Index 10 (ATCGATCG-TGCTTCCA)", + "C2 xGen UDI Index 11 (GCAAGATC-CTTCGACT)", + "D2 xGen UDI Index 12 (GCTATCCT-CACCTGTT)", + "E2 xGen UDI Index 13 (TACGCTAC-ATCACACG)", + "F2 xGen UDI Index 14 (TGGACTCT-CCGTAAGA)", + "G2 xGen UDI Index 15 (AGAGTAGC-TACGCCTT)", + "H2 xGen UDI Index 16 (ATCCAGAG-CGACGTTA)", + "A3 xGen UDI Index 17 (GACGATCT-ATGCACGA)", + "B3 xGen UDI Index 18 (AACTGAGC-CCTGATTG)", + "C3 xGen UDI Index 19 (CTTAGGAC-GTAGGAGT)", + "D3 xGen UDI Index 20 (GTGCCATA-ACTAGGAG)", + "E3 xGen UDI Index 21 (GAATCCGA-CACTAGCT)", + "F3 xGen UDI Index 22 (TCGCTGTT-ACGACTTG)", + "G3 xGen UDI Index 23 (TTCGTTGG-CGTGTGTA)", + "H3 xGen UDI Index 24 (AAGCACTG-GTTGACCT)", + "A4 xGen UDI Index 25 (CCTTGATC-ACTCCATC)", + "B4 xGen UDI Index 26 (GTCGAAGA-CAATGTGG)", + "C4 xGen UDI Index 27 (ACCACGAT-TTGCAGAC)", + "D4 xGen UDI Index 28 (GATTACCG-CAGTCCAA)", + "E4 xGen UDI Index 29 (GCACAACT-ACGTTCAG)", + "F4 xGen UDI Index 30 (GCGTCATT-AACGTCTG)", + "G4 xGen UDI Index 31 (ATCCGGTA-TATCGGTC)", + "H4 xGen UDI Index 32 (CGTTGCAA-CGCTCTAT)", + "A5 xGen UDI Index 33 (GTGAAGTG-GATTGCTC)", + "B5 xGen UDI Index 34 (CATGGCTA-GATGTGTG)", + "C5 xGen UDI Index 35 (ATGCCTGT-CGCAATCT)", + "D5 xGen UDI Index 36 (CAACACCT-TGGTAGCT)", + "E5 xGen UDI Index 37 (TGTGACTG-GATAGGCT)", + "F5 xGen UDI Index 38 (GTCATCGA-AGTGGATC)", + "G5 xGen UDI Index 39 (AGCACTTC-TTGGACGT)", + "H5 xGen UDI Index 40 (GAAGGAAG-ATGACGTC)", + "A6 xGen UDI Index 41 (GTTGTTCG-GAAGTTGG)", + "B6 xGen UDI Index 42 (CGGTTGTT-CATACCAC)", + "C6 xGen UDI Index 43 (ACTGAGGT-CTGTTGAC)", + "D6 xGen UDI Index 44 (TGAAGACG-TGGCATGT)", + "E6 xGen UDI Index 45 (GTTACGCA-ATCGCCAT)", + "F6 xGen UDI Index 46 (AGCGTGTT-TTGCGAAG)", + "G6 xGen UDI Index 47 (GATCGAGT-AGTTCGTC)", + "H6 xGen UDI Index 48 (ACAGCTCA-GAGCAGTA)", + "A7 xGen UDI Index 49 (GAGCAGTA-ACAGCTCA)", + "B7 xGen UDI Index 50 (AGTTCGTC-GATCGAGT)", + "C7 xGen UDI Index 51 (TTGCGAAG-AGCGTGTT)", + "D7 xGen UDI Index 52 (ATCGCCAT-GTTACGCA)", + "E7 xGen UDI Index 53 (TGGCATGT-TGAAGACG)", + "F7 xGen UDI Index 54 (CTGTTGAC-ACTGAGGT)", + "G7 xGen UDI Index 55 (CATACCAC-CGGTTGTT)", + "H7 xGen UDI Index 56 (GAAGTTGG-GTTGTTCG)", + "A8 xGen UDI Index 57 (ATGACGTC-GAAGGAAG)", + "B8 xGen UDI Index 58 (TTGGACGT-AGCACTTC)", + "C8 xGen UDI Index 59 (AGTGGATC-GTCATCGA)", + "D8 xGen UDI Index 60 (GATAGGCT-TGTGACTG)", + "E8 xGen UDI Index 61 (TGGTAGCT-CAACACCT)", + "F8 xGen UDI Index 62 (CGCAATCT-ATGCCTGT)", + "G8 xGen UDI Index 63 (GATGTGTG-CATGGCTA)", + "H8 xGen UDI Index 64 (GATTGCTC-GTGAAGTG)", + "A9 xGen UDI Index 65 (CGCTCTAT-CGTTGCAA)", + "B9 xGen UDI Index 66 (TATCGGTC-ATCCGGTA)", + "C9 xGen UDI Index 67 (AACGTCTG-GCGTCATT)", + "D9 xGen UDI Index 68 (ACGTTCAG-GCACAACT)", + "E9 xGen UDI Index 69 (CAGTCCAA-GATTACCG)", + "F9 xGen UDI Index 70 (TTGCAGAC-ACCACGAT)", + "G9 xGen UDI Index 71 (CAATGTGG-GTCGAAGA)", + "H9 xGen UDI Index 72 (ACTCCATC-CCTTGATC)", + "A10 xGen UDI Index 73 (GTTGACCT-AAGCACTG)", + "B10 xGen UDI Index 74 (CGTGTGTA-TTCGTTGG)", + "C10 xGen UDI Index 75 (ACGACTTG-TCGCTGTT)", + "D10 xGen UDI Index 76 (CACTAGCT-GAATCCGA)", + "E10 xGen UDI Index 77 (ACTAGGAG-GTGCCATA)", + "F10 xGen UDI Index 78 (GTAGGAGT-CTTAGGAC)", + "G10 xGen UDI Index 79 (CCTGATTG-AACTGAGC)", + "H10 xGen UDI Index 80 (ATGCACGA-GACGATCT)", + "A11 xGen UDI Index 81 (CGACGTTA-ATCCAGAG)", + "B11 xGen UDI Index 82 (TACGCCTT-AGAGTAGC)", + "C11 xGen UDI Index 83 (CCGTAAGA-TGGACTCT)", + "D11 xGen UDI Index 84 (ATCACACG-TACGCTAC)", + "E11 xGen UDI Index 85 (CACCTGTT-GCTATCCT)", + "F11 xGen UDI Index 86 (CTTCGACT-GCAAGATC)", + "G11 xGen UDI Index 87 (TGCTTCCA-ATCGATCG)", + "H11 xGen UDI Index 88 (AGAACGAG-CGGCTAAT)", + "A12 xGen UDI Index 89 (GTTCTCGT-ACGGAACA)", + "B12 xGen UDI Index 90 (TCAGGCTT-CGCATGAT)", + "C12 xGen UDI Index 91 (CCTTGTAG-TTCCAAGG)", + "D12 xGen UDI Index 92 (GAACATCG-CTTGTCGA)", + "E12 xGen UDI Index 93 (TAACCGGT-GAGACGAT)", + "F12 xGen UDI Index 94 (AACCGTTC-TGAGCTAG)", + "G12 xGen UDI Index 95 (TGGTACAG-ACTCTCGA)", + "H12 xGen UDI Index 96 (ATATGCGC-CTGATCGT)", + ], + IndexEnum.KAPA_UDI_NIPT: [ + "A01 UDI0001 (GTAACATC-CAGCGATT)", + "B01 UDI0002 (AGGTAAGG-CACGATTC)", + "C01 UDI0003 (ACAGGTAT-GCCACCAT)", + "D01 UDI0004 (AATGTTCT-AGTCACCT)", + "E01 UDI0005 (TCTGCAAG-TTCACCTT)", + "F01 UDI0006 (CAGCGGTA-TGACTTGG)", + "G01 UDI0007 (CGCCTTCC-GCGGACTT)", + "H01 UDI0008 (CAATAGTC-CAGCTCAC)", + "A02 UDI0009 (ATTATCAA-CGACTCTC)", + "B02 UDI0010 (CCAACATT-GCTCTCTT)", + "C02 UDI0011 (GCCTAGCC-TTGGTCTG)", + "D02 UDI0012 (GACCAGGA-CTGGCTAT)", + "E02 UDI0013 (CTGTAATC-AATTGCTT)", + "F02 UDI0014 (ACTAAGAC-TTCCAGCT)", + "G02 UDI0015 (TCGCTAGA-AGTACTGC)", + "H02 UDI0016 (AACGCATT-GCAGGTTG)", + "A03 UDI0017 (TGCTGCTG-GTCCTCAT)", + "B03 UDI0018 (TATCTGCC-CCAACGCT)", + "C03 UDI0019 (ATTCCTCT-GCGATATT)", + "D03 UDI0020 (CAACTCTC-ATCTTCTC)", + "E03 UDI0021 (GCCGTCGA-TTAATCAC)", + "F03 UDI0022 (TATCCAGG-TCCACTTC)", + "G03 UDI0023 (TAAGCACA-GACATTAA)", + "H03 UDI0024 (GTCCACAG-CGCGAATA)", + "A04 UDI0025 (ACACGATC-AATACCAT)", + "B04 UDI0026 (GTATAACA-TGCTTCAC)", + "C04 UDI0027 (TGTCGGAT-TCAGGCTT)", + "D04 UDI0028 (AGGATCTA-GAACTTCG)", + "E04 UDI0029 (AGCAATTC-CTGCTCCT)", + "F04 UDI0030 (CCTATGCC-CAAGCTTA)", + "G04 UDI0031 (AAGGATGT-CACTTCAT)", + "H04 UDI0032 (TTGAGCCT-TCATTCGA)", + "A05 UDI0033 (CACATCCT-GCTGCACT)", + "B05 UDI0034 (TTCGCTGA-CGCATATT)", + "C05 UDI0035 (CATGCTTA-ATGAATTA)", + "D05 UDI0036 (AAGTAGAG-ATCGACTG)", + "E05 UDI0037 (CATAGCGA-GACGGTTA)", + "F05 UDI0038 (AGTTGCTT-TAGCATTG)", + "G05 UDI0039 (GCACATCT-AACCTCTT)", + "H05 UDI0040 (CCTACCAT-GCTTCCTA)", + "A06 UDI0041 (TGCTCGAC-ATCCTTAA)", + "B06 UDI0042 (CCAGTTAG-CCTGTCAT)", + "C06 UDI0043 (TGTTCCGA-TTAGCCAG)", + "D06 UDI0044 (GGTCCAGA-CGGTTCTT)", + "E06 UDI0045 (TCGGAATG-CTACATTG)", + "F06 UDI0046 (ATAGCGTC-TACTCCAG)", + "G06 UDI0047 (AACTTGAC-GCTAGCAG)", + "H06 UDI0048 (ATTCTAGG-TTCTTGGC)", + "A07 UDI0049 (TTGAATAG-TCCATAAC)", + "B07 UDI0050 (TCTGGCGA-AATTCAAC)", + "C07 UDI0051 (TAATGAAC-CTTGGCTT)", + "D07 UDI0052 (ATTATGTT-CTGTATTC)", + "E07 UDI0053 (ATTGTCTG-TTCACAGA)", + "F07 UDI0054 (GAAGAAGT-CTATTAGC)", + "G07 UDI0055 (GACAGTAA-GCGATTAC)", + "H07 UDI0056 (CCTTCGCA-CATCACTT)", + "A08 UDI0057 (CATGATCG-TACTCTCC)", + "B08 UDI0058 (TCCTTGGT-GAATCGAC)", + "C08 UDI0059 (GTCATCTA-TCCAACCA)", + "D08 UDI0060 (GAACCTAG-CTGGTATT)", + "E08 UDI0061 (CAGCAAGG-CCTCTAAC)", + "F08 UDI0062 (CGTTACCA-GAACGCTA)", + "G08 UDI0063 (TCCAGCAA-AATTGGCC)", + "H08 UDI0064 (CAGGAGCC-GTCCAATC)", + "A09 UDI0065 (TTACGCAC-GACCATCT)", + "B09 UDI0066 (AGGTTATC-ATCATACC)", + "C09 UDI0067 (TCGCCTTG-GCTGATTC)", + "D09 UDI0068 (CCAGAGCT-CGAACTTC)", + "E09 UDI0069 (TACTTAGC-AGGTACCA)", + "F09 UDI0070 (GTCTGATG-ATATCCGA)", + "G09 UDI0071 (TCTCGGTC-CTGACATC)", + "H09 UDI0072 (AAGACACT-TGACAGCA)", + "A10 UDI0073 (CTACCAGG-CAACTGAT)", + "B10 UDI0074 (ACTGTATC-TGCTATTA)", + "C10 UDI0075 (CTGTGGCG-CACTAGCC)", + "D10 UDI0076 (TGTAATCA-AATCTCCA)", + "E10 UDI0077 (TTATATCT-GTCTGCAC)", + "F10 UDI0078 (GCCGCAAC-TCATGTCT)", + "G10 UDI0079 (TGTAACTC-CGACAGTT)", + "H10 UDI0080 (CTGCGGAT-GGTTATCT)", + "A11 UDI0081 (GACCGTTG-CCATCACA)", + "B11 UDI0082 (AACAATGG-TAGTTAGC)", + "C11 UDI0083 (AGGTGCGA-CTTCTGGC)", + "D11 UDI0084 (AGGTCGCA-GCACAATT)", + "E11 UDI0085 (ACCAACTG-GGCAATAC)", + "F11 UDI0086 (TGCAAGTA-CCAACTAA)", + "G11 UDI0087 (GACCTAAC-GCTCACCA)", + "H11 UDI0088 (AGCATGGA-AGCGCTAA)", + "A12 UDI0089 (ACAGTTGA-GCTCCGAT)", + "B12 UDI0090 (TTGTCTAT-CTTGAATC)", + "C12 UDI0091 (CGCTATGT-TCCGCATA)", + "D12 UDI0092 (TTAATCAG-CCAATCTG)", + "E12 UDI0093 (CTATGCGT-GAATATCA)", + "F12 UDI0094 (GATATCCA-GGATTAAC)", + "G12 UDI0095 (GAAGGAAG-CATCCTGG)", + "H12 UDI0096 (CTAACTCG-TATGGTTC)", + ], + IndexEnum.NEXTERA_XT: [ + "N701-S502 (TAAGGCGA-CTCTCTAT)", + "N701-S503 (TAAGGCGA-TATCCTCT)", + "N701-S505 (TAAGGCGA-GTAAGGAG)", + "N701-S506 (TAAGGCGA-ACTGCATA)", + "N701-S507 (TAAGGCGA-AAGGAGTA)", + "N701-S508 (TAAGGCGA-CTAAGCCT)", + "N701-S510 (TAAGGCGA-CGTCTAAT)", + "N701-S511 (TAAGGCGA-TCTCTCCG)", + "N702-S502 (CGTACTAG-CTCTCTAT)", + "N702-S503 (CGTACTAG-TATCCTCT)", + "N702-S505 (CGTACTAG-GTAAGGAG)", + "N702-S506 (CGTACTAG-ACTGCATA)", + "N702-S507 (CGTACTAG-AAGGAGTA)", + "N702-S508 (CGTACTAG-CTAAGCCT)", + "N702-S510 (CGTACTAG-CGTCTAAT)", + "N702-S511 (CGTACTAG-TCTCTCCG)", + "N703-S502 (AGGCAGAA-CTCTCTAT)", + "N703-S503 (AGGCAGAA-TATCCTCT)", + "N703-S505 (AGGCAGAA-GTAAGGAG)", + "N703-S506 (AGGCAGAA-ACTGCATA)", + "N703-S507 (AGGCAGAA-AAGGAGTA)", + "N703-S508 (AGGCAGAA-CTAAGCCT)", + "N703-S510 (AGGCAGAA-CGTCTAAT)", + "N703-S511 (AGGCAGAA-TCTCTCCG)", + "N704-S502 (TCCTGAGC-CTCTCTAT)", + "N704-S503 (TCCTGAGC-TATCCTCT)", + "N704-S505 (TCCTGAGC-GTAAGGAG)", + "N704-S506 (TCCTGAGC-ACTGCATA)", + "N704-S507 (TCCTGAGC-AAGGAGTA)", + "N704-S508 (TCCTGAGC-CTAAGCCT)", + "N704-S510 (TCCTGAGC-CGTCTAAT)", + "N704-S511 (TCCTGAGC-TCTCTCCG)", + "N705-S502 (GGACTCCT-CTCTCTAT)", + "N705-S503 (GGACTCCT-TATCCTCT)", + "N705-S505 (GGACTCCT-GTAAGGAG)", + "N705-S506 (GGACTCCT-ACTGCATA)", + "N705-S507 (GGACTCCT-AAGGAGTA)", + "N705-S508 (GGACTCCT-CTAAGCCT)", + "N705-S510 (GGACTCCT-CGTCTAAT)", + "N705-S511 (GGACTCCT-TCTCTCCG)", + "N706-S502 (TAGGCATG-CTCTCTAT)", + "N706-S503 (TAGGCATG-TATCCTCT)", + "N706-S505 (TAGGCATG-GTAAGGAG)", + "N706-S506 (TAGGCATG-ACTGCATA)", + "N706-S507 (TAGGCATG-AAGGAGTA)", + "N706-S508 (TAGGCATG-CTAAGCCT)", + "N706-S510 (TAGGCATG-CGTCTAAT)", + "N706-S511 (TAGGCATG-TCTCTCCG)", + "N707-S502 (CTCTCTAC-CTCTCTAT)", + "N707-S503 (CTCTCTAC-TATCCTCT)", + "N707-S505 (CTCTCTAC-GTAAGGAG)", + "N707-S506 (CTCTCTAC-ACTGCATA)", + "N707-S507 (CTCTCTAC-AAGGAGTA)", + "N707-S508 (CTCTCTAC-CTAAGCCT)", + "N707-S510 (CTCTCTAC-CGTCTAAT)", + "N707-S511 (CTCTCTAC-TCTCTCCG)", + "N710-S502 (CGAGGCTG-CTCTCTAT)", + "N710-S503 (CGAGGCTG-TATCCTCT)", + "N710-S505 (CGAGGCTG-GTAAGGAG)", + "N710-S506 (CGAGGCTG-ACTGCATA)", + "N710-S507 (CGAGGCTG-AAGGAGTA)", + "N710-S508 (CGAGGCTG-CTAAGCCT)", + "N710-S510 (CGAGGCTG-CGTCTAAT)", + "N710-S511 (CGAGGCTG-TCTCTCCG)", + "N711-S502 (AAGAGGCA-CTCTCTAT)", + "N711-S503 (AAGAGGCA-TATCCTCT)", + "N711-S505 (AAGAGGCA-GTAAGGAG)", + "N711-S506 (AAGAGGCA-ACTGCATA)", + "N711-S507 (AAGAGGCA-AAGGAGTA)", + "N711-S508 (AAGAGGCA-CTAAGCCT)", + "N711-S510 (AAGAGGCA-CGTCTAAT)", + "N711-S511 (AAGAGGCA-TCTCTCCG)", + "N712-S502 (GTAGAGGA-CTCTCTAT)", + "N712-S503 (GTAGAGGA-TATCCTCT)", + "N712-S505 (GTAGAGGA-GTAAGGAG)", + "N712-S506 (GTAGAGGA-ACTGCATA)", + "N712-S507 (GTAGAGGA-AAGGAGTA)", + "N712-S508 (GTAGAGGA-CTAAGCCT)", + "N712-S510 (GTAGAGGA-CGTCTAAT)", + "N712-S511 (GTAGAGGA-TCTCTCCG)", + "N714-S502 (GCTCATGA-CTCTCTAT)", + "N714-S503 (GCTCATGA-TATCCTCT)", + "N714-S505 (GCTCATGA-GTAAGGAG)", + "N714-S506 (GCTCATGA-ACTGCATA)", + "N714-S507 (GCTCATGA-AAGGAGTA)", + "N714-S508 (GCTCATGA-CTAAGCCT)", + "N714-S510 (GCTCATGA-CGTCTAAT)", + "N714-S511 (GCTCATGA-TCTCTCCG)", + "N715-S502 (ATCTCAGG-CTCTCTAT)", + "N715-S503 (ATCTCAGG-TATCCTCT)", + "N715-S505 (ATCTCAGG-GTAAGGAG)", + "N715-S506 (ATCTCAGG-ACTGCATA)", + "N715-S507 (ATCTCAGG-AAGGAGTA)", + "N715-S508 (ATCTCAGG-CTAAGCCT)", + "N715-S510 (ATCTCAGG-CGTCTAAT)", + "N715-S511 (ATCTCAGG-TCTCTCCG)", + "N701-S513 (TAAGGCGA-TCGACTAG)", + "N701-S515 (TAAGGCGA-TTCTAGCT)", + "N701-S516 (TAAGGCGA-CCTAGAGT)", + "N701-S517 (TAAGGCGA-GCGTAAGA)", + "N701-S518 (TAAGGCGA-CTATTAAG)", + "N701-S520 (TAAGGCGA-AAGGCTAT)", + "N701-S521 (TAAGGCGA-GAGCCTTA)", + "N701-S522 (TAAGGCGA-TTATGCGA)", + "N702-S513 (CGTACTAG-TCGACTAG)", + "N702-S515 (CGTACTAG-TTCTAGCT)", + "N702-S516 (CGTACTAG-CCTAGAGT)", + "N702-S517 (CGTACTAG-GCGTAAGA)", + "N702-S518 (CGTACTAG-CTATTAAG)", + "N702-S520 (CGTACTAG-AAGGCTAT)", + "N702-S521 (CGTACTAG-GAGCCTTA)", + "N702-S522 (CGTACTAG-TTATGCGA)", + "N703-S513 (AGGCAGAA-TCGACTAG)", + "N703-S515 (AGGCAGAA-TTCTAGCT)", + "N703-S516 (AGGCAGAA-CCTAGAGT)", + "N703-S517 (AGGCAGAA-GCGTAAGA)", + "N703-S518 (AGGCAGAA-CTATTAAG)", + "N703-S520 (AGGCAGAA-AAGGCTAT)", + "N703-S521 (AGGCAGAA-GAGCCTTA)", + "N703-S522 (AGGCAGAA-TTATGCGA)", + "N704-S513 (TCCTGAGC-TCGACTAG)", + "N704-S515 (TCCTGAGC-TTCTAGCT)", + "N704-S516 (TCCTGAGC-CCTAGAGT)", + "N704-S517 (TCCTGAGC-GCGTAAGA)", + "N704-S518 (TCCTGAGC-CTATTAAG)", + "N704-S520 (TCCTGAGC-AAGGCTAT)", + "N704-S521 (TCCTGAGC-GAGCCTTA)", + "N704-S522 (TCCTGAGC-TTATGCGA)", + "N705-S513 (GGACTCCT-TCGACTAG)", + "N705-S515 (GGACTCCT-TTCTAGCT)", + "N705-S516 (GGACTCCT-CCTAGAGT)", + "N705-S517 (GGACTCCT-GCGTAAGA)", + "N705-S518 (GGACTCCT-CTATTAAG)", + "N705-S520 (GGACTCCT-AAGGCTAT)", + "N705-S521 (GGACTCCT-GAGCCTTA)", + "N705-S522 (GGACTCCT-TTATGCGA)", + "N706-S513 (TAGGCATG-TCGACTAG)", + "N706-S515 (TAGGCATG-TTCTAGCT)", + "N706-S516 (TAGGCATG-CCTAGAGT)", + "N706-S517 (TAGGCATG-GCGTAAGA)", + "N706-S518 (TAGGCATG-CTATTAAG)", + "N706-S520 (TAGGCATG-AAGGCTAT)", + "N706-S521 (TAGGCATG-GAGCCTTA)", + "N706-S522 (TAGGCATG-TTATGCGA)", + "N707-S513 (CTCTCTAC-TCGACTAG)", + "N707-S515 (CTCTCTAC-TTCTAGCT)", + "N707-S516 (CTCTCTAC-CCTAGAGT)", + "N707-S517 (CTCTCTAC-GCGTAAGA)", + "N707-S518 (CTCTCTAC-CTATTAAG)", + "N707-S520 (CTCTCTAC-AAGGCTAT)", + "N707-S521 (CTCTCTAC-GAGCCTTA)", + "N707-S522 (CTCTCTAC-TTATGCGA)", + "N710-S513 (CGAGGCTG-TCGACTAG)", + "N710-S515 (CGAGGCTG-TTCTAGCT)", + "N710-S516 (CGAGGCTG-CCTAGAGT)", + "N710-S517 (CGAGGCTG-GCGTAAGA)", + "N710-S518 (CGAGGCTG-CTATTAAG)", + "N710-S520 (CGAGGCTG-AAGGCTAT)", + "N710-S521 (CGAGGCTG-GAGCCTTA)", + "N710-S522 (CGAGGCTG-TTATGCGA)", + "N711-S513 (AAGAGGCA-TCGACTAG)", + "N711-S515 (AAGAGGCA-TTCTAGCT)", + "N711-S516 (AAGAGGCA-CCTAGAGT)", + "N711-S517 (AAGAGGCA-GCGTAAGA)", + "N711-S518 (AAGAGGCA-CTATTAAG)", + "N711-S520 (AAGAGGCA-AAGGCTAT)", + "N711-S521 (AAGAGGCA-GAGCCTTA)", + "N711-S522 (AAGAGGCA-TTATGCGA)", + "N712-S513 (GTAGAGGA-TCGACTAG)", + "N712-S515 (GTAGAGGA-TTCTAGCT)", + "N712-S516 (GTAGAGGA-CCTAGAGT)", + "N712-S517 (GTAGAGGA-GCGTAAGA)", + "N712-S518 (GTAGAGGA-CTATTAAG)", + "N712-S520 (GTAGAGGA-AAGGCTAT)", + "N712-S521 (GTAGAGGA-GAGCCTTA)", + "N712-S522 (GTAGAGGA-TTATGCGA)", + "N714-S513 (GCTCATGA-TCGACTAG)", + "N714-S515 (GCTCATGA-TTCTAGCT)", + "N714-S516 (GCTCATGA-CCTAGAGT)", + "N714-S517 (GCTCATGA-GCGTAAGA)", + "N714-S518 (GCTCATGA-CTATTAAG)", + "N714-S520 (GCTCATGA-AAGGCTAT)", + "N714-S521 (GCTCATGA-GAGCCTTA)", + "N714-S522 (GCTCATGA-TTATGCGA)", + "N715-S513 (ATCTCAGG-TCGACTAG)", + "N715-S515 (ATCTCAGG-TTCTAGCT)", + "N715-S516 (ATCTCAGG-CCTAGAGT)", + "N715-S517 (ATCTCAGG-GCGTAAGA)", + "N715-S518 (ATCTCAGG-CTATTAAG)", + "N715-S520 (ATCTCAGG-AAGGCTAT)", + "N715-S521 (ATCTCAGG-GAGCCTTA)", + "N715-S522 (ATCTCAGG-TTATGCGA)", + "N716-S502 (ACTCGCTA-CTCTCTAT)", + "N716-S503 (ACTCGCTA-TATCCTCT)", + "N716-S505 (ACTCGCTA-GTAAGGAG)", + "N716-S506 (ACTCGCTA-ACTGCATA)", + "N716-S507 (ACTCGCTA-AAGGAGTA)", + "N716-S508 (ACTCGCTA-CTAAGCCT)", + "N716-S510 (ACTCGCTA-CGTCTAAT)", + "N716-S511 (ACTCGCTA-TCTCTCCG)", + "N718-S502 (GGAGCTAC-CTCTCTAT)", + "N718-S503 (GGAGCTAC-TATCCTCT)", + "N718-S505 (GGAGCTAC-GTAAGGAG)", + "N718-S506 (GGAGCTAC-ACTGCATA)", + "N718-S507 (GGAGCTAC-AAGGAGTA)", + "N718-S508 (GGAGCTAC-CTAAGCCT)", + "N718-S510 (GGAGCTAC-CGTCTAAT)", + "N718-S511 (GGAGCTAC-TCTCTCCG)", + "N719-S502 (GCGTAGTA-CTCTCTAT)", + "N719-S503 (GCGTAGTA-TATCCTCT)", + "N719-S505 (GCGTAGTA-GTAAGGAG)", + "N719-S506 (GCGTAGTA-ACTGCATA)", + "N719-S507 (GCGTAGTA-AAGGAGTA)", + "N719-S508 (GCGTAGTA-CTAAGCCT)", + "N719-S510 (GCGTAGTA-CGTCTAAT)", + "N719-S511 (GCGTAGTA-TCTCTCCG)", + "N720-S502 (CGGAGCCT-CTCTCTAT)", + "N720-S503 (CGGAGCCT-TATCCTCT)", + "N720-S505 (CGGAGCCT-GTAAGGAG)", + "N720-S506 (CGGAGCCT-ACTGCATA)", + "N720-S507 (CGGAGCCT-AAGGAGTA)", + "N720-S508 (CGGAGCCT-CTAAGCCT)", + "N720-S510 (CGGAGCCT-CGTCTAAT)", + "N720-S511 (CGGAGCCT-TCTCTCCG)", + "N721-S502 (TACGCTGC-CTCTCTAT)", + "N721-S503 (TACGCTGC-TATCCTCT)", + "N721-S505 (TACGCTGC-GTAAGGAG)", + "N721-S506 (TACGCTGC-ACTGCATA)", + "N721-S507 (TACGCTGC-AAGGAGTA)", + "N721-S508 (TACGCTGC-CTAAGCCT)", + "N721-S510 (TACGCTGC-CGTCTAAT)", + "N721-S511 (TACGCTGC-TCTCTCCG)", + "N722-S502 (ATGCGCAG-CTCTCTAT)", + "N722-S503 (ATGCGCAG-TATCCTCT)", + "N722-S505 (ATGCGCAG-GTAAGGAG)", + "N722-S506 (ATGCGCAG-ACTGCATA)", + "N722-S507 (ATGCGCAG-AAGGAGTA)", + "N722-S508 (ATGCGCAG-CTAAGCCT)", + "N722-S510 (ATGCGCAG-CGTCTAAT)", + "N722-S511 (ATGCGCAG-TCTCTCCG)", + "N723-S502 (TAGCGCTC-CTCTCTAT)", + "N723-S503 (TAGCGCTC-TATCCTCT)", + "N723-S505 (TAGCGCTC-GTAAGGAG)", + "N723-S506 (TAGCGCTC-ACTGCATA)", + "N723-S507 (TAGCGCTC-AAGGAGTA)", + "N723-S508 (TAGCGCTC-CTAAGCCT)", + "N723-S510 (TAGCGCTC-CGTCTAAT)", + "N723-S511 (TAGCGCTC-TCTCTCCG)", + "N724-S502 (ACTGAGCG-CTCTCTAT)", + "N724-S503 (ACTGAGCG-TATCCTCT)", + "N724-S505 (ACTGAGCG-GTAAGGAG)", + "N724-S506 (ACTGAGCG-ACTGCATA)", + "N724-S507 (ACTGAGCG-AAGGAGTA)", + "N724-S508 (ACTGAGCG-CTAAGCCT)", + "N724-S510 (ACTGAGCG-CGTCTAAT)", + "N724-S511 (ACTGAGCG-TCTCTCCG)", + "N726-S502 (CCTAAGAC-CTCTCTAT)", + "N726-S503 (CCTAAGAC-TATCCTCT)", + "N726-S505 (CCTAAGAC-GTAAGGAG)", + "N726-S506 (CCTAAGAC-ACTGCATA)", + "N726-S507 (CCTAAGAC-AAGGAGTA)", + "N726-S508 (CCTAAGAC-CTAAGCCT)", + "N726-S510 (CCTAAGAC-CGTCTAAT)", + "N726-S511 (CCTAAGAC-TCTCTCCG)", + "N727-S502 (CGATCAGT-CTCTCTAT)", + "N727-S503 (CGATCAGT-TATCCTCT)", + "N727-S505 (CGATCAGT-GTAAGGAG)", + "N727-S506 (CGATCAGT-ACTGCATA)", + "N727-S507 (CGATCAGT-AAGGAGTA)", + "N727-S508 (CGATCAGT-CTAAGCCT)", + "N727-S510 (CGATCAGT-CGTCTAAT)", + "N727-S511 (CGATCAGT-TCTCTCCG)", + "N728-S502 (TGCAGCTA-CTCTCTAT)", + "N728-S503 (TGCAGCTA-TATCCTCT)", + "N728-S505 (TGCAGCTA-GTAAGGAG)", + "N728-S506 (TGCAGCTA-ACTGCATA)", + "N728-S507 (TGCAGCTA-AAGGAGTA)", + "N728-S508 (TGCAGCTA-CTAAGCCT)", + "N728-S510 (TGCAGCTA-CGTCTAAT)", + "N728-S511 (TGCAGCTA-TCTCTCCG)", + "N729-S502 (TCGACGTC-CTCTCTAT)", + "N729-S503 (TCGACGTC-TATCCTCT)", + "N729-S505 (TCGACGTC-GTAAGGAG)", + "N729-S506 (TCGACGTC-ACTGCATA)", + "N729-S507 (TCGACGTC-AAGGAGTA)", + "N729-S508 (TCGACGTC-CTAAGCCT)", + "N729-S510 (TCGACGTC-CGTCTAAT)", + "N729-S511 (TCGACGTC-TCTCTCCG)", + "N716-S513 (ACTCGCTA-TCGACTAG)", + "N716-S515 (ACTCGCTA-TTCTAGCT)", + "N716-S516 (ACTCGCTA-CCTAGAGT)", + "N716-S517 (ACTCGCTA-GCGTAAGA)", + "N716-S518 (ACTCGCTA-CTATTAAG)", + "N716-S520 (ACTCGCTA-AAGGCTAT)", + "N716-S521 (ACTCGCTA-GAGCCTTA)", + "N716-S522 (ACTCGCTA-TTATGCGA)", + "N718-S513 (GGAGCTAC-TCGACTAG)", + "N718-S515 (GGAGCTAC-TTCTAGCT)", + "N718-S516 (GGAGCTAC-CCTAGAGT)", + "N718-S517 (GGAGCTAC-GCGTAAGA)", + "N718-S518 (GGAGCTAC-CTATTAAG)", + "N718-S520 (GGAGCTAC-AAGGCTAT)", + "N718-S521 (GGAGCTAC-GAGCCTTA)", + "N718-S522 (GGAGCTAC-TTATGCGA)", + "N719-S513 (GCGTAGTA-TCGACTAG)", + "N719-S515 (GCGTAGTA-TTCTAGCT)", + "N719-S516 (GCGTAGTA-CCTAGAGT)", + "N719-S517 (GCGTAGTA-GCGTAAGA)", + "N719-S518 (GCGTAGTA-CTATTAAG)", + "N719-S520 (GCGTAGTA-AAGGCTAT)", + "N719-S521 (GCGTAGTA-GAGCCTTA)", + "N719-S522 (GCGTAGTA-TTATGCGA)", + "N720-S513 (CGGAGCCT-TCGACTAG)", + "N720-S515 (CGGAGCCT-TTCTAGCT)", + "N720-S516 (CGGAGCCT-CCTAGAGT)", + "N720-S517 (CGGAGCCT-GCGTAAGA)", + "N720-S518 (CGGAGCCT-CTATTAAG)", + "N720-S520 (CGGAGCCT-AAGGCTAT)", + "N720-S521 (CGGAGCCT-GAGCCTTA)", + "N720-S522 (CGGAGCCT-TTATGCGA)", + "N721-S513 (TACGCTGC-TCGACTAG)", + "N721-S515 (TACGCTGC-TTCTAGCT)", + "N721-S516 (TACGCTGC-CCTAGAGT)", + "N721-S517 (TACGCTGC-GCGTAAGA)", + "N721-S518 (TACGCTGC-CTATTAAG)", + "N721-S520 (TACGCTGC-AAGGCTAT)", + "N721-S521 (TACGCTGC-GAGCCTTA)", + "N721-S522 (TACGCTGC-TTATGCGA)", + "N722-S513 (ATGCGCAG-TCGACTAG)", + "N722-S515 (ATGCGCAG-TTCTAGCT)", + "N722-S516 (ATGCGCAG-CCTAGAGT)", + "N722-S517 (ATGCGCAG-GCGTAAGA)", + "N722-S518 (ATGCGCAG-CTATTAAG)", + "N722-S520 (ATGCGCAG-AAGGCTAT)", + "N722-S521 (ATGCGCAG-GAGCCTTA)", + "N722-S522 (ATGCGCAG-TTATGCGA)", + "N723-S513 (TAGCGCTC-TCGACTAG)", + "N723-S515 (TAGCGCTC-TTCTAGCT)", + "N723-S516 (TAGCGCTC-CCTAGAGT)", + "N723-S517 (TAGCGCTC-GCGTAAGA)", + "N723-S518 (TAGCGCTC-CTATTAAG)", + "N723-S520 (TAGCGCTC-AAGGCTAT)", + "N723-S521 (TAGCGCTC-GAGCCTTA)", + "N723-S522 (TAGCGCTC-TTATGCGA)", + "N724-S513 (ACTGAGCG-TCGACTAG)", + "N724-S515 (ACTGAGCG-TTCTAGCT)", + "N724-S516 (ACTGAGCG-CCTAGAGT)", + "N724-S517 (ACTGAGCG-GCGTAAGA)", + "N724-S518 (ACTGAGCG-CTATTAAG)", + "N724-S520 (ACTGAGCG-AAGGCTAT)", + "N724-S521 (ACTGAGCG-GAGCCTTA)", + "N724-S522 (ACTGAGCG-TTATGCGA)", + "N726-S513 (CCTAAGAC-TCGACTAG)", + "N726-S515 (CCTAAGAC-TTCTAGCT)", + "N726-S516 (CCTAAGAC-CCTAGAGT)", + "N726-S517 (CCTAAGAC-GCGTAAGA)", + "N726-S518 (CCTAAGAC-CTATTAAG)", + "N726-S520 (CCTAAGAC-AAGGCTAT)", + "N726-S521 (CCTAAGAC-GAGCCTTA)", + "N726-S522 (CCTAAGAC-TTATGCGA)", + "N727-S513 (CGATCAGT-TCGACTAG)", + "N727-S515 (CGATCAGT-TTCTAGCT)", + "N727-S516 (CGATCAGT-CCTAGAGT)", + "N727-S517 (CGATCAGT-GCGTAAGA)", + "N727-S518 (CGATCAGT-CTATTAAG)", + "N727-S520 (CGATCAGT-AAGGCTAT)", + "N727-S521 (CGATCAGT-GAGCCTTA)", + "N727-S522 (CGATCAGT-TTATGCGA)", + "N728-S513 (TGCAGCTA-TCGACTAG)", + "N728-S515 (TGCAGCTA-TTCTAGCT)", + "N728-S516 (TGCAGCTA-CCTAGAGT)", + "N728-S517 (TGCAGCTA-GCGTAAGA)", + "N728-S518 (TGCAGCTA-CTATTAAG)", + "N728-S520 (TGCAGCTA-AAGGCTAT)", + "N728-S521 (TGCAGCTA-GAGCCTTA)", + "N728-S522 (TGCAGCTA-TTATGCGA)", + "N729-S513 (TCGACGTC-TCGACTAG)", + "N729-S515 (TCGACGTC-TTCTAGCT)", + "N729-S516 (TCGACGTC-CCTAGAGT)", + "N729-S517 (TCGACGTC-GCGTAAGA)", + "N729-S518 (TCGACGTC-CTATTAAG)", + "N729-S520 (TCGACGTC-AAGGCTAT)", + "N729-S521 (TCGACGTC-GAGCCTTA)", + "N729-S522 (TCGACGTC-TTATGCGA)", + ], + IndexEnum.NEXTFLEX_UDI_96: [ + "UDI1 (AATCGTTA-AATAACGT)", + "UDI2 (GTCTACAT-TTCTTGAA)", + "UDI3 (CGCTGCTC-GGCAGATC)", + "UDI4 (GATCAACA-CTATGTTA)", + "UDI5 (CGAAGGAC-GTTGACGC)", + "UDI6 (GATGCCGG-ATCTACGA)", + "UDI7 (CTACGAAG-CTCGACAG)", + "UDI8 (GATGCGTC-GAGGCTGC)", + "UDI9 (CTACGGCA-CCTCGTAG)", + "UDI10 (GATTCCTT-CATAGGCA)", + "UDI11 (CTACTCGA-AGATGAAC)", + "UDI12 (GATTCGAG-CCGAGTAT)", + "UDI13 (AATCGGCG-AATATTGA)", + "UDI14 (TTCGCCGA-GTATACCG)", + "UDI15 (CTGGCCTC-GATCCAAC)", + "UDI16 (GAACTTAT-AGATACGC)", + "UDI17 (CGTATTGG-GGTATCTT)", + "UDI18 (GAAGCACA-CCTCTGGC)", + "UDI19 (CTTAATAC-CCATTGTG)", + "UDI20 (GAAGTCTT-ACTACGGT)", + "UDI21 (GAAGAGGC-AAGTGCTA)", + "UDI22 (CGGATAAC-GCCGAACG)", + "UDI23 (GAATCTGG-TGTCCACG)", + "UDI24 (CTGATTGA-GACACACT)", + "UDI25 (AATCCGTT-AATATGCT)", + "UDI26 (TGCGTACA-TTCTCATA)", + "UDI27 (GAATCAAT-TCTGTGAT)", + "UDI28 (TGAGTCAG-CCGAACTT)", + "UDI29 (GAATGCTC-GTCTAACA)", + "UDI30 (GAATATCC-GACGCCAT)", + "UDI31 (CTTATGAA-GCCAATGT)", + "UDI32 (TCGGCACC-CCAACGTC)", + "UDI33 (AAGAAGCG-GTAGATAA)", + "UDI34 (CTCACGAT-CTTACGGC)", + "UDI35 (TCGGTCGA-CCAAGTGC)", + "UDI36 (TCGGTAAG-CTAACTCA)", + "UDI37 (AAGATACA-AATATCTG)", + "UDI38 (GTCGCTGT-TTATATCA)", + "UDI39 (TCGGATGT-CTGCGGAT)", + "UDI40 (CGAGCCGG-GCGGCTTG)", + "UDI41 (CGATTATC-GAGTTGAT)", + "UDI42 (TCGAAGCT-GCACTGAG)", + "UDI43 (CTATCATT-GACCACCT)", + "UDI44 (CGCGCCAA-TGGCTAGG)", + "UDI45 (CGAACGGA-CCTACCGG)", + "UDI46 (CTACTGAC-GGAGGATG)", + "UDI47 (TCTTAAGT-CGCTGAAT)", + "UDI48 (TTAGAGTC-TGTGACGA)", + "UDI49 (AAGACGAA-AATAGATT)", + "UDI50 (TTATTATG-TTAGCGCA)", + "UDI51 (CGCTATTA-GCGGCCGT)", + "UDI52 (TCTATCAG-CAGTAACC)", + "UDI53 (CGGTGGTA-GCCTAGTA)", + "UDI54 (TCACCAAT-CACGGCGC)", + "UDI55 (CTGGAAGC-GGTGCAGA)", + "UDI56 (CGTAAGAG-TCGCTGAC)", + "UDI57 (AAGAGAGC-CAGCCAGT)", + "UDI58 (TCAACGAG-CGTCAACC)", + "UDI59 (TGCGAGAC-GCCGGCGA)", + "UDI60 (CCTGGTGT-GCCTCCGG)", + "UDI61 (AAGTAAGT-AATAGTCC)", + "UDI62 (TGACTGAA-TTAGACGT)", + "UDI63 (AAGACTGT-GTGGACTA)", + "UDI64 (CAATGATG-CACGGACG)", + "UDI65 (CACAGTAA-CACTAGAG)", + "UDI66 (TGGTCATT-GCAGATGG)", + "UDI67 (CAACCGTG-CTCTCACG)", + "UDI68 (TGGTGCAC-GGAATCAC)", + "UDI69 (CCACAATG-CGTTGACG)", + "UDI70 (TGTGTGCC-CATCAGGT)", + "UDI71 (CACCACGG-CGTTGTAA)", + "UDI72 (TGTGTTAA-GGCACGGT)", + "UDI73 (AAGTTATC-AATAGCAA)", + "UDI74 (GTACAGCT-TGATCGGT)", + "UDI75 (CAACTGCT-AGTAGTAT)", + "UDI76 (CATGATGA-GTTAGAGG)", + "UDI77 (TGACTACT-CCTTACAG)", + "UDI78 (CAGAAGAT-GTACATTG)", + "UDI79 (TGAGGCGC-GGAGACCA)", + "UDI80 (CAGGTTCC-CGAACACC)", + "UDI81 (TGAACAGG-GAGAACAA)", + "UDI82 (CAGTGTGG-TGTGAATC)", + "UDI83 (TTCCACCA-GGTTAAGG)", + "UDI84 (CCGCTGTT-AGACCGCA)", + "UDI85 (AAGTTGGA-AATACAGG)", + "UDI86 (GGACAACG-TGATGGCC)", + "UDI87 (TTCGAACC-TGTCACCT)", + "UDI88 (CAGACCAC-GCTTCGGC)", + "UDI89 (TTCTGGTG-CCAGTGGT)", + "UDI90 (CAATCGAA-GCACACGC)", + "UDI91 (AAGTACAG-GTCACGTC)", + "UDI92 (CCGTGCCA-GCAGCTCC)", + "UDI93 (CATTGCAC-CATGCAGC)", + "UDI94 (TTACCTGG-ACGATTGC)", + "UDI95 (CTGCAACG-GACATTCG)", + "UDI96 (TACTGTTA-GCGAATAC)", + ], + IndexEnum.NEXTFLEX_V2_UDI_96: [ + "UDI 1 (AATCGTTA-AATAACGT)", + "UDI 2 (GTCTACAT-TTCTTGAA)", + "UDI 3 (CGCTGCTC-GGCAGATC)", + "UDI 4 (GATCAACA-CTATGTTA)", + "UDI 5 (CGAAGGAC-GTTGACGC)", + "UDI 6 (GATGCCGG-ATCTACGA)", + "UDI 7 (CTACGAAG-CTCGACAG)", + "UDI 8 (GATGCGTC-GAGGCTGC)", + "UDI 9 (CTACGGCA-CCTCGTAG)", + "UDI 10 (GATTCCTT-CATAGGCA)", + "UDI 11 (CTACTCGA-AGATGAAC)", + "UDI 12 (GATTCGAG-CCGAGTAT)", + "UDI 13 (AATCGGCG-AATATTGA)", + "UDI 14 (TTCGCCGA-GTATACCG)", + "UDI 15 (CTGGCCTC-GATCCAAC)", + "UDI 16 (GAACTTAT-AGATACGC)", + "UDI 17 (CGTATTGG-GGTATCTT)", + "UDI 18 (GAAGCACA-CCTCTGGC)", + "UDI 19 (CTTAATAC-CCATTGTG)", + "UDI 20 (GAAGTCTT-ACTACGGT)", + "UDI 21 (GAAGAGGC-AAGTGCTA)", + "UDI 22 (CGGATAAC-GCCGAACG)", + "UDI 23 (GAATCTGG-TGTCCACG)", + "UDI 24 (CTGATTGA-GACACACT)", + "UDI 25 (AATCCGTT-AATATGCT)", + "UDI 26 (TGCGTACA-TTCTCATA)", + "UDI 27 (GAATCAAT-TCTGTGAT)", + "UDI 28 (TGAGTCAG-CCGAACTT)", + "UDI 29 (GAATGCTC-GTCTAACA)", + "UDI 30 (GAATATCC-GACGCCAT)", + "UDI 31 (CTTATGAA-GCCAATGT)", + "UDI 32 (TCGGCACC-CCAACGTC)", + "UDI 33 (AAGAAGCG-GTAGATAA)", + "UDI 34 (CTCACGAT-CTTACGGC)", + "UDI 35 (TCGGTCGA-CCAAGTGC)", + "UDI 36 (TCGGTAAG-CTAACTCA)", + "UDI 37 (AAGATACA-AATATCTG)", + "UDI 38 (GTCGCTGT-TTATATCA)", + "UDI 39 (TCGGATGT-CTGCGGAT)", + "UDI 40 (CGAGCCGG-GCGGCTTG)", + "UDI 41 (CGATTATC-GAGTTGAT)", + "UDI 42 (TCGAAGCT-GCACTGAG)", + "UDI 43 (CTATCATT-GACCACCT)", + "UDI 44 (CGCGCCAA-TGGCTAGG)", + "UDI 45 (CGAACGGA-CCTACCGG)", + "UDI 46 (CTACTGAC-GGAGGATG)", + "UDI 47 (TCTTAAGT-CGCTGAAT)", + "UDI 48 (TTAGAGTC-TGTGACGA)", + "UDI 49 (AAGACGAA-AATAGATT)", + "UDI 50 (TTATTATG-TTAGCGCA)", + "UDI 51 (CGCTATTA-GCGGCCGT)", + "UDI 52 (TCTATCAG-CAGTAACC)", + "UDI 53 (CGGTGGTA-GCCTAGTA)", + "UDI 54 (TCACCAAT-CACGGCGC)", + "UDI 55 (CTGGAAGC-GGTGCAGA)", + "UDI 56 (TCCTCGAT-GTAACTGC)", + "UDI 57 (AAGAGAGC-CAGCCAGT)", + "UDI 58 (TCAACGAG-CGTCAACC)", + "UDI 59 (TGCGAGAC-GCCGGCGA)", + "UDI 60 (CCTGGTGT-GCCTCCGG)", + "UDI 61 (AAGTAAGT-AATAGTCC)", + "UDI 62 (TGACTGAA-TTAGACGT)", + "UDI 63 (AAGACTGT-GTGGACTA)", + "UDI 64 (CAATGATG-CACGGACG)", + "UDI 65 (CACAGTAA-CACTAGAG)", + "UDI 66 (TGGTCATT-GCAGATGG)", + "UDI 67 (CAACCGTG-CTCTCACG)", + "UDI 68 (TGGTGCAC-GGAATCAC)", + "UDI 69 (CCACAATG-CGTTGACG)", + "UDI 70 (TGTGTGCC-CATCAGGT)", + "UDI 71 (CACCACGG-CGTTGTAA)", + "UDI 72 (TGTGTTAA-GGCACGGT)", + "UDI 73 (AAGTTATC-AATAGCAA)", + "UDI 74 (GTACAGCT-TGATCGGT)", + "UDI 75 (CAACTGCT-AGTAGTAT)", + "UDI 76 (CATGATGA-GTTAGAGG)", + "UDI 77 (TGACTACT-CCTTACAG)", + "UDI 78 (CAGAAGAT-GTACATTG)", + "UDI 79 (TGAGGCGC-GGAGACCA)", + "UDI 80 (CAGGTTCC-CGAACACC)", + "UDI 81 (TGAACAGG-GAGAACAA)", + "UDI 82 (CAGTGTGG-TGTGAATC)", + "UDI 83 (TTCCACCA-GGTTAAGG)", + "UDI 84 (CCGCTGTT-AGACCGCA)", + "UDI 85 (AAGTTGGA-AATACAGG)", + "UDI 86 (GGACAACG-TGATGGCC)", + "UDI 87 (TTCGAACC-TGTCACCT)", + "UDI 88 (CAGACCAC-GCTTCGGC)", + "UDI 89 (TTCTGGTG-CCAGTGGT)", + "UDI 90 (CAATCGAA-GCACACGC)", + "UDI 91 (AAGTACAG-GTCACGTC)", + "UDI 92 (CCGTGCCA-GCAGCTCC)", + "UDI 93 (CATTGCAC-CATGCAGC)", + "UDI 94 (TTACCTGG-ACGATTGC)", + "UDI 95 (CTGCAACG-GACATTCG)", + "UDI 96 (TACTGTTA-GCGAATAC)", + ], + IndexEnum.NO_INDEX: [], + IndexEnum.TEN_X_TN_A: [ + "SI_TN_A1 (AGTATCTGCA-TCGCTAGCGA)", + "SI_TN_B1 (CATAGCATGA-GACCTGCCTG)", + "SI_TN_C1 (AAGGGTTTAC-TACTCACGCG)", + "SI_TN_D1 (AGCGCCTTGC-GTACGAAGTG)", + "SI_TN_E1 (GTCATCCTAT-CTAGGGCAAA)", + "SI_TN_F1 (CCTGGCTATA-CCCTCACAAA)", + "SI_TN_G1 (TCATCGTTCT-AATTCGGGAA)", + "SI_TN_H1 (CGTTCCACAT-GAGGGAGCCA)", + "SI_TN_A2 (TCTATGAGTG-CAACCAACGA)", + "SI_TN_B2 (TACTGCAATA-AGAGTCCATG)", + "SI_TN_C2 (GCTTAAGCAA-GCCTACCGAA)", + "SI_TN_D2 (CAGTAATACA-TTGAGCTGAG)", + "SI_TN_E2 (CGACCCAGTG-TCGCACCAAC)", + "SI_TN_F2 (ACTTGTTCGA-TGGATGGGTG)", + "SI_TN_G2 (GCACTACTGA-CACTACGGTT)", + "SI_TN_H2 (ATCTTGCAGC-CGAGTAAGGA)", + "SI_TN_A3 (TTATTGACAC-GCGAACTGAT)", + "SI_TN_B3 (TGGCTACCGG-CTGAGTCATT)", + "SI_TN_C3 (GTGATCTGGG-ACTGTGTCGC)", + "SI_TN_D3 (ATAGAACCAC-CTAACCTAAC)", + "SI_TN_E3 (CATAGTTCGC-TTTCGTAACT)", + "SI_TN_F3 (AACCATTAGT-GTATGTCGGG)", + "SI_TN_G3 (GTTATCACGA-GCAACACCTC)", + "SI_TN_H3 (CAGCTGTTAT-AGTACGTGAG)", + "SI_TN_A4 (GAACAACCTT-GAACTGGTAC)", + "SI_TN_B4 (ACGTTTGATT-TACTGAGAGA)", + "SI_TN_C4 (ATGCAAGATC-ACGCCTCTGA)", + "SI_TN_D4 (TTACAATCGT-CAGATTGTAC)", + "SI_TN_E4 (TCACGTTGGG-CTTTGCTCCA)", + "SI_TN_F4 (TTGCGGGACT-TGAGGATCGC)", + "SI_TN_G4 (CAGGCGAATA-CCCTTTACCG)", + "SI_TN_H4 (CTACGACTGA-CATCGCCCTC)", + "SI_TN_A5 (TCTCGAATGT-ACGATCGCGA)", + "SI_TN_B5 (TGGGTGCACA-CATGCATCAT)", + "SI_TN_C5 (TCAAAGGGTT-GATTACTGAG)", + "SI_TN_D5 (CTGCCTGGGT-GACCAATAGC)", + "SI_TN_E5 (TAATCTTCGG-AGCCATCAAT)", + "SI_TN_F5 (GAGCGAAAGC-TCCTTACCAA)", + "SI_TN_G5 (TAAGTAGAAG-CGCGTTTCCT)", + "SI_TN_H5 (GCTGGGATGC-GACTAACTGG)", + "SI_TN_A6 (TTTGCTGGGT-CAAATTCCGG)", + "SI_TN_B6 (ACGAGCGGAA-AACAACTAAG)", + "SI_TN_C6 (TCAAGTAAAG-CAGTGCTGTT)", + "SI_TN_D6 (GAAAGCGCGC-GAAACATATC)", + "SI_TN_E6 (CCATCACCAC-GTGATCCCAA)", + "SI_TN_F6 (AGCATCACAT-TCGTAGAGGA)", + "SI_TN_G6 (CGAGGAGCAT-CAGTAAGTCT)", + "SI_TN_H6 (CCGTCGCTGA-AGGTGTTAGT)", + "SI_TN_A7 (TCCGAATAAA-ATGCTACCGC)", + "SI_TN_B7 (CCATTGTAAG-TACGAATTGA)", + "SI_TN_C7 (GCTGCTCCCA-CTACAGGGTC)", + "SI_TN_D7 (TGTTCGCGAA-TCGTGAAATA)", + "SI_TN_E7 (CCATTAGGCG-GCATTTCATC)", + "SI_TN_F7 (ACCATTGCAC-ACAGTTAAGC)", + "SI_TN_G7 (ATCACCGTTT-TGTCGAGGAG)", + "SI_TN_H7 (AAGTTAGTAC-ACGCGGAATA)", + "SI_TN_A8 (GTTTGAAAGT-GTACGCCATG)", + "SI_TN_B8 (TAGTAGTTTG-TCATCGGGCG)", + "SI_TN_C8 (ATCTGTAGTT-AGGCCCAATG)", + "SI_TN_D8 (GCGTAACGAT-ATTCGTTCAA)", + "SI_TN_E8 (CTGGTGATAA-AGGGACCTGG)", + "SI_TN_F8 (GTTCTGGAAC-CGGGTACTGG)", + "SI_TN_G8 (GACCGCCTTT-TTTAACTCGT)", + "SI_TN_H8 (GACTCAGGGT-GAGACCCTTC)", + "SI_TN_A9 (GCACGTGACA-AGGAAGTCTG)", + "SI_TN_B9 (GATGGAAGGT-AAATTGAGCA)", + "SI_TN_C9 (ATGGCGCAAA-CGATGCAAGC)", + "SI_TN_D9 (TGTCAGTAAG-AGATGACATC)", + "SI_TN_E9 (CGTTGGTCCG-GTAGCTGATA)", + "SI_TN_F9 (CGGACGACCT-GTTGCGCCTC)", + "SI_TN_G9 (TCCCGACCTG-AACCCACCAA)", + "SI_TN_H9 (TGCACAAGCG-TGATGTTTGC)", + "SI_TN_A10 (GTCGTTGCCT-AGAACTTCTT)", + "SI_TN_B10 (ACAGGTTACG-AGATAAACAG)", + "SI_TN_C10 (TAATGGGCAA-GCGCATAGGC)", + "SI_TN_D10 (CTACGTAGGT-GTCCCACTTA)", + "SI_TN_E10 (CTTCCTACTT-TCCTCCTGTA)", + "SI_TN_F10 (TGGACCTTTG-TAAATCTCTG)", + "SI_TN_G10 (ATTGTACAGT-TTGGTTACGT)", + "SI_TN_H10 (TTCGCTTAAC-CCGCTCGTTA)", + "SI_TN_A11 (TCGTACGATG-ACCCTCCCAT)", + "SI_TN_B11 (AGTAGTTTGG-ATAGCATGCA)", + "SI_TN_C11 (TAACTGTAGT-ATGACCGATA)", + "SI_TN_D11 (CCTAGGCAAA-CAGAAATATC)", + "SI_TN_E11 (AGACGCATCT-TTGTACGTGG)", + "SI_TN_F11 (TCATATGTGA-AAAGTGTTCT)", + "SI_TN_G11 (GACAATTGGG-ACATTTGGAA)", + "SI_TN_H11 (AGACGACCGA-CCACAGAACA)", + "SI_TN_A12 (CGCGAGTAGG-CCTGGTGACA)", + "SI_TN_B12 (TCGCCATTTG-ACGGGCATGT)", + "SI_TN_C12 (AGCCTTCTCT-CTGTCCGCGT)", + "SI_TN_D12 (TTAACGGACG-CAAACGTCGC)", + "SI_TN_E12 (TCGGGAGCTG-CCAGACTGCA)", + "SI_TN_F12 (CGCCCTCATC-GCGTCTACGC)", + "SI_TN_G12 (ATTGGCGCAA-TCAATTGCAA)", + "SI_TN_H12 (AGTGGAGGGA-TGCATAGTTT)", + ], + IndexEnum.TEN_X_TT_A: [ + "SI_TT_A1 (GTAACATGCG-AGTGTTACCT)", + "SI_TT_B1 (ACAGTAACTA-ACAGTTCGTT)", + "SI_TT_C1 (TGCGCGGTTT-CAAGGATAAA)", + "SI_TT_D1 (TGCAATGTTC-GCTTGTCGAA)", + "SI_TT_E1 (TTATTCGAGG-CTGTCCTGCT)", + "SI_TT_F1 (AAGATTGGAT-AGCGGGATTT)", + "SI_TT_G1 (TGTAGTCATT-CTTGATCGTA)", + "SI_TT_H1 (ACAATGTGAA-CGTACCGTTA)", + "SI_TT_A2 (GTGGATCAAA-GCCAACCCTG)", + "SI_TT_B2 (TCTACCATTT-CGGGAGAGTC)", + "SI_TT_C2 (CAATCCCGAC-CCGAGTAGTA)", + "SI_TT_D2 (TTAATACGCG-CACCTCGGGT)", + "SI_TT_E2 (ATGGAGGGAG-ATAACCCATT)", + "SI_TT_F2 (AAGGGCCGCA-CTGATTCCTC)", + "SI_TT_G2 (CATGTGGGTT-GATTCCTTTA)", + "SI_TT_H2 (TAGCATAGTG-CGGCTCTGTC)", + "SI_TT_A3 (CACTACGAAA-TTAGACTGAT)", + "SI_TT_B3 (CACGGTGAAT-GTTCGTCACA)", + "SI_TT_C3 (ATGGCTTGTG-GAATGTTGTG)", + "SI_TT_D3 (CCTTCTAGAG-AATACAACGA)", + "SI_TT_E3 (ACCAGACAAC-AGGAACTAGG)", + "SI_TT_F3 (GAGAGGATAT-TTGAAATGGG)", + "SI_TT_G3 (ATGACGTCGC-AGGTCAGGAT)", + "SI_TT_H3 (CCCGTTCTCG-GACGGATTGG)", + "SI_TT_A4 (CTCTAGCGAG-TATCTTCATC)", + "SI_TT_B4 (GTAGACGAAA-CTAGTGTGGT)", + "SI_TT_C4 (TTCTCGATGA-TGTCGGGCAC)", + "SI_TT_D4 (GCAGTATAGG-TTCCGTGCAC)", + "SI_TT_E4 (AACCACGCAT-ATTCAGGTTA)", + "SI_TT_F4 (CCCACCACAA-ACCTCCGCTT)", + "SI_TT_G4 (GCGCTTATGG-GCCTGGCTAG)", + "SI_TT_H4 (AGTTTCCTGG-TGCCACACAG)", + "SI_TT_A5 (GTAGCCCTGT-GAGCATCTAT)", + "SI_TT_B5 (TCGGCTCTAC-CCGATGGTCT)", + "SI_TT_C5 (TCCGTTGGAT-ACGTTCTCGC)", + "SI_TT_D5 (TGGTTCGGGT-GTGGCAGGAG)", + "SI_TT_E5 (CGCGGTAGGT-CAGGATGTTG)", + "SI_TT_F5 (CGGCTGGATG-TGATAAGCAC)", + "SI_TT_G5 (ATAGGGCGAG-TGCATCGAGT)", + "SI_TT_H5 (AGCAAGAAGC-TTGTGTTTCT)", + "SI_TT_A6 (TAACGCGTGA-CCCTAACTTC)", + "SI_TT_B6 (AATGCCATGA-TACGTAATGC)", + "SI_TT_C6 (ACGACTACCA-ACGACCCTAA)", + "SI_TT_D6 (CCCAGCTTCT-GACACCAAAC)", + "SI_TT_E6 (TTGAGAGTCA-AACCTGGTAG)", + "SI_TT_F6 (TTGCCCGTGC-GCGTGAGATT)", + "SI_TT_G6 (GCGGGTAAGT-TAGCACTAAG)", + "SI_TT_H6 (CCTATCCTCG-GAATACTAAC)", + "SI_TT_A7 (TCCCAAGGGT-TACTACCTTT)", + "SI_TT_B7 (GCCTTCGGTA-CCAACGATTT)", + "SI_TT_C7 (CGCGCACTTA-CCTGTATTCT)", + "SI_TT_D7 (CCTGTCAGGG-AGCCCGTAAC)", + "SI_TT_E7 (GTCCTTCGGC-TCATGCACAG)", + "SI_TT_F7 (AATGTATCCA-AATGAGCTTA)", + "SI_TT_G7 (GTTTCACGAT-TTCGGCCAAA)", + "SI_TT_H7 (ACCTCGAGCT-TGTGTTCGAT)", + "SI_TT_A8 (CGAAGTATAC-GAACTTGGAG)", + "SI_TT_B8 (GCACTGAGAA-TATGCGTGAA)", + "SI_TT_C8 (GCTACAAAGC-CACGTGCCCT)", + "SI_TT_D8 (CGCTGAAATC-AGGTGTCTGC)", + "SI_TT_E8 (GAGCAAGGGC-ATTGACTTGG)", + "SI_TT_F8 (CTCCTTTAGA-GACATAGCTC)", + "SI_TT_G8 (TAAGCAACTG-CTATACTCAA)", + "SI_TT_H8 (ATAAGGATAC-ATAGATAGGG)", + "SI_TT_A9 (AAGTGGAGAG-TTCCTGTTAC)", + "SI_TT_B9 (TATTGAGGCA-CAGGTAAGTG)", + "SI_TT_C9 (TATCAGCCTA-GTTTCGTCCT)", + "SI_TT_D9 (TGGTCCCAAG-CCTCTGGCGT)", + "SI_TT_E9 (TGTCCCAACG-TCGATGTCCA)", + "SI_TT_F9 (GTCCCATCAA-CGAACGTGAC)", + "SI_TT_G9 (CCGGAGGAAG-TGCGGATGTT)", + "SI_TT_H9 (AGAACTTAGA-CGAGTCCTTT)", + "SI_TT_A10 (CGTGACATGC-ATGGTCTAAA)", + "SI_TT_B10 (GCCCGATGGA-AATCGTCTAG)", + "SI_TT_C10 (AGAATGGTTT-GAGGGTGGGA)", + "SI_TT_D10 (ATGCGAATGG-ACAAGTGTCG)", + "SI_TT_E10 (CACAATCCCA-ATATCCACAA)", + "SI_TT_F10 (CCGGCAACTG-CGGTTTAACA)", + "SI_TT_G10 (ACTTTACGTG-TGAACGCCCT)", + "SI_TT_H10 (TTATCTAGGG-AAAGGCTCTA)", + "SI_TT_A11 (CGGAACCCAA-GATTCGAGGA)", + "SI_TT_B11 (TCTTACTTGC-TGACCTCTAG)", + "SI_TT_C11 (ATGGGTGAAA-CTTGGGAATT)", + "SI_TT_D11 (CGAATATTCG-CTGGAAGCAA)", + "SI_TT_E11 (TCCGGGACAA-GTGAATGCCA)", + "SI_TT_F11 (TTCACACCTT-TAGTGTACAC)", + "SI_TT_G11 (GATAACCTGC-CATTAGAAAC)", + "SI_TT_H11 (ACAATCGATC-TGACGGAATG)", + "SI_TT_A12 (CACCGCACCA-GACTGTCAAT)", + "SI_TT_B12 (CGTCAAGGGC-TAGGTCACTC)", + "SI_TT_C12 (TCGTCAAGAT-GCAACTCAGG)", + "SI_TT_D12 (GAATTGGTTA-ACTCTAGTAG)", + "SI_TT_E12 (CGTCCACCTG-CATTCATGAC)", + "SI_TT_F12 (GAGACGCACG-CTATGAACAT)", + "SI_TT_G12 (CTTGCATAAA-ATCAGGGCTT)", + "SI_TT_H12 (TGATGATTCA-GTAGGAGTCG)", + ], + IndexEnum.TRUSEQ_DNA_HT: [ + "A01 - D701-D501 (ATTACTCG-TATAGCCT)", + "B01 - D701-D502 (ATTACTCG-ATAGAGGC)", + "C01 - D701-D503 (ATTACTCG-CCTATCCT)", + "D01 - D701-D504 (ATTACTCG-GGCTCTGA)", + "E01 - D701-D505 (ATTACTCG-AGGCGAAG)", + "F01 - D701-D506 (ATTACTCG-TAATCTTA)", + "G01 - D701-D507 (ATTACTCG-CAGGACGT)", + "H01 - D701-D508 (ATTACTCG-GTACTGAC)", + "A02 - D702-D501 (TCCGGAGA-TATAGCCT)", + "B02 - D702-D502 (TCCGGAGA-ATAGAGGC)", + "C02 - D702-D503 (TCCGGAGA-CCTATCCT)", + "D02 - D702-D504 (TCCGGAGA-GGCTCTGA)", + "E02 - D702-D505 (TCCGGAGA-AGGCGAAG)", + "F02 - D702-D506 (TCCGGAGA-TAATCTTA)", + "G02 - D702-D507 (TCCGGAGA-CAGGACGT)", + "H02 - D702-D508 (TCCGGAGA-GTACTGAC)", + "A03 - D703-D501 (CGCTCATT-TATAGCCT)", + "B03 - D703-D502 (CGCTCATT-ATAGAGGC)", + "C03 - D703-D503 (CGCTCATT-CCTATCCT)", + "D03 - D703-D504 (CGCTCATT-GGCTCTGA)", + "E03 - D703-D505 (CGCTCATT-AGGCGAAG)", + "F03 - D703-D506 (CGCTCATT-TAATCTTA)", + "G03 - D703-D507 (CGCTCATT-CAGGACGT)", + "H03 - D703-D508 (CGCTCATT-GTACTGAC)", + "A04 - D704-D501 (GAGATTCC-TATAGCCT)", + "B04 - D704-D502 (GAGATTCC-ATAGAGGC)", + "C04 - D704-D503 (GAGATTCC-CCTATCCT)", + "D04 - D704-D504 (GAGATTCC-GGCTCTGA)", + "E04 - D704-D505 (GAGATTCC-AGGCGAAG)", + "F04 - D704-D506 (GAGATTCC-TAATCTTA)", + "G04 - D704-D507 (GAGATTCC-CAGGACGT)", + "H04 - D704-D508 (GAGATTCC-GTACTGAC)", + "A05 - D705-D501 (ATTCAGAA-TATAGCCT)", + "B05 - D705-D502 (ATTCAGAA-ATAGAGGC)", + "C05 - D705-D503 (ATTCAGAA-CCTATCCT)", + "D05 - D705-D504 (ATTCAGAA-GGCTCTGA)", + "E05 - D705-D505 (ATTCAGAA-AGGCGAAG)", + "F05 - D705-D506 (ATTCAGAA-TAATCTTA)", + "G05 - D705-D507 (ATTCAGAA-CAGGACGT)", + "H05 - D705-D508 (ATTCAGAA-GTACTGAC)", + "A06 - D706-D501 (GAATTCGT-TATAGCCT)", + "B06 - D706-D502 (GAATTCGT-ATAGAGGC)", + "C06 - D706-D503 (GAATTCGT-CCTATCCT)", + "D06 - D706-D504 (GAATTCGT-GGCTCTGA)", + "E06 - D706-D505 (GAATTCGT-AGGCGAAG)", + "F06 - D706-D506 (GAATTCGT-TAATCTTA)", + "G06 - D706-D507 (GAATTCGT-CAGGACGT)", + "H06 - D706-D508 (GAATTCGT-GTACTGAC)", + "A07 - D707-D501 (CTGAAGCT-TATAGCCT)", + "B07 - D707-D502 (CTGAAGCT-ATAGAGGC)", + "C07 - D707-D503 (CTGAAGCT-CCTATCCT)", + "D07 - D707-D504 (CTGAAGCT-GGCTCTGA)", + "E07 - D707-D505 (CTGAAGCT-AGGCGAAG)", + "F07 - D707-D506 (CTGAAGCT-TAATCTTA)", + "G07 - D707-D507 (CTGAAGCT-CAGGACGT)", + "H07 - D707-D508 (CTGAAGCT-GTACTGAC)", + "A08 - D708-D501 (TAATGCGC-TATAGCCT)", + "B08 - D708-D502 (TAATGCGC-ATAGAGGC)", + "C08 - D708-D503 (TAATGCGC-CCTATCCT)", + "D08 - D708-D504 (TAATGCGC-GGCTCTGA)", + "E08 - D708-D505 (TAATGCGC-AGGCGAAG)", + "F08 - D708-D506 (TAATGCGC-TAATCTTA)", + "G08 - D708-D507 (TAATGCGC-CAGGACGT)", + "H08 - D708-D508 (TAATGCGC-GTACTGAC)", + "A09 - D709-D501 (CGGCTATG-TATAGCCT)", + "B09 - D709-D502 (CGGCTATG-ATAGAGGC)", + "C09 - D709-D503 (CGGCTATG-CCTATCCT)", + "D09 - D709-D504 (CGGCTATG-GGCTCTGA)", + "E09 - D709-D505 (CGGCTATG-AGGCGAAG)", + "F09 - D709-D506 (CGGCTATG-TAATCTTA)", + "G09 - D709-D507 (CGGCTATG-CAGGACGT)", + "H09 - D709-D508 (CGGCTATG-GTACTGAC)", + "A10 - D710-D501 (TCCGCGAA-TATAGCCT)", + "B10 - D710-D502 (TCCGCGAA-ATAGAGGC)", + "C10 - D710-D503 (TCCGCGAA-CCTATCCT)", + "D10 - D710-D504 (TCCGCGAA-GGCTCTGA)", + "E10 - D710-D505 (TCCGCGAA-AGGCGAAG)", + "F10 - D710-D506 (TCCGCGAA-TAATCTTA)", + "G10 - D710-D507 (TCCGCGAA-CAGGACGT)", + "H10 - D710-D508 (TCCGCGAA-GTACTGAC)", + "A11 - D711-D501 (TCTCGCGC-TATAGCCT)", + "B11 - D711-D502 (TCTCGCGC-ATAGAGGC)", + "C11 - D711-D503 (TCTCGCGC-CCTATCCT)", + "D11 - D711-D504 (TCTCGCGC-GGCTCTGA)", + "E11 - D711-D505 (TCTCGCGC-AGGCGAAG)", + "F11 - D711-D506 (TCTCGCGC-TAATCTTA)", + "G11 - D711-D507 (TCTCGCGC-CAGGACGT)", + "H11 - D711-D508 (TCTCGCGC-GTACTGAC)", + "A12 - D712-D501 (AGCGATAG-TATAGCCT)", + "B12 - D712-D502 (AGCGATAG-ATAGAGGC)", + "C12 - D712-D503 (AGCGATAG-CCTATCCT)", + "D12 - D712-D504 (AGCGATAG-GGCTCTGA)", + "E12 - D712-D505 (AGCGATAG-AGGCGAAG)", + "F12 - D712-D506 (AGCGATAG-TAATCTTA)", + "G12 - D712-D507 (AGCGATAG-CAGGACGT)", + "H12 - D712-D508 (AGCGATAG-GTACTGAC)", + ], + IndexEnum.TWIST_UDI_A: [ + "A01 TWIST (TATCTTCAGC- CCAATATTCG)", + "B01 TWIST (TGCACGGATA- CGCAGACAAC)", + "C01 TWIST (GGTTGATAGA- TCGGAGCAGA)", + "D01 TWIST (ACTCCTGCCT- GAGTCCGTAG)", + "E01 TWIST (CCGATAGTCG- ATGTTCACGT)", + "F01 TWIST (CAAGATCGAA- TTCGATGGTT)", + "G01 TWIST (AGGCTCCTTC- TATCCGTGCA)", + "H01 TWIST (ATACGGATAG- AAGCGCAGAG)", + "A02 TWIST (AATAGCCTCA- CCGACTTAGT)", + "B02 TWIST (CTGCAATCGG- TTCTGCATCG)", + "C02 TWIST (CCTGAGTTAT- GGAAGTGCCA)", + "D02 TWIST (GACGTCCAGA- AGATTCAACC)", + "E02 TWIST (GAATAATCGG- TTCAGGAGAT)", + "F02 TWIST (CGGAGTGTGT- AAGGCGTCTG)", + "G02 TWIST (TTACCGACCG- ACGCTTGACA)", + "H02 TWIST (AGTGTTCGCC- CATGAAGTGA)", + "A03 TWIST (CTACGTTCTT- TTACGACCTG)", + "B03 TWIST (TCGACACGAA- ATGCAAGCCG)", + "C03 TWIST (CCGATAACTT- CTCCGTATAC)", + "D03 TWIST (TTGGACATCG- GAATCTGGTC)", + "E03 TWIST (AACGTTGAGA- CGGTCGGTAA)", + "F03 TWIST (GGCCAGTGAA- TCTGCTAATG)", + "G03 TWIST (ATGTCTCCGG- CTCTTATTCG)", + "H03 TWIST (GAAGGCGTTC- CACCTCTAGC)", + "A04 TWIST (TGTTCCTAGA- TTACTTACCG)", + "B04 TWIST (CTCTCGAGGT- CTATGCCTTA)", + "C04 TWIST (CTGTACGGTA- GGAAGGTACG)", + "D04 TWIST (CTTATGGCAA- GAGGAGACGT)", + "E04 TWIST (TCCGCATAGC- ACGCAAGGCA)", + "F04 TWIST (GCAAGCACCT- TATCCTGACG)", + "G04 TWIST (GCCTGTCCTA- GAAGACCGCT)", + "H04 TWIST (ACTGTCTATC- CAACGTGGAC)", + "A05 TWIST (CGTCCATGTA- TAAGTGCTCG)", + "B05 TWIST (CTAACTGCAA- CACATCGTAG)", + "C05 TWIST (TGCTTGTGGT- ACTACCGAGG)", + "D05 TWIST (TGTAAGCACA- GATGTGTTCT)", + "E05 TWIST (CTCGTTGCGT- AAGTGTCGTA)", + "F05 TWIST (GCTAGAGGTG- GGAGAACCAC)", + "G05 TWIST (AAGCGGAGAA- TGTACGAACT)", + "H05 TWIST (AATGACGCTG- GGATGAGTGC)", + "A06 TWIST (TTGGTACGCG- TAGTAGGACA)", + "B06 TWIST (TGAAGGTGAA- ACGCCTCGTT)", + "C06 TWIST (GTAGTGGCTT- CACCGCTGTT)", + "D06 TWIST (CGTAACAGAA- TCTATAGCGG)", + "E06 TWIST (AAGGCCATAA- CCGATGGACA)", + "F06 TWIST (TTCATAGACC- TTCAACATGC)", + "G06 TWIST (CCAACTCCGA- GGAGTAACGC)", + "H06 TWIST (CACGAGTATG- AGCCTTAGCG)", + "A07 TWIST (CCGCTACCAA- TTACCTCAGT)", + "B07 TWIST (CTGAACCTCC- CAGGCATTGT)", + "C07 TWIST (GGCCTTGTTA- GTGTTCCACG)", + "D07 TWIST (TTAACGCAGA- TTGATCCGCC)", + "E07 TWIST (AGGTAGTGCG- GGAGGCTGAT)", + "F07 TWIST (CGTGTAACTT- AACGTGACAA)", + "G07 TWIST (ACTTGTGACG- CACAAGCTCC)", + "H07 TWIST (CCATGCGTTG- CCGTGTTGTC)", + "A08 TWIST (CCTTGTAGCG- TTGAGCCAGC)", + "B08 TWIST (ACATACGTGA- GCGTTACAGA)", + "C08 TWIST (CTTGATATCC- TCCAGACATT)", + "D08 TWIST (CAGCCGATGT- TCGAACTCTT)", + "E08 TWIST (TCATGCGCTA- ACCTTCTCGG)", + "F08 TWIST (ACTCCGTCCA- AGACGCCAAC)", + "G08 TWIST (GACAGCCTTG- CAACCGTAAT)", + "H08 TWIST (CGGTTATCTG- TTATGCGTTG)", + "A09 TWIST (TACTCCACGG- CTATGAGAAC)", + "B09 TWIST (ACTTCCGGCA- AAGTTACACG)", + "C09 TWIST (GTGAAGCTGC- GCAATGTGAG)", + "D09 TWIST (TTGCTCTTCT- CGAAGTCGCA)", + "E09 TWIST (AACGCACGTA- CCTGATTCAA)", + "F09 TWIST (TTACTGCAGG- TAGAACGTGC)", + "G09 TWIST (CCAGTTGAGG- TTCGCAAGGT)", + "H09 TWIST (TGTGCGTTAA- TTAATGCCGA)", + "A10 TWIST (ACTAGTGCTT- AGAACAGAGT)", + "B10 TWIST (CGTGGAACAC- CCATCTGTTC)", + "C10 TWIST (ATGGAAGTGG- TTCGTAGGTG)", + "D10 TWIST (TGAGATCACA- GCACGGTACA)", + "E10 TWIST (GTCCTTGGTG- TGTCAAGAGG)", + "F10 TWIST (GAGCGTGGAA- TCTAAGGTAC)", + "G10 TWIST (CACACGCTGT- GAACGGAGAC)", + "H10 TWIST (TGGTTGTACA- CGCTACCATC)", + "A11 TWIST (ATCACTCACA- TTACGGTAAC)", + "B11 TWIST (CGGAGGTAGA- TTCAGATGGA)", + "C11 TWIST (GAGTTGACAA- TAGCATCTGT)", + "D11 TWIST (GCCGAACTTG- GGACGAGATC)", + "E11 TWIST (AGGCCTCACA- AGGTTCTGTT)", + "F11 TWIST (TCTCTGTTAG- CATACTCGTG)", + "G11 TWIST (TCCGACGATT- CCGGATACCA)", + "H11 TWIST (AGGCTATGTT- ATGTCCACCG)", + "A12 TWIST (CGTTCTCTTG- CACCAAGTGG)", + "B12 TWIST (TTGTCTATGG- TTGAGTACAC)", + "C12 TWIST (GATGGATACA- CGGTTCCGTA)", + "D12 TWIST (CACTTAGGCG- GGAGGTCCTA)", + "E12 TWIST (ACACTGGCTA- CCTGCTTGGA)", + "F12 TWIST (ATCGCCACTG- TTCACGTCAG)", + "G12 TWIST (CTGACGTGAA- AACATAGCCT)", + "H12 TWIST (TCAATCGTCT- TGACATAGTC)", + ], + IndexEnum.TWIST_UDI_B: [ + "A01 TWIST (ATCGCCTATA-TTGGCTCATA)", + "B01 TWIST (CGGATTCCTG-CAGAATACGG)", + "C01 TWIST (TCACACGTGG-TGTATAGGTC)", + "D01 TWIST (GCAGCATTCC-GTATACCACA)", + "E01 TWIST (CCGTGGTGAA-AACTGGACGG)", + "F01 TWIST (CACAGAACGG-TGTGAGTGAT)", + "G01 TWIST (ATGGATCGAA-AACTCAGCAA)", + "H01 TWIST (GGTCTCACCT-AGACGATTGA)", + "A02 TWIST (CAACACCGTA-CGGCTTGTTC)", + "B02 TWIST (CGAATATTGG-TTCCGTGCTG)", + "C02 TWIST (TAATTCCAGC-CGAATACGAT)", + "D02 TWIST (GTCGCGGTTA-ACCTCACCAG)", + "E02 TWIST (TTCTGCGTCG-TTCGTACACC)", + "F02 TWIST (ACGCATACTT-AAGTACGAGA)", + "G02 TWIST (GGCTGCACAA-TCGGACCTCT)", + "H02 TWIST (ACCAAGCCAA-CCGCCTTGTA)", + "A03 TWIST (CCAATTGTCC-GCGTATGAGC)", + "B03 TWIST (CAGACGCCTT-TTGAGCTCTG)", + "C03 TWIST (AATTGCCAGA-AACGTACCGT)", + "D03 TWIST (TGATACCAGA-GGCCTTCACA)", + "E03 TWIST (GAGGTTGTTA-TGTGCACTGG)", + "F03 TWIST (AGAGTATCAG-GGATACAGGT)", + "G03 TWIST (CTGGCGTATG-CCAATGTTAC)", + "H03 TWIST (GGTCATCTCG-GCTATGCGGA)", + "A04 TWIST (TGTCGAACAA-CCAGAATCTA)", + "B04 TWIST (GTGGCACGAA-CCAATTAGCA)", + "C04 TWIST (AAGCCTTAGA-CGTGTTATGA)", + "D04 TWIST (CGCTAAGGCT-TGTGCCGGTT)", + "E04 TWIST (AATCACGACC-CACCAGAAGT)", + "F04 TWIST (GTAGCTGTCG-TCTGCGTTAA)", + "G04 TWIST (CACGTAAGGT-AGCTTAGAGG)", + "H04 TWIST (TCACTTCATG-TTGCGACCAC)", + "A05 TWIST (GTTGGCGTCT-CGAAGTCTAG)", + "B05 TWIST (CACACGCCAA-GCTGAAGATA)", + "C05 TWIST (ACACTGTGAA-TCTGTTAGAC)", + "D05 TWIST (CGATTGTTCT-TGTACAACCA)", + "E05 TWIST (TCGGCTACTG-CTATTGTGTG)", + "F05 TWIST (TTGTAAGAGG-GAAGCAGCTG)", + "G05 TWIST (CGAGTCCGTT-CCGCAGTAGT)", + "H05 TWIST (GTGTACTCAA-AAGGTTGCTT)", + "A06 TWIST (GCGTGACGTT-CTCTCTTCTA)", + "B06 TWIST (AGGCGTCTGA-GGATCTTGTG)", + "C06 TWIST (ACTTACGAGG-AGCGATTAAC)", + "D06 TWIST (CAGGTCGTAA-GAAGGCATAA)", + "E06 TWIST (TACGCTAGTT-AGCAGACTAA)", + "F06 TWIST (TCTGTCGTGC-AAGCACTAGT)", + "G06 TWIST (GATCTTGGCA-TTAGACAGCG)", + "H06 TWIST (TGGAGAGCCA-TTAGGCACAA)", + "A07 TWIST (ACCAATCTCG-TTCCGGCACT)", + "B07 TWIST (GTCGTGACAC-TTGTATGGCT)", + "C07 TWIST (TCTCTAGTCG-TGGATCGATT)", + "D07 TWIST (ATTACGGTTG-CGGAATCACC)", + "E07 TWIST (CGGTAAGTAA-GAGCTATCTA)", + "F07 TWIST (TAACGTCCGG-ACCTCGAGAG)", + "G07 TWIST (GAACACAGTT-CCGAATTCAC)", + "H07 TWIST (AGGTCCTATA-AACGTCACGC)", + "A08 TWIST (TTGACCTAGC-TTGGTGTTCC)", + "B08 TWIST (GCTTCAATCA-CCAGGTGGAA)", + "C08 TWIST (TGCGTGCGAA-TCATACCGAT)", + "D08 TWIST (AATGGTACCT-CGACGGTTGT)", + "E08 TWIST (TGTATCGCGA-CACTCACACG)", + "F08 TWIST (GTAACATTGG-TTGGCCACGA)", + "G08 TWIST (CAACAATTCG-AATCGGTCGC)", + "H08 TWIST (GCGTGTCATG-AGAACAATCG)", + "A09 TWIST (TAGATCCGAA-CTATCGAAGT)", + "B09 TWIST (TCTTAACTGG-TCGGCCTGAA)", + "C09 TWIST (GTCACATCCG-TCACTGTTCT)", + "D09 TWIST (TGAAGCATCT-GGTATCTAAC)", + "E09 TWIST (CGGACTACTT-CGTATTAAGG)", + "F09 TWIST (AACGGAGTCC-TAGGAGTGTC)", + "G09 TWIST (AGGTGTGACC-CTCCGAACTC)", + "H09 TWIST (CCAGAGTTCC-ATGTCTCTCG)", + "A10 TWIST (CCAGTGATTG-AGGTGCACTT)", + "B10 TWIST (GACTGACATA-TTGGCCGCAT)", + "C10 TWIST (GCGATCCTTG-GGTGTCTGAG)", + "D10 TWIST (TGTTCCACTT-CCGTGCCATT)", + "E10 TWIST (ATCCAATAGG-AAGATGACGA)", + "F10 TWIST (AGACCGTTAA-TGTATTGCCA)", + "G10 TWIST (ACTATTGACC-AACCATCGGC)", + "H10 TWIST (GCCTAATTCC-CGTGCAACCT)", + "A11 TWIST (GTAGGTACAA-TTCTTGAGTG)", + "B11 TWIST (TGCGACTTCG-TCTGCAACAA)", + "C11 TWIST (TTGTCACGTT-CCGCTACACA)", + "D11 TWIST (CAACGACTGA-CTCTGTCAGG)", + "E11 TWIST (GATTCGGCTA-TTAACGGTCT)", + "F11 TWIST (TGGTGGCTAG-CGATGACCTT)", + "G11 TWIST (AGGCCAGGAT-AGGCAGGAGT)", + "H11 TWIST (AACGCCTGTG-AACGGACTCG)", + "A12 TWIST (CGTGTGAGTG-TTGGTTCGGC)", + "B12 TWIST (CGTATGTGAA-CGCACTACCT)", + "C12 TWIST (TACGTCACAA-CCATACCACG)", + "D12 TWIST (GGAAGATCCG-GAATTCGGTA)", + "E12 TWIST (CATGTCAGCT-AGTCCTCCAC)", + "F12 TWIST (ACAGCGTCAC-TAGTCATTCG)", + "G12 TWIST (TGTTACAAGG-TTGAGGTCGC)", + "H12 TWIST (CTTATAGAGG-CAACGTTATG)", + ], + IndexEnum.TWIST_UDI_C: [ + "A01 TWIST (TCGAAGTCAA-CGGAGGAATG)", + "B01 TWIST (CGAGGCCTAT-GAGTCAGCCA)", + "C01 TWIST (TCCTGAAGTG-GGAATTAGGC)", + "D01 TWIST (AATCCTTACC-TTCGCCACAC)", + "E01 TWIST (TTGTTGCAGA-CCTCGCTTAC)", + "F01 TWIST (AATCTAGGCC-ACAGCGTGTG)", + "G01 TWIST (GGCTCTACTG-TTCCGCTTCT)", + "H01 TWIST (GTCCACGTTG-CAGCGTCATT)", + "A02 TWIST (CTCCGCAGTT-CCGTAGAACA)", + "B02 TWIST (AGAACAGTGA-CGGTTATCGT)", + "C02 TWIST (GCTCTTATTG-TCTGGTATCA)", + "D02 TWIST (TGTAGACGAA-AAGTATGCGT)", + "E02 TWIST (CTTGTCGTCG-TTCCTTCGAG)", + "F02 TWIST (TCGTCTTACA-GCTATGGATA)", + "G02 TWIST (GAGAGGAGGA-AGGTACCATT)", + "H02 TWIST (GTTAGATACC-TTACGGAGTC)", + "A03 TWIST (GGCTTAAGAA-TGAGGACTTA)", + "B03 TWIST (TCTGGTACAA-TTGAGTTGCC)", + "C03 TWIST (GTGAATTCGG-AGCTTCGCGA)", + "D03 TWIST (GAATGGAGAA-CATACGCCAG)", + "E03 TWIST (AGTCAATTGG-CAAGACCAGC)", + "F03 TWIST (CGCATCACCT-GATAGACAGT)", + "G03 TWIST (TATTGACACC-CGCTCGTGAA)", + "H03 TWIST (AGACTGTCGG-TCTCTAACAG)", + "A04 TWIST (ATCTGGACTC-ACCTAGGAGG)", + "B04 TWIST (GAGAATAAGG-TCTGTACCTT)", + "C04 TWIST (TGTTGTCGCC-CTCAGGCCAT)", + "D04 TWIST (CTGCGGTGTT-TTGTGCAGCC)", + "E04 TWIST (GATAACTCCG-TAGCCGAATC)", + "F04 TWIST (ATCCTTGTAC-AAGCCTGTTA)", + "G04 TWIST (TACGCGTATA-TGTACAGTAG)", + "H04 TWIST (CCACCAATTG-CGATTCTGCC)", + "A05 TWIST (TGTGAAGGCC-TTGCTAAGGA)", + "B05 TWIST (CCTTGACTGC-ACTCCTTGGC)", + "C05 TWIST (AATGCGTCGG-GAAGGCGAAC)", + "D05 TWIST (AAGACTACAC-CAATACCTTG)", + "E05 TWIST (GTCAGTGCAG-CGACGACAAG)", + "F05 TWIST (CTCACCAGAA-GAACCTGACC)", + "G05 TWIST (TCTCGTACTT-TTGCCTCGCA)", + "H05 TWIST (TCAGATTAGG-TTCGTGTCGA)", + "A06 TWIST (CACTCAAGAA-TGGATGGCAA)", + "B06 TWIST (AGAGCCATTC-TTCACCAGCT)", + "C06 TWIST (CACGATTCCG-CCTGAGTAGC)", + "D06 TWIST (TTGGAGCCTG-AGGTGTCCGT)", + "E06 TWIST (TTACGACTTG-GTCTGGTTGC)", + "F06 TWIST (TTAAGGTCGG-CTCTTAGATG)", + "G06 TWIST (GGTTCTGTCA-TATCACCTGC)", + "H06 TWIST (GATACGCACC-CAGAGGCAAG)", + "A07 TWIST (TCGCGAAGCT-CCGGTCAACA)", + "B07 TWIST (GTTAAGACGG-TCACGAGGTG)", + "C07 TWIST (CCGGTCATAC-CCATAGACAA)", + "D07 TWIST (GTCAGCTTAA-GAGCTTGGAC)", + "E07 TWIST (ACCGCGGATA-TACGGTGTTG)", + "F07 TWIST (GTTGCATCAA-TTCAACTCGA)", + "G07 TWIST (TGTGCACCAA-AAGGCAGGTA)", + "H07 TWIST (ATCTGTGGTC-CGGCCAATTC)", + "A08 TWIST (CACAAGATCC-CAACCGGACA)", + "B08 TWIST (CTGCTAGCTG-AACTTGGCCG)", + "C08 TWIST (ACCGGTCGAA-TGGAACATAG)", + "D08 TWIST (GCACGTTCTA-TTCGGATCTA)", + "E08 TWIST (AAGGAAGGAA-CGGAATCGTG)", + "F08 TWIST (AGAGAGATAG-TCTAATCGGT)", + "G08 TWIST (GGTTCCTATT-GCTGGAATTA)", + "H08 TWIST (TTCACGAGCG-CGCTTCTCAC)", + "A09 TWIST (GGCACAACCT-TAGACTCCTG)", + "B09 TWIST (TGACTCAGAA-CCGTTGATTG)", + "C09 TWIST (CGATCTCAGG-CGAACCTCCA)", + "D09 TWIST (CCTGCTGGAA-TTGGAAGTTG)", + "E09 TWIST (GAGCTGTATA-CCAGGAGTAC)", + "F09 TWIST (AACCTGACGG-AGGTTCGTCG)", + "G09 TWIST (AAGCTCGTGG-GACCTGAAGA)", + "H09 TWIST (GTCCAAGCTC-TTAACGCACA)", + "A10 TWIST (CTAGACTTCG-TCGGAGTTGG)", + "B10 TWIST (TCCAAGGTAA-CGATGACTCC)", + "C10 TWIST (CTTGGTAGCA-TATAGGTTGG)", + "D10 TWIST (AACGAGGCGT-GACAAGTGTT)", + "E10 TWIST (CAGAAGATGG-TTCTCCGGAA)", + "F10 TWIST (TGATACATCC-ACACACTCCG)", + "G10 TWIST (GCGCGTAGTT-CTGGTCACTA)", + "H10 TWIST (GTTGTCTGCG-TTCGTGCCAC)", + "A11 TWIST (CTTAGCGCTG-AGATCATGGA)", + "B11 TWIST (ATCAGCCTCC-GAGTATGTAC)", + "C11 TWIST (TGCAGTGCTC-TAGAACACCT)", + "D11 TWIST (GAGCTCAGAC-CCAGTTAAGA)", + "E11 TWIST (ACCTGGACAA-CGCTTATCTG)", + "F11 TWIST (CAACTTCCAA-GAGCTCTTAC)", + "G11 TWIST (CCATCCTGTG-TCTCAAGGCG)", + "H11 TWIST (GGCAGTTAGA-CTAAGTACCA)", + "A12 TWIST (TCACATGAGA-TCGACAAGCC)", + "B12 TWIST (TATTCGTTGG-TTCGACATCA)", + "C12 TWIST (AGCGGTCTTC-AGTGGTACTT)", + "D12 TWIST (GCGACCGATT-TTGCACTTGT)", + "E12 TWIST (GATCTCGTCC-GTCTTCGCAG)", + "F12 TWIST (CCATTATAGG-CAGGCTCCAA)", + "G12 TWIST (ACAGACCACG-CCAGGTTACG)", + "H12 TWIST (ATTCCACACA-CAATCGCCTA)", + ], +} diff --git a/cg/services/orders/validation/model_validator/model_validator.py b/cg/services/orders/validation/model_validator/model_validator.py new file mode 100644 index 0000000000..5ff97d792f --- /dev/null +++ b/cg/services/orders/validation/model_validator/model_validator.py @@ -0,0 +1,20 @@ +from typing import TypeVar + +from pydantic_core import ValidationError + +from cg.services.orders.validation.errors.validation_errors import ValidationErrors +from cg.services.orders.validation.model_validator.utils import convert_errors +from cg.services.orders.validation.models.order import Order + +ParsedOrder = TypeVar("ParsedOrder", bound=Order) + + +class ModelValidator: + + @staticmethod + def validate(order: dict, model: type[Order]) -> tuple[ParsedOrder | None, ValidationErrors]: + try: + order: Order = model.model_validate(order) + return order, ValidationErrors() + except ValidationError as error: + return None, convert_errors(pydantic_errors=error) diff --git a/cg/services/orders/validation/model_validator/utils.py b/cg/services/orders/validation/model_validator/utils.py new file mode 100644 index 0000000000..a4e736e955 --- /dev/null +++ b/cg/services/orders/validation/model_validator/utils.py @@ -0,0 +1,169 @@ +from pydantic_core import ErrorDetails, ValidationError + +from cg.services.orders.validation.errors.case_errors import CaseError +from cg.services.orders.validation.errors.case_sample_errors import CaseSampleError +from cg.services.orders.validation.errors.order_errors import OrderError +from cg.services.orders.validation.errors.sample_errors import SampleError +from cg.services.orders.validation.errors.validation_errors import ValidationErrors + + +def convert_errors(pydantic_errors: ValidationError) -> ValidationErrors: + error_details: list[ErrorDetails] = pydantic_errors.errors() + order_errors: list[OrderError] = convert_order_errors(error_details) + case_errors: list[CaseError] = convert_case_errors(error_details=error_details) + case_sample_errors: list[CaseSampleError] = convert_case_sample_errors( + error_details=error_details + ) + sample_errors: list[SampleError] = convert_sample_errors(error_details=error_details) + return ValidationErrors( + order_errors=order_errors, + case_errors=case_errors, + case_sample_errors=case_sample_errors, + sample_errors=sample_errors, + ) + + +def convert_order_errors(error_details: list[ErrorDetails]) -> list[OrderError]: + errors: list[OrderError] = [] + order_details: list[ErrorDetails] = get_order_error_details(error_details) + for error_detail in order_details: + error: OrderError = create_order_error(error_detail) + errors.append(error) + return errors + + +def convert_case_errors(error_details: list[ErrorDetails]) -> list[CaseError]: + errors: list[CaseError] = [] + case_details: list[ErrorDetails] = get_case_error_details(error_details) + for error_detail in case_details: + error: CaseError = create_case_error(error_detail) + errors.append(error) + return errors + + +def convert_sample_errors(error_details: list[ErrorDetails]) -> list[SampleError]: + errors: list[SampleError] = [] + sample_details: list[ErrorDetails] = get_sample_error_details(error_details) + for error_detail in sample_details: + error: SampleError = create_sample_error(error_detail) + errors.append(error) + return errors + + +def create_order_error(error: ErrorDetails) -> OrderError: + field_name: str = get_order_field_name(error) + message: str = get_error_message(error) + error = OrderError(field=field_name, message=message) + return error + + +def create_sample_error(error: ErrorDetails) -> SampleError: + sample_index: int = get_sample_index(error) + field_name: str = get_sample_field_name(error) + message: str = get_error_message(error) + error = SampleError(sample_index=sample_index, field=field_name, message=message) + return error + + +def create_case_error(error: ErrorDetails) -> CaseError: + case_index: int = get_case_index(error=error) + field_name: str = get_case_field_name(error) + message: str = get_error_message(error) + error = CaseError(case_index=case_index, field=field_name, message=message) + return error + + +def convert_case_sample_errors(error_details: list[ErrorDetails]) -> list[CaseSampleError]: + errors: list[CaseSampleError] = [] + case_sample_details: list[ErrorDetails] = get_case_sample_error_details(error_details) + for error_detail in case_sample_details: + error = create_case_sample_error(error_detail) + errors.append(error) + return errors + + +def create_case_sample_error(error: ErrorDetails) -> CaseSampleError: + case_index: int = get_case_index(error=error) + sample_index: int = get_case_sample_index(error=error) + field_name: str = get_case_sample_field_name(error) + message: str = get_error_message(error) + error = CaseSampleError( + case_index=case_index, + sample_index=sample_index, + field=field_name, + message=message, + ) + return error + + +""" +What follows below are ways of extracting data from a Pydantic ErrorDetails object. The aim is to find out +where the error occurred, for which the 'loc' value (which is a tuple) can be used. It is generally structured in +alternating strings and ints, specifying field names and list indices. An example: +if loc = ('cases', 3, 'samples', 2, 'well_position'), that means that the error stems from the well_position of the +third sample in the fourth case. +""" + + +def get_sample_error_details(error_details: list[ErrorDetails]) -> list[ErrorDetails]: + return [error for error in error_details if is_sample_error(error)] + + +def get_case_error_details(error_details: list[ErrorDetails]) -> list[ErrorDetails]: + return [error for error in error_details if is_case_error(error)] + + +def get_case_sample_error_details(error_details: list[ErrorDetails]) -> list[ErrorDetails]: + return [error for error in error_details if is_case_sample_error(error)] + + +def get_order_error_details(error_details: list[ErrorDetails]) -> list[ErrorDetails]: + return [error for error in error_details if is_order_error(error)] + + +def is_sample_error(error: ErrorDetails) -> bool: + return len(error["loc"]) == 3 and error["loc"][0] == "samples" + + +def is_case_error(error: ErrorDetails) -> bool: + return len(error["loc"]) == 4 and error["loc"][0] == "cases" + + +def is_case_sample_error(error: ErrorDetails) -> bool: + return len(error["loc"]) == 7 + + +def is_order_error(error: ErrorDetails) -> bool: + return len(error["loc"]) == 1 + + +def get_error_message(error: ErrorDetails) -> str: + return error["msg"] + + +def get_sample_field_name(error: ErrorDetails) -> str: + return error["loc"][2] + + +def get_case_field_name(error: ErrorDetails) -> str: + return error["loc"][3] + + +def get_case_sample_field_name(error: ErrorDetails) -> str: + return error["loc"][6] + + +def get_order_field_name(error: ErrorDetails) -> str: + return error["loc"][0] + + +def get_sample_index(error: ErrorDetails) -> int: + return error["loc"][1] + + +def get_case_index(error: ErrorDetails) -> int: + return error["loc"][1] + + +def get_case_sample_index(error: ErrorDetails) -> int: + return error["loc"][4] diff --git a/cg/services/orders/validation/models/__init__.py b/cg/services/orders/validation/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/orders/validation/models/case.py b/cg/services/orders/validation/models/case.py new file mode 100644 index 0000000000..6c7207b547 --- /dev/null +++ b/cg/services/orders/validation/models/case.py @@ -0,0 +1,66 @@ +from pydantic import BaseModel, Discriminator, Field, Tag, model_validator +from typing_extensions import Annotated + +from cg.constants.priority import PriorityTerms +from cg.models.orders.sample_base import NAME_PATTERN +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.models.sample_aliases import SampleInCase + +NewSample = Annotated[SampleInCase, Tag("new")] +ExistingSampleType = Annotated[ExistingSample, Tag("existing")] + + +class Case(BaseModel): + name: str = Field(pattern=NAME_PATTERN, min_length=2, max_length=128) + priority: PriorityTerms = PriorityTerms.STANDARD + samples: list[ + Annotated[ + NewSample | ExistingSampleType, + Discriminator(has_internal_id), + ] + ] + + @property + def is_new(self) -> bool: + return True + + @property + def enumerated_samples(self) -> enumerate[NewSample | ExistingSampleType]: + return enumerate(self.samples) + + @property + def enumerated_new_samples(self) -> list[tuple[int, SampleInCase]]: + samples: list[tuple[int, SampleInCase]] = [] + for sample_index, sample in self.enumerated_samples: + if sample.is_new: + samples.append((sample_index, sample)) + return samples + + @property + def enumerated_existing_samples(self) -> list[tuple[int, ExistingSample]]: + samples: list[tuple[int, ExistingSample]] = [] + for sample_index, sample in self.enumerated_samples: + if not sample.is_new: + samples.append((sample_index, sample)) + return samples + + def get_sample(self, sample_name: str) -> SampleInCase | None: + for _, sample in self.enumerated_new_samples: + if sample.name == sample_name: + return sample + + @model_validator(mode="before") + def convert_empty_strings_to_none(cls, data): + if isinstance(data, dict): + for key, value in data.items(): + if value == "": + data[key] = None + return data + + @model_validator(mode="after") + def set_case_name_on_new_samples(self): + """Sets the case name on new samples, so it can be easily fetched when stored in LIMS.""" + for _, sample in self.enumerated_new_samples: + sample._case_name = self.name + return self diff --git a/cg/services/orders/validation/models/case_aliases.py b/cg/services/orders/validation/models/case_aliases.py new file mode 100644 index 0000000000..6ea53d6895 --- /dev/null +++ b/cg/services/orders/validation/models/case_aliases.py @@ -0,0 +1,6 @@ +from cg.services.orders.validation.workflows.mip_dna.models.case import MipDnaCase +from cg.services.orders.validation.workflows.tomte.models.case import TomteCase + +CaseContainingRelatives = TomteCase | MipDnaCase + +CaseWithSkipRC = TomteCase | MipDnaCase diff --git a/cg/services/orders/validation/models/discriminators.py b/cg/services/orders/validation/models/discriminators.py new file mode 100644 index 0000000000..272126cd74 --- /dev/null +++ b/cg/services/orders/validation/models/discriminators.py @@ -0,0 +1,7 @@ +from typing import Any + + +def has_internal_id(v: Any) -> str: + if isinstance(v, dict): + return "existing" if v.get("internal_id") else "new" + return "existing" if getattr(v, "internal_id", None) else "new" diff --git a/cg/services/orders/validation/models/existing_case.py b/cg/services/orders/validation/models/existing_case.py new file mode 100644 index 0000000000..3bb7a508de --- /dev/null +++ b/cg/services/orders/validation/models/existing_case.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class ExistingCase(BaseModel): + internal_id: str + panels: list[str] | None = None + + @property + def is_new(self) -> bool: + return False diff --git a/cg/services/orders/validation/models/existing_sample.py b/cg/services/orders/validation/models/existing_sample.py new file mode 100644 index 0000000000..87e8febd28 --- /dev/null +++ b/cg/services/orders/validation/models/existing_sample.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel, Field + +from cg.models.orders.sample_base import NAME_PATTERN, StatusEnum + + +class ExistingSample(BaseModel): + father: str | None = Field(None, pattern=NAME_PATTERN) + internal_id: str + mother: str | None = Field(None, pattern=NAME_PATTERN) + status: StatusEnum | None = None + + @property + def is_new(self) -> bool: + return False diff --git a/cg/services/orders/validation/models/order.py b/cg/services/orders/validation/models/order.py new file mode 100644 index 0000000000..8f950f0c4c --- /dev/null +++ b/cg/services/orders/validation/models/order.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, Field, PrivateAttr, model_validator + +from cg.constants import DataDelivery +from cg.models.orders.constants import OrderType + + +class Order(BaseModel): + comment: str | None = None + customer: str = Field(min_length=1) + delivery_type: DataDelivery + order_type: OrderType = Field(alias="project_type") + name: str = Field(min_length=1) + skip_reception_control: bool = False + _generated_ticket_id: int | None = PrivateAttr(default=None) + _user_id: int = PrivateAttr(default=None) + + @model_validator(mode="before") + def convert_empty_strings_to_none(cls, data): + if isinstance(data, dict): + for key, value in data.items(): + if value == "": + data[key] = None + return data diff --git a/cg/services/orders/validation/models/order_aliases.py b/cg/services/orders/validation/models/order_aliases.py new file mode 100644 index 0000000000..237d1427a4 --- /dev/null +++ b/cg/services/orders/validation/models/order_aliases.py @@ -0,0 +1,12 @@ +from cg.services.orders.validation.workflows.fluffy.models.order import FluffyOrder +from cg.services.orders.validation.workflows.metagenome.models.sample import MetagenomeSample +from cg.services.orders.validation.workflows.microbial_fastq.models.order import MicrobialFastqOrder +from cg.services.orders.validation.workflows.microsalt.models.order import MicrosaltOrder +from cg.services.orders.validation.workflows.mutant.models.order import MutantOrder +from cg.services.orders.validation.workflows.rml.models.order import RmlOrder +from cg.services.orders.validation.workflows.taxprofiler.models.sample import TaxprofilerSample + +OrderWithIndexedSamples = FluffyOrder | RmlOrder +OrderWithControlSamples = ( + MetagenomeSample | MicrobialFastqOrder | MicrosaltOrder | MutantOrder | TaxprofilerSample +) diff --git a/cg/services/orders/validation/models/order_with_cases.py b/cg/services/orders/validation/models/order_with_cases.py new file mode 100644 index 0000000000..d440a35018 --- /dev/null +++ b/cg/services/orders/validation/models/order_with_cases.py @@ -0,0 +1,43 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.case import Case +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_case import ExistingCase +from cg.services.orders.validation.models.order import Order +from cg.services.orders.validation.models.sample import Sample + +NewCaseType = Annotated[Case, Tag("new")] +ExistingCaseType = Annotated[ExistingCase, Tag("existing")] + + +class OrderWithCases(Order): + cases: list[Annotated[NewCaseType | ExistingCaseType, Discriminator(has_internal_id)]] + + @property + def enumerated_cases(self) -> enumerate[Case | ExistingCase]: + return enumerate(self.cases) + + @property + def enumerated_new_cases(self) -> list[tuple[int, Case]]: + cases: list[tuple[int, Case]] = [] + for case_index, case in self.enumerated_cases: + if case.is_new: + cases.append((case_index, case)) + return cases + + @property + def enumerated_existing_cases(self) -> list[tuple[int, ExistingCase]]: + cases: list[tuple[int, ExistingCase]] = [] + for case_index, case in self.enumerated_cases: + if not case.is_new: + cases.append((case_index, case)) + return cases + + @property + def enumerated_new_samples(self) -> list[tuple[int, int, Sample]]: + return [ + (case_index, sample_index, sample) + for case_index, case in self.enumerated_new_cases + for sample_index, sample in case.enumerated_new_samples + ] diff --git a/cg/services/orders/validation/models/order_with_samples.py b/cg/services/orders/validation/models/order_with_samples.py new file mode 100644 index 0000000000..91f228d825 --- /dev/null +++ b/cg/services/orders/validation/models/order_with_samples.py @@ -0,0 +1,10 @@ +from cg.services.orders.validation.models.order import Order +from cg.services.orders.validation.models.sample import Sample + + +class OrderWithSamples(Order): + samples: list[Sample] + + @property + def enumerated_samples(self) -> enumerate[Sample]: + return enumerate(self.samples) diff --git a/cg/services/orders/validation/models/sample.py b/cg/services/orders/validation/models/sample.py new file mode 100644 index 0000000000..21dcfa453e --- /dev/null +++ b/cg/services/orders/validation/models/sample.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, Field, PrivateAttr, model_validator + +from cg.models.orders.sample_base import NAME_PATTERN, ContainerEnum + + +class Sample(BaseModel): + application: str = Field(min_length=1) + _case_name: str = PrivateAttr(default="") + comment: str | None = None + container: ContainerEnum + container_name: str | None = None + _generated_lims_id: str | None = PrivateAttr(default=None) # Will be populated by LIMS + name: str = Field(pattern=NAME_PATTERN, min_length=2, max_length=128) + volume: int | None = None + well_position: str | None = None + + @property + def is_new(self) -> bool: + return True + + @property + def is_on_plate(self) -> bool: + return self.container == ContainerEnum.plate + + @model_validator(mode="before") + @classmethod + def convert_empty_strings_to_none(cls, data): + if isinstance(data, dict): + for key, value in data.items(): + if value == "": + data[key] = None + return data + + @model_validator(mode="after") + def set_tube_name_default(self): + if self.container == ContainerEnum.tube and not self.container_name: + self.container_name = self.name + return self diff --git a/cg/services/orders/validation/models/sample_aliases.py b/cg/services/orders/validation/models/sample_aliases.py new file mode 100644 index 0000000000..f89c26df76 --- /dev/null +++ b/cg/services/orders/validation/models/sample_aliases.py @@ -0,0 +1,23 @@ +from cg.services.orders.validation.workflows.balsamic.models.sample import BalsamicSample +from cg.services.orders.validation.workflows.balsamic_umi.models.sample import BalsamicUmiSample +from cg.services.orders.validation.workflows.fastq.models.sample import FastqSample +from cg.services.orders.validation.workflows.fluffy.models.sample import FluffySample +from cg.services.orders.validation.workflows.mip_dna.models.sample import MipDnaSample +from cg.services.orders.validation.workflows.mip_rna.models.sample import MipRnaSample +from cg.services.orders.validation.workflows.rml.models.sample import RmlSample +from cg.services.orders.validation.workflows.rna_fusion.models.sample import RnaFusionSample +from cg.services.orders.validation.workflows.tomte.models.sample import TomteSample + +HumanSample = ( + BalsamicSample | BalsamicUmiSample | FastqSample | MipDnaSample | RnaFusionSample | TomteSample +) + +IndexedSample = FluffySample | RmlSample + +SampleInCase = ( + BalsamicSample | BalsamicUmiSample | MipDnaSample | MipRnaSample | RnaFusionSample | TomteSample +) + +SampleWithRelatives = TomteSample | MipDnaSample + +SampleWithSkipRC = TomteSample | MipDnaSample | FastqSample diff --git a/cg/services/orders/validation/order_type_maps.py b/cg/services/orders/validation/order_type_maps.py new file mode 100644 index 0000000000..dd2a31379b --- /dev/null +++ b/cg/services/orders/validation/order_type_maps.py @@ -0,0 +1,143 @@ +from pydantic import BaseModel, ConfigDict + +from cg.models.orders.constants import OrderType +from cg.services.orders.validation.models.order import Order +from cg.services.orders.validation.workflows.balsamic.models.order import BalsamicOrder +from cg.services.orders.validation.workflows.balsamic.validation_rules import ( + BALSAMIC_CASE_RULES, + BALSAMIC_CASE_SAMPLE_RULES, +) +from cg.services.orders.validation.workflows.balsamic_umi.models.order import BalsamicUmiOrder +from cg.services.orders.validation.workflows.balsamic_umi.validation_rules import ( + BALSAMIC_UMI_CASE_RULES, + BALSAMIC_UMI_CASE_SAMPLE_RULES, +) +from cg.services.orders.validation.workflows.fastq.models.order import FastqOrder +from cg.services.orders.validation.workflows.fastq.validation_rules import FASTQ_SAMPLE_RULES +from cg.services.orders.validation.workflows.fluffy.models.order import FluffyOrder +from cg.services.orders.validation.workflows.fluffy.validation_rules import FLUFFY_SAMPLE_RULES +from cg.services.orders.validation.workflows.metagenome.models.order import MetagenomeOrder +from cg.services.orders.validation.workflows.metagenome.validation_rules import ( + METAGENOME_SAMPLE_RULES, +) +from cg.services.orders.validation.workflows.microbial_fastq.models.order import MicrobialFastqOrder +from cg.services.orders.validation.workflows.microbial_fastq.validation_rules import ( + MICROBIAL_FASTQ_SAMPLE_RULES, +) +from cg.services.orders.validation.workflows.microsalt.models.order import MicrosaltOrder +from cg.services.orders.validation.workflows.microsalt.validation_rules import ( + MICROSALT_SAMPLE_RULES, +) +from cg.services.orders.validation.workflows.mip_dna.models.order import MipDnaOrder +from cg.services.orders.validation.workflows.mip_dna.validation_rules import ( + MIP_DNA_CASE_RULES, + MIP_DNA_CASE_SAMPLE_RULES, +) +from cg.services.orders.validation.workflows.mip_rna.models.order import MipRnaOrder +from cg.services.orders.validation.workflows.mip_rna.validation_rules import ( + MIP_RNA_CASE_RULES, + MIP_RNA_CASE_SAMPLE_RULES, +) +from cg.services.orders.validation.workflows.mutant.models.order import MutantOrder +from cg.services.orders.validation.workflows.mutant.validation_rules import MUTANT_SAMPLE_RULES +from cg.services.orders.validation.workflows.order_validation_rules import ORDER_RULES +from cg.services.orders.validation.workflows.pacbio_long_read.models.order import PacbioOrder +from cg.services.orders.validation.workflows.pacbio_long_read.validation_rules import ( + PACBIO_LONG_READ_SAMPLE_RULES, +) +from cg.services.orders.validation.workflows.rml.models.order import RmlOrder +from cg.services.orders.validation.workflows.rml.validation_rules import RML_SAMPLE_RULES +from cg.services.orders.validation.workflows.rna_fusion.models.order import RnaFusionOrder +from cg.services.orders.validation.workflows.rna_fusion.validation_rules import ( + RNAFUSION_CASE_RULES, + RNAFUSION_CASE_SAMPLE_RULES, +) +from cg.services.orders.validation.workflows.taxprofiler.models.order import TaxprofilerOrder +from cg.services.orders.validation.workflows.taxprofiler.validation_rules import ( + TAXPROFILER_SAMPLE_RULES, +) +from cg.services.orders.validation.workflows.tomte.models.order import TomteOrder +from cg.services.orders.validation.workflows.tomte.validation_rules import ( + TOMTE_CASE_RULES, + TOMTE_CASE_SAMPLE_RULES, +) + + +class RuleSet(BaseModel): + case_rules: list[callable] = [] + case_sample_rules: list[callable] = [] + order_rules: list[callable] = ORDER_RULES + sample_rules: list[callable] = [] + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +ORDER_TYPE_RULE_SET_MAP: dict[OrderType, RuleSet] = { + OrderType.BALSAMIC: RuleSet( + case_rules=BALSAMIC_CASE_RULES, case_sample_rules=BALSAMIC_CASE_SAMPLE_RULES + ), + OrderType.BALSAMIC_UMI: RuleSet( + case_rules=BALSAMIC_UMI_CASE_RULES, + case_sample_rules=BALSAMIC_UMI_CASE_SAMPLE_RULES, + ), + OrderType.FASTQ: RuleSet( + sample_rules=FASTQ_SAMPLE_RULES, + ), + OrderType.FLUFFY: RuleSet( + sample_rules=FLUFFY_SAMPLE_RULES, + ), + OrderType.METAGENOME: RuleSet( + sample_rules=METAGENOME_SAMPLE_RULES, + ), + OrderType.MICROBIAL_FASTQ: RuleSet( + sample_rules=MICROBIAL_FASTQ_SAMPLE_RULES, + ), + OrderType.MICROSALT: RuleSet( + sample_rules=MICROSALT_SAMPLE_RULES, + ), + OrderType.MIP_DNA: RuleSet( + case_rules=MIP_DNA_CASE_RULES, case_sample_rules=MIP_DNA_CASE_SAMPLE_RULES + ), + OrderType.MIP_RNA: RuleSet( + case_rules=MIP_RNA_CASE_RULES, + case_sample_rules=MIP_RNA_CASE_SAMPLE_RULES, + ), + OrderType.PACBIO_LONG_READ: RuleSet( + sample_rules=PACBIO_LONG_READ_SAMPLE_RULES, + ), + OrderType.RML: RuleSet( + sample_rules=RML_SAMPLE_RULES, + ), + OrderType.RNAFUSION: RuleSet( + case_rules=RNAFUSION_CASE_RULES, + case_sample_rules=RNAFUSION_CASE_SAMPLE_RULES, + ), + OrderType.SARS_COV_2: RuleSet( + sample_rules=MUTANT_SAMPLE_RULES, + ), + OrderType.TAXPROFILER: RuleSet( + sample_rules=TAXPROFILER_SAMPLE_RULES, + ), + OrderType.TOMTE: RuleSet( + case_rules=TOMTE_CASE_RULES, + case_sample_rules=TOMTE_CASE_SAMPLE_RULES, + ), +} + +ORDER_TYPE_MODEL_MAP: dict[OrderType, type[Order]] = { + OrderType.BALSAMIC: BalsamicOrder, + OrderType.BALSAMIC_UMI: BalsamicUmiOrder, + OrderType.FASTQ: FastqOrder, + OrderType.FLUFFY: FluffyOrder, + OrderType.METAGENOME: MetagenomeOrder, + OrderType.MICROBIAL_FASTQ: MicrobialFastqOrder, + OrderType.MICROSALT: MicrosaltOrder, + OrderType.MIP_DNA: MipDnaOrder, + OrderType.MIP_RNA: MipRnaOrder, + OrderType.PACBIO_LONG_READ: PacbioOrder, + OrderType.RML: RmlOrder, + OrderType.RNAFUSION: RnaFusionOrder, + OrderType.SARS_COV_2: MutantOrder, + OrderType.TAXPROFILER: TaxprofilerOrder, + OrderType.TOMTE: TomteOrder, +} diff --git a/cg/services/orders/validation/response_mapper.py b/cg/services/orders/validation/response_mapper.py new file mode 100644 index 0000000000..48c6537101 --- /dev/null +++ b/cg/services/orders/validation/response_mapper.py @@ -0,0 +1,103 @@ +from typing import Any + +from cg.services.orders.validation.errors.case_errors import CaseError +from cg.services.orders.validation.errors.case_sample_errors import CaseSampleError +from cg.services.orders.validation.errors.order_errors import OrderError +from cg.services.orders.validation.errors.sample_errors import SampleError +from cg.services.orders.validation.errors.validation_errors import ValidationErrors + + +def create_order_validation_response(raw_order: dict, errors: ValidationErrors) -> dict: + """Ensures each field in the order looks like: {value: raw value, errors: [errors]}""" + wrap_fields(raw_order) + map_errors_to_order(order=raw_order, errors=errors) + return raw_order + + +def map_errors_to_order(order: dict, errors: ValidationErrors) -> None: + map_order_errors(order=order, errors=errors.order_errors) + map_case_errors(order=order, errors=errors.case_errors) + map_case_sample_errors(order=order, errors=errors.case_sample_errors) + map_sample_errors(order=order, errors=errors.sample_errors) + + +def map_order_errors(order: dict, errors: list[OrderError]) -> None: + for error in errors: + add_error(entity=order, field=error.field, message=error.message) + + +def map_case_errors(order: dict, errors: list[CaseError]) -> None: + for error in errors: + case: dict = get_case(order=order, index=error.case_index) + add_error(entity=case, field=error.field, message=error.message) + + +def map_case_sample_errors(order: dict, errors: list[CaseSampleError]) -> None: + for error in errors: + case: dict = get_case(order=order, index=error.case_index) + sample: dict = get_case_sample(case=case, index=error.sample_index) + add_error(entity=sample, field=error.field, message=error.message) + + +def map_sample_errors(order: dict, errors: list[SampleError]) -> None: + for error in errors: + sample: dict = get_sample(order=order, index=error.sample_index) + add_error(entity=sample, field=error.field, message=error.message) + + +def add_error(entity: dict, field: str, message: str) -> None: + if not entity.get(field): + set_field(entity=entity, field=field, value=None) + if field == "sample_errors": + # Special handling for sample errors since the 'value' corresponds to whether it is set + entity[field]["value"] = True + entity[field]["errors"].append(message) + + +def get_case(order: dict, index: int) -> dict: + return order["cases"][index] + + +def get_case_sample(case: dict, index: int) -> dict: + return case["samples"][index] + + +def get_sample(order: dict, index: int) -> dict: + return order["samples"][index] + + +def wrap_fields(raw_order: dict) -> None: + wrap_order_fields(raw_order) + if raw_order.get("cases"): + wrap_case_and_sample_fields(raw_order) + else: + wrap_sample_fields(raw_order["samples"]) + + +def wrap_order_fields(raw_order: dict) -> None: + for field, value in raw_order.items(): + if field not in {"cases", "samples"}: + set_field(entity=raw_order, field=field, value=value) + + +def wrap_case_and_sample_fields(raw_order: dict) -> None: + for case in raw_order["cases"]: + wrap_case_fields(case) + wrap_sample_fields(case["samples"]) + + +def wrap_case_fields(case: dict) -> None: + for field, value in case.items(): + if field != "samples": + set_field(entity=case, field=field, value=value) + set_field(entity=case, field="sample_errors", value=False) + + +def wrap_sample_fields(samples: list[dict]) -> None: + for sample in samples: + for field, value in sample.items(): + set_field(entity=sample, field=field, value=value) + + +def set_field(entity: dict, field: str, value: Any) -> None: + entity[field] = {"value": value, "errors": []} diff --git a/cg/services/orders/validation/rules/__init__.py b/cg/services/orders/validation/rules/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/orders/validation/rules/case/__init__.py b/cg/services/orders/validation/rules/case/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/orders/validation/rules/case/rules.py b/cg/services/orders/validation/rules/case/rules.py new file mode 100644 index 0000000000..897648fd35 --- /dev/null +++ b/cg/services/orders/validation/rules/case/rules.py @@ -0,0 +1,125 @@ +from cg.services.orders.validation.errors.case_errors import ( + CaseDoesNotExistError, + CaseNameNotAvailableError, + CaseOutsideOfCollaborationError, + DoubleNormalError, + DoubleTumourError, + MoreThanTwoSamplesInCaseError, + MultipleSamplesInCaseError, + NumberOfNormalSamplesError, + RepeatedCaseNameError, + RepeatedGenePanelsError, +) +from cg.services.orders.validation.models.case import Case +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.rules.case.utils import ( + contains_duplicates, + is_case_not_from_collaboration, + is_double_normal, + is_double_tumour, +) +from cg.services.orders.validation.rules.case_sample.utils import get_repeated_case_name_errors +from cg.services.orders.validation.workflows.balsamic.models.order import BalsamicOrder +from cg.services.orders.validation.workflows.balsamic_umi.models.order import BalsamicUmiOrder +from cg.store.store import Store + + +def validate_gene_panels_unique(order: OrderWithCases, **kwargs) -> list[RepeatedGenePanelsError]: + errors: list[RepeatedGenePanelsError] = [] + for case_index, case in order.enumerated_new_cases: + if contains_duplicates(case.panels): + error = RepeatedGenePanelsError(case_index=case_index) + errors.append(error) + return errors + + +def validate_case_names_available( + order: OrderWithCases, + store: Store, + **kwargs, +) -> list[CaseNameNotAvailableError]: + errors: list[CaseNameNotAvailableError] = [] + customer = store.get_customer_by_internal_id(order.customer) + for case_index, case in order.enumerated_new_cases: + if store.get_case_by_name_and_customer(case_name=case.name, customer=customer): + error = CaseNameNotAvailableError(case_index=case_index) + errors.append(error) + return errors + + +def validate_case_internal_ids_exist( + order: OrderWithCases, + store: Store, + **kwargs, +) -> list[CaseDoesNotExistError]: + errors: list[CaseDoesNotExistError] = [] + for case_index, case in order.enumerated_existing_cases: + case: Case | None = store.get_case_by_internal_id(case.internal_id) + if not case: + error = CaseDoesNotExistError(case_index=case_index) + errors.append(error) + return errors + + +def validate_existing_cases_belong_to_collaboration( + order: OrderWithCases, + store: Store, + **kwargs, +) -> list[CaseOutsideOfCollaborationError]: + """Validates that all existing cases within the order belong to a customer + within the order's customer's collaboration.""" + errors: list[CaseOutsideOfCollaborationError] = [] + for case_index, case in order.enumerated_existing_cases: + if is_case_not_from_collaboration(case=case, customer_id=order.customer, store=store): + error = CaseOutsideOfCollaborationError(case_index=case_index) + errors.append(error) + return errors + + +def validate_case_names_not_repeated( + order: OrderWithCases, + **kwargs, +) -> list[RepeatedCaseNameError]: + return get_repeated_case_name_errors(order) + + +def validate_one_sample_per_case( + order: OrderWithCases, **kwargs +) -> list[MultipleSamplesInCaseError]: + """Validates that there is only one sample in each case. + Only applicable to RNAFusion.""" + errors: list[MultipleSamplesInCaseError] = [] + for case_index, case in order.enumerated_new_cases: + if len(case.samples) > 1: + error = MultipleSamplesInCaseError(case_index=case_index) + errors.append(error) + return errors + + +def validate_at_most_two_samples_per_case( + order: OrderWithCases, **kwargs +) -> list[MoreThanTwoSamplesInCaseError]: + """Validates that there is at most two samples in each case. + Only applicable to Balsamic and Balsamic-UMI.""" + errors: list[MoreThanTwoSamplesInCaseError] = [] + for case_index, case in order.enumerated_new_cases: + if len(case.samples) > 2: + error = MoreThanTwoSamplesInCaseError(case_index=case_index) + errors.append(error) + return errors + + +def validate_number_of_normal_samples( + order: BalsamicOrder | BalsamicUmiOrder, store: Store, **kwargs +) -> list[NumberOfNormalSamplesError]: + """Validates that Balsamic cases with pairs of samples contain one tumour and one normal sample. + Only applicable to Balsamic and Balsamic-UMI.""" + errors: list[NumberOfNormalSamplesError] = [] + for case_index, case in order.enumerated_new_cases: + if is_double_normal(case=case, store=store): + error = DoubleNormalError(case_index=case_index) + errors.append(error) + elif is_double_tumour(case=case, store=store): + error = DoubleTumourError(case_index=case_index) + errors.append(error) + return errors diff --git a/cg/services/orders/validation/rules/case/utils.py b/cg/services/orders/validation/rules/case/utils.py new file mode 100644 index 0000000000..350082f884 --- /dev/null +++ b/cg/services/orders/validation/rules/case/utils.py @@ -0,0 +1,36 @@ +from cg.services.orders.validation.models.existing_case import ExistingCase +from cg.services.orders.validation.workflows.balsamic.models.case import BalsamicCase +from cg.services.orders.validation.workflows.balsamic_umi.models.case import BalsamicUmiCase +from cg.store.models import Case as DbCase +from cg.store.models import Customer, Sample +from cg.store.store import Store + + +def contains_duplicates(input_list: list) -> bool: + return len(set(input_list)) != len(input_list) + + +def is_double_tumour(case: BalsamicCase | BalsamicUmiCase, store: Store) -> bool: + return len(case.samples) == 2 and get_number_of_tumours(case=case, store=store) == 2 + + +def is_double_normal(case: BalsamicCase | BalsamicUmiCase, store: Store) -> bool: + return len(case.samples) == 2 and get_number_of_tumours(case=case, store=store) == 0 + + +def get_number_of_tumours(case: BalsamicCase | BalsamicUmiCase, store: Store) -> int: + number_of_tumours = 0 + for sample in case.samples: + if sample.is_new and sample.tumour: + number_of_tumours += 1 + elif not sample.is_new: + db_sample: Sample = store.get_sample_by_internal_id(sample.internal_id) + if db_sample.is_tumour: + number_of_tumours += 1 + return number_of_tumours + + +def is_case_not_from_collaboration(case: ExistingCase, customer_id: str, store: Store) -> bool: + db_case: DbCase | None = store.get_case_by_internal_id(case.internal_id) + customer: Customer | None = store.get_customer_by_internal_id(customer_id) + return db_case and customer and db_case.customer not in customer.collaborators diff --git a/cg/services/orders/validation/rules/case_sample/__init__.py b/cg/services/orders/validation/rules/case_sample/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/orders/validation/rules/case_sample/pedigree/__init__.py b/cg/services/orders/validation/rules/case_sample/pedigree/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/orders/validation/rules/case_sample/pedigree/models.py b/cg/services/orders/validation/rules/case_sample/pedigree/models.py new file mode 100644 index 0000000000..e4a3129e62 --- /dev/null +++ b/cg/services/orders/validation/rules/case_sample/pedigree/models.py @@ -0,0 +1,79 @@ +from cg.services.orders.validation.models.case_aliases import CaseContainingRelatives +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.workflows.mip_dna.models.sample import MipDnaSample +from cg.services.orders.validation.workflows.tomte.models.sample import TomteSample +from cg.store.store import Store + +SampleWithParents = TomteSample | MipDnaSample | ExistingSample + + +class Node: + """ + This class is used to represent the samples in the family tree graph. The variables 'mother' and + 'father' refer to other nodes in the family tree, and can be thought of as an edge in the graph. + Because the 'mother' and 'father' are tracked using the sample's _name_ in the order, and + because said name is not set in the ExistingSample model, we require the sample name as a + separate input. + """ + + def __init__( + self, + sample: SampleWithParents, + case_index: int, + sample_index: int, + sample_name: str, + ): + self.sample: SampleWithParents = sample + self.sample_name: str = sample_name + self.sample_index: int = sample_index + self.case_index: int = case_index + self.father: Node | None = None + self.mother: Node | None = None + self.visited = False + self.in_current_path = False + + +class FamilyTree: + """ + This class is a directed graph representing a family tree from a submitted order with specified + mothers and fathers. Each node represents a sample, and each node has a property 'mother' and + a property 'father' referring to other nodes in the graph. These may be thought of as the + graph's edges. + """ + + def __init__(self, case: CaseContainingRelatives, case_index: int, store: Store): + self.graph: dict[str, Node] = {} + self.case: CaseContainingRelatives = case + self.case_index: int = case_index + self.store = store + self._add_nodes() + self._add_edges() + + def _add_nodes(self) -> None: + """Add a node to the graph for each sample in the graph. For existing samples, the name + is fetched from StatusDB.""" + for sample_index, sample in self.case.enumerated_samples: + if sample.is_new: + sample_name = sample.name + else: + sample_name = self.store.get_sample_by_internal_id(sample.internal_id).name + node = Node( + sample=sample, + sample_index=sample_index, + case_index=self.case_index, + sample_name=sample_name, + ) + self.graph[sample_name] = node + + def _add_edges(self) -> None: + """Add edges to the graph by populating each node's 'mother' and 'father' property.""" + for node in self.graph.values(): + sample: SampleWithParents = node.sample + if sample.mother: + node.mother = self.graph.get(sample.mother) + if sample.father: + node.father = self.graph.get(sample.father) + + @property + def nodes(self) -> list[Node]: + return list(self.graph.values()) diff --git a/cg/services/orders/validation/rules/case_sample/pedigree/utils.py b/cg/services/orders/validation/rules/case_sample/pedigree/utils.py new file mode 100644 index 0000000000..51b42a7c97 --- /dev/null +++ b/cg/services/orders/validation/rules/case_sample/pedigree/utils.py @@ -0,0 +1,64 @@ +from cg.constants.pedigree import Pedigree +from cg.services.orders.validation.errors.case_sample_errors import ( + DescendantAsFatherError, + DescendantAsMotherError, + PedigreeError, + SampleIsOwnFatherError, + SampleIsOwnMotherError, +) +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.rules.case_sample.pedigree.models import FamilyTree, Node +from cg.services.orders.validation.workflows.mip_dna.models.sample import MipDnaSample +from cg.services.orders.validation.workflows.tomte.models.sample import TomteSample + + +def validate_tree(pedigree: FamilyTree) -> list[PedigreeError]: + """This performs a DFS algorithm on the family tree to find any cycles, which indicates an + order error.""" + errors: list[PedigreeError] = [] + for node in pedigree.nodes: + if not node.visited: + detect_cycles(node=node, errors=errors) + return errors + + +def detect_cycles(node: Node, errors: list[PedigreeError]) -> None: + """Detect cycles in the pedigree graph using depth-first search. If a cycle is detected, + this is considered an error.""" + node.visited = True + node.in_current_path = True + + parents: dict[str, Node] = {Pedigree.MOTHER: node.mother, Pedigree.FATHER: node.father} + + for parent_type, parent in parents.items(): + if parent and parent.in_current_path: + error: PedigreeError = get_error(node=node, parent_type=parent_type) + errors.append(error) + elif parent and not parent.visited: + detect_cycles(node=parent, errors=errors) + node.in_current_path = False + + +def get_error(node: Node, parent_type: str) -> PedigreeError: + if parent_type == Pedigree.MOTHER: + return get_mother_error(node) + if parent_type == Pedigree.FATHER: + return get_father_error(node) + + +def get_mother_error(node: Node) -> PedigreeError: + """Called when the node's 'mother' creates a cycle in the family tree. For clearer feedback + we distinguish between the sample being its own mother, and other more complex situations.""" + sample: TomteSample | MipDnaSample | ExistingSample = node.sample + if node.sample_name == sample.mother: + return SampleIsOwnMotherError(sample_index=node.sample_index, case_index=node.case_index) + return DescendantAsMotherError(sample_index=node.sample_index, case_index=node.case_index) + + +def get_father_error(node: Node) -> PedigreeError: + """Called when the node's 'father' creates a cycle in the family tree. For clearer feedback + we distinguish between the sample being its own father, and other more complex situations.""" + sample: TomteSample = node.sample + if node.sample_name == sample.father: + return SampleIsOwnFatherError(sample_index=node.sample_index, case_index=node.case_index) + return DescendantAsFatherError(sample_index=node.sample_index, case_index=node.case_index) diff --git a/cg/services/orders/validation/rules/case_sample/pedigree/validate_pedigree.py b/cg/services/orders/validation/rules/case_sample/pedigree/validate_pedigree.py new file mode 100644 index 0000000000..6918d1bc41 --- /dev/null +++ b/cg/services/orders/validation/rules/case_sample/pedigree/validate_pedigree.py @@ -0,0 +1,14 @@ +from cg.services.orders.validation.errors.case_sample_errors import PedigreeError +from cg.services.orders.validation.rules.case_sample.pedigree.models import FamilyTree +from cg.services.orders.validation.rules.case_sample.pedigree.utils import validate_tree +from cg.services.orders.validation.workflows.mip_dna.models.case import MipDnaCase +from cg.services.orders.validation.workflows.tomte.models.case import TomteCase +from cg.store.store import Store + + +def get_pedigree_errors( + case: TomteCase | MipDnaCase, case_index: int, store: Store +) -> list[PedigreeError]: + """Return a list of errors if any sample is labelled as its own ancestor in the family tree.""" + pedigree = FamilyTree(case=case, case_index=case_index, store=store) + return validate_tree(pedigree) diff --git a/cg/services/orders/validation/rules/case_sample/rules.py b/cg/services/orders/validation/rules/case_sample/rules.py new file mode 100644 index 0000000000..e0c7a79b06 --- /dev/null +++ b/cg/services/orders/validation/rules/case_sample/rules.py @@ -0,0 +1,482 @@ +from collections import Counter + +from cg.models.orders.constants import OrderType +from cg.services.orders.validation.constants import ALLOWED_SKIP_RC_BUFFERS +from cg.services.orders.validation.errors.case_errors import InvalidGenePanelsError +from cg.services.orders.validation.errors.case_sample_errors import ( + ApplicationArchivedError, + ApplicationNotCompatibleError, + ApplicationNotValidError, + BufferMissingError, + CaptureKitMissingError, + ConcentrationRequiredIfSkipRCError, + ContainerNameMissingError, + ContainerNameRepeatedError, + FatherNotInCaseError, + InvalidBufferError, + InvalidConcentrationIfSkipRCError, + InvalidFatherSexError, + InvalidMotherSexError, + InvalidVolumeError, + MotherNotInCaseError, + OccupiedWellError, + PedigreeError, + SampleDoesNotExistError, + SampleNameRepeatedError, + SampleNameSameAsCaseNameError, + SampleOutsideOfCollaborationError, + SexSubjectIdError, + StatusUnknownError, + SubjectIdSameAsCaseNameError, + SubjectIdSameAsSampleNameError, + VolumeRequiredError, + WellFormatError, + WellPositionMissingError, +) +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.models.sample_aliases import SampleInCase +from cg.services.orders.validation.rules.case_sample.pedigree.validate_pedigree import ( + get_pedigree_errors, +) +from cg.services.orders.validation.rules.case_sample.utils import ( + are_all_samples_unknown, + get_counter_container_names, + get_existing_case_names, + get_existing_sample_names, + get_father_case_errors, + get_father_sex_errors, + get_invalid_panels, + get_mother_case_errors, + get_mother_sex_errors, + get_occupied_well_errors, + get_well_sample_map, + has_sex_and_subject, + is_buffer_missing, + is_concentration_missing, + is_container_name_missing, + is_invalid_plate_well_format, + is_sample_missing_capture_kit, + is_sample_not_from_collaboration, + is_sample_tube_name_reused, + is_well_position_missing, + validate_concentration_in_case, + validate_subject_ids_in_case, +) +from cg.services.orders.validation.rules.utils import ( + is_application_compatible, + is_volume_invalid, + is_volume_missing, +) +from cg.services.orders.validation.workflows.balsamic.models.order import BalsamicOrder +from cg.services.orders.validation.workflows.balsamic_umi.models.order import BalsamicUmiOrder +from cg.store.models import Sample as DbSample +from cg.store.store import Store + + +def validate_application_compatibility( + order: OrderWithCases, + store: Store, + **kwargs, +) -> list[ApplicationNotCompatibleError]: + errors: list[ApplicationNotCompatibleError] = [] + order_type: OrderType = order.order_type + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if not is_application_compatible( + order_type=order_type, + application_tag=sample.application, + store=store, + ): + error = ApplicationNotCompatibleError( + case_index=case_index, + sample_index=sample_index, + ) + errors.append(error) + return errors + + +def validate_buffer_skip_rc_condition(order: OrderWithCases, **kwargs) -> list[InvalidBufferError]: + errors: list[InvalidBufferError] = [] + if order.skip_reception_control: + errors.extend(validate_buffers_are_allowed(order)) + return errors + + +def validate_buffers_are_allowed(order: OrderWithCases, **kwargs) -> list[InvalidBufferError]: + errors: list[InvalidBufferError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if sample.elution_buffer not in ALLOWED_SKIP_RC_BUFFERS: + error = InvalidBufferError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_concentration_required_if_skip_rc( + order: OrderWithCases, **kwargs +) -> list[ConcentrationRequiredIfSkipRCError]: + if not order.skip_reception_control: + return [] + errors: list[ConcentrationRequiredIfSkipRCError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if is_concentration_missing(sample): + error = ConcentrationRequiredIfSkipRCError( + case_index=case_index, + sample_index=sample_index, + ) + errors.append(error) + return errors + + +def validate_subject_ids_different_from_sample_names( + order: OrderWithCases, **kwargs +) -> list[SubjectIdSameAsSampleNameError]: + errors: list[SubjectIdSameAsSampleNameError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if sample.name == sample.subject_id: + error = SubjectIdSameAsSampleNameError( + case_index=case_index, + sample_index=sample_index, + ) + errors.append(error) + return errors + + +def validate_well_positions_required( + order: OrderWithCases, **kwargs +) -> list[WellPositionMissingError]: + errors: list[WellPositionMissingError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if is_well_position_missing(sample): + error = WellPositionMissingError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_container_name_required( + order: OrderWithCases, **kwargs +) -> list[ContainerNameMissingError]: + errors: list[ContainerNameMissingError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if is_container_name_missing(sample): + error = ContainerNameMissingError( + case_index=case_index, + sample_index=sample_index, + ) + errors.append(error) + return errors + + +def validate_application_exists( + order: OrderWithCases, + store: Store, + **kwargs, +) -> list[ApplicationNotValidError]: + errors: list[ApplicationNotValidError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if not store.get_application_by_tag(sample.application): + error = ApplicationNotValidError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_application_not_archived( + order: OrderWithCases, + store: Store, + **kwargs, +) -> list[ApplicationArchivedError]: + errors: list[ApplicationArchivedError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if store.is_application_archived(sample.application): + error = ApplicationArchivedError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_gene_panels_exist( + order: OrderWithCases, + store: Store, + **kwargs, +) -> list[InvalidGenePanelsError]: + errors: list[InvalidGenePanelsError] = [] + for case_index, case in order.enumerated_new_cases: + if invalid_panels := get_invalid_panels(panels=case.panels, store=store): + case_error = InvalidGenePanelsError(case_index=case_index, panels=invalid_panels) + errors.append(case_error) + return errors + + +def validate_volume_interval(order: OrderWithCases, **kwargs) -> list[InvalidVolumeError]: + errors: list[InvalidVolumeError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if is_volume_invalid(sample): + error = InvalidVolumeError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_volume_required(order: OrderWithCases, **kwargs) -> list[VolumeRequiredError]: + errors: list[VolumeRequiredError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if is_volume_missing(sample): + error = VolumeRequiredError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_samples_exist( + order: OrderWithCases, + store: Store, + **kwargs, +) -> list[SampleDoesNotExistError]: + errors: list[SampleDoesNotExistError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_existing_samples: + sample: DbSample | None = store.get_sample_by_internal_id(sample.internal_id) + if not sample: + error = SampleDoesNotExistError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_wells_contain_at_most_one_sample( + order: OrderWithCases, **kwargs +) -> list[OccupiedWellError]: + errors: list[OccupiedWellError] = [] + well_position_to_sample_map: dict[tuple[str, str], list[tuple[int, int]]] = get_well_sample_map( + order + ) + for indices in well_position_to_sample_map.values(): + if len(indices) > 1: + well_errors = get_occupied_well_errors(indices[1:]) + errors.extend(well_errors) + return errors + + +def validate_sample_names_not_repeated( + order: OrderWithCases, store: Store, **kwargs +) -> list[SampleNameRepeatedError]: + old_sample_names: set[str] = get_existing_sample_names(order=order, status_db=store) + new_samples: list[tuple[int, int, SampleInCase]] = order.enumerated_new_samples + sample_name_counter = Counter([sample.name for _, _, sample in new_samples]) + return [ + SampleNameRepeatedError(case_index=case_index, sample_index=sample_index) + for case_index, sample_index, sample in new_samples + if sample_name_counter.get(sample.name) > 1 or sample.name in old_sample_names + ] + + +def validate_sample_names_different_from_case_names( + order: OrderWithCases, store: Store, **kwargs +) -> list[SampleNameSameAsCaseNameError]: + """Return errors with the indexes of samples having the same name as any case in the order.""" + errors: list[SampleNameSameAsCaseNameError] = [] + new_case_names: set[str] = {case.name for _, case in order.enumerated_new_cases} + existing_case_names: set[str] = get_existing_case_names(order=order, status_db=store) + all_case_names = new_case_names.union(existing_case_names) + for case_index, sample_index, sample in order.enumerated_new_samples: + if sample.name in all_case_names: + error = SampleNameSameAsCaseNameError( + case_index=case_index, + sample_index=sample_index, + ) + errors.append(error) + return errors + + +def validate_fathers_are_male(order: OrderWithCases, **kwargs) -> list[InvalidFatherSexError]: + errors: list[InvalidFatherSexError] = [] + for index, case in order.enumerated_new_cases: + case_errors: list[InvalidFatherSexError] = get_father_sex_errors( + case=case, case_index=index + ) + errors.extend(case_errors) + return errors + + +def validate_fathers_in_same_case_as_children( + order: OrderWithCases, **kwargs +) -> list[FatherNotInCaseError]: + errors: list[FatherNotInCaseError] = [] + for index, case in order.enumerated_new_cases: + case_errors: list[FatherNotInCaseError] = get_father_case_errors( + case=case, + case_index=index, + ) + errors.extend(case_errors) + return errors + + +def validate_mothers_are_female(order: OrderWithCases, **kwargs) -> list[InvalidMotherSexError]: + errors: list[InvalidMotherSexError] = [] + for index, case in order.enumerated_new_cases: + case_errors: list[InvalidMotherSexError] = get_mother_sex_errors( + case=case, + case_index=index, + ) + errors.extend(case_errors) + return errors + + +def validate_mothers_in_same_case_as_children( + order: OrderWithCases, **kwargs +) -> list[MotherNotInCaseError]: + errors: list[MotherNotInCaseError] = [] + for index, case in order.enumerated_new_cases: + case_errors: list[MotherNotInCaseError] = get_mother_case_errors( + case=case, + case_index=index, + ) + errors.extend(case_errors) + return errors + + +def validate_pedigree(order: OrderWithCases, store: Store, **kwargs) -> list[PedigreeError]: + errors: list[PedigreeError] = [] + for case_index, case in order.enumerated_new_cases: + case_errors: list[PedigreeError] = get_pedigree_errors( + case=case, case_index=case_index, store=store + ) + errors.extend(case_errors) + return errors + + +def validate_subject_sex_consistency( + order: OrderWithCases, + store: Store, +) -> list[SexSubjectIdError]: + errors: list[SexSubjectIdError] = [] + + for case_index, sample_index, sample in order.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 + + +def validate_subject_ids_different_from_case_names( + order: OrderWithCases, **kwargs +) -> list[SubjectIdSameAsCaseNameError]: + errors: list[SubjectIdSameAsCaseNameError] = [] + for index, case in order.enumerated_new_cases: + case_errors: list[SubjectIdSameAsCaseNameError] = validate_subject_ids_in_case( + case=case, + case_index=index, + ) + errors.extend(case_errors) + return errors + + +def validate_concentration_interval_if_skip_rc( + order: OrderWithCases, store: Store, **kwargs +) -> list[InvalidConcentrationIfSkipRCError]: + if not order.skip_reception_control: + return [] + errors: list[InvalidConcentrationIfSkipRCError] = [] + for index, case in order.enumerated_new_cases: + case_errors: list[InvalidConcentrationIfSkipRCError] = validate_concentration_in_case( + case=case, + case_index=index, + store=store, + ) + errors.extend(case_errors) + return errors + + +def validate_well_position_format(order: OrderWithCases, **kwargs) -> list[WellFormatError]: + errors: list[WellFormatError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if is_invalid_plate_well_format(sample=sample): + error = WellFormatError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_tube_container_name_unique( + order: OrderWithCases, **kwargs +) -> list[ContainerNameRepeatedError]: + errors: list[ContainerNameRepeatedError] = [] + + container_name_counter: Counter = get_counter_container_names(order) + + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if is_sample_tube_name_reused(sample=sample, counter=container_name_counter): + error = ContainerNameRepeatedError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_not_all_samples_unknown_in_case( + order: OrderWithCases, **kwargs +) -> list[StatusUnknownError]: + errors: list[StatusUnknownError] = [] + + for case_index, case in order.enumerated_new_cases: + if are_all_samples_unknown(case): + for sample_index, _ in case.enumerated_samples: + error = StatusUnknownError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_buffer_required(order: OrderWithCases, **kwargs) -> list[BufferMissingError]: + """Return an error for each new sample missing a buffer, if its application requires one.""" + + errors: list[BufferMissingError] = [] + for case_index, sample_index, sample in order.enumerated_new_samples: + if is_buffer_missing(sample): + error = BufferMissingError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_capture_kit_panel_requirement( + order: BalsamicOrder | BalsamicUmiOrder, store: Store +) -> list[CaptureKitMissingError]: + """ + Return an error for each new sample missing a capture kit, if its application requires one. + Applicable to Balsamic and Balsamic-UMI orders only. + """ + errors: list[CaptureKitMissingError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if is_sample_missing_capture_kit(sample=sample, store=store): + error = CaptureKitMissingError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_existing_samples_belong_to_collaboration( + order: OrderWithCases, store: Store, **kwargs +) -> list[SampleOutsideOfCollaborationError]: + """Validates that existing samples belong to the same collaboration as the order's customer.""" + errors: list[SampleOutsideOfCollaborationError] = [] + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_existing_samples: + if is_sample_not_from_collaboration( + customer_id=order.customer, sample=sample, store=store + ): + error = SampleOutsideOfCollaborationError( + sample_index=sample_index, case_index=case_index + ) + errors.append(error) + return errors diff --git a/cg/services/orders/validation/rules/case_sample/utils.py b/cg/services/orders/validation/rules/case_sample/utils.py new file mode 100644 index 0000000000..9db37b3c9e --- /dev/null +++ b/cg/services/orders/validation/rules/case_sample/utils.py @@ -0,0 +1,317 @@ +import re +from collections import Counter + +from cg.constants.constants import StatusOptions +from cg.constants.sequencing import SeqLibraryPrepCategory +from cg.constants.subject import Sex +from cg.models.orders.sample_base import ContainerEnum, SexEnum +from cg.services.orders.validation.errors.case_errors import RepeatedCaseNameError +from cg.services.orders.validation.errors.case_sample_errors import ( + FatherNotInCaseError, + InvalidConcentrationIfSkipRCError, + InvalidFatherSexError, + InvalidMotherSexError, + MotherNotInCaseError, + OccupiedWellError, + SubjectIdSameAsCaseNameError, +) +from cg.services.orders.validation.models.case import Case +from cg.services.orders.validation.models.case_aliases import ( + CaseContainingRelatives, + CaseWithSkipRC, +) +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.models.sample_aliases import ( + HumanSample, + SampleInCase, + SampleWithRelatives, +) +from cg.services.orders.validation.rules.utils import ( + get_concentration_interval, + has_sample_invalid_concentration, + is_in_container, + is_sample_on_plate, + is_volume_within_allowed_interval, +) +from cg.services.orders.validation.workflows.balsamic.models.sample import BalsamicSample +from cg.services.orders.validation.workflows.balsamic_umi.models.sample import BalsamicUmiSample +from cg.store.models import Application, Customer +from cg.store.models import Sample as DbSample +from cg.store.store import Store + + +def is_concentration_missing(sample: SampleWithRelatives) -> bool: + return not sample.concentration_ng_ul + + +def is_well_position_missing(sample: SampleWithRelatives) -> bool: + return sample.container == ContainerEnum.plate and not sample.well_position + + +def is_container_name_missing(sample: SampleWithRelatives) -> bool: + return sample.container == ContainerEnum.plate and not sample.container_name + + +def get_invalid_panels(panels: list[str], store: Store) -> list[str]: + invalid_panels: list[str] = [ + panel for panel in panels if not store.does_gene_panel_exist(panel) + ] + return invalid_panels + + +def is_volume_invalid(sample: Sample) -> bool: + in_container: bool = is_in_container(sample.container) + allowed_volume: bool = is_volume_within_allowed_interval(sample.volume) + return in_container and not allowed_volume + + +def get_well_sample_map( + order: OrderWithCases, **kwargs +) -> dict[tuple[str, str], list[tuple[int, int]]]: + """ + Constructs a dict with keys being a (container_name, well_position) pair. For each such pair, the value will be + a list of (case index, sample index) pairs corresponding to all samples with matching container_name and + well_position, provided the sample is on a plate. + """ + well_position_to_sample_map = {} + for case_index, case in order.enumerated_new_cases: + for sample_index, sample in case.enumerated_new_samples: + if is_sample_on_plate(sample): + key: tuple[str, str] = (sample.container_name, sample.well_position) + value: tuple[int, int] = (case_index, sample_index) + if not well_position_to_sample_map.get(key): + well_position_to_sample_map[key] = [] + well_position_to_sample_map[key].append(value) + return well_position_to_sample_map + + +def get_occupied_well_errors(colliding_samples: list[tuple[int, int]]) -> list[OccupiedWellError]: + errors: list[OccupiedWellError] = [] + for case_index, sample_index in colliding_samples: + error = OccupiedWellError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def get_indices_for_repeated_case_names(order: OrderWithCases) -> list[int]: + counter = Counter([case.name for _, case in order.enumerated_new_cases]) + indices: list[int] = [] + + for index, case in order.enumerated_new_cases: + if counter.get(case.name) > 1: + indices.append(index) + + return indices + + +def get_repeated_case_name_errors(order: OrderWithCases) -> list[RepeatedCaseNameError]: + case_indices: list[int] = get_indices_for_repeated_case_names(order) + return [RepeatedCaseNameError(case_index=case_index) for case_index in case_indices] + + +def get_father_sex_errors( + case: CaseContainingRelatives, case_index: int +) -> list[InvalidFatherSexError]: + errors: list[InvalidFatherSexError] = [] + children: list[tuple[SampleWithRelatives, int]] = case.get_samples_with_father() + for child, child_index in children: + if is_father_sex_invalid(child=child, case=case): + error: InvalidFatherSexError = create_father_sex_error( + case_index=case_index, sample_index=child_index + ) + errors.append(error) + return errors + + +def is_father_sex_invalid(child: SampleWithRelatives, case: CaseContainingRelatives) -> bool: + father: SampleWithRelatives | None = case.get_sample(child.father) + return father and father.sex != Sex.MALE + + +def create_father_sex_error(case_index: int, sample_index: int) -> InvalidFatherSexError: + return InvalidFatherSexError(case_index=case_index, sample_index=sample_index) + + +def get_father_case_errors( + case: CaseContainingRelatives, + case_index: int, +) -> list[FatherNotInCaseError]: + errors: list[FatherNotInCaseError] = [] + children: list[tuple[SampleWithRelatives, int]] = case.get_samples_with_father() + for child, child_index in children: + father: SampleWithRelatives | None = case.get_sample(child.father) + if not father: + error: FatherNotInCaseError = create_father_case_error( + case_index=case_index, + sample_index=child_index, + ) + errors.append(error) + return errors + + +def get_mother_sex_errors( + case: CaseContainingRelatives, + case_index: int, +) -> list[InvalidMotherSexError]: + errors: list[InvalidMotherSexError] = [] + children: list[tuple[SampleWithRelatives, int]] = case.get_samples_with_mother() + for child, child_index in children: + if is_mother_sex_invalid(child=child, case=case): + error: InvalidMotherSexError = create_mother_sex_error( + case_index=case_index, + sample_index=child_index, + ) + errors.append(error) + return errors + + +def get_mother_case_errors( + case: CaseContainingRelatives, + case_index: int, +) -> list[MotherNotInCaseError]: + errors: list[MotherNotInCaseError] = [] + children: list[tuple[SampleWithRelatives, int]] = case.get_samples_with_mother() + for child, child_index in children: + mother: SampleWithRelatives | None = case.get_sample(child.mother) + if not mother: + error: MotherNotInCaseError = create_mother_case_error( + case_index=case_index, sample_index=child_index + ) + errors.append(error) + return errors + + +def create_father_case_error(case_index: int, sample_index: int) -> FatherNotInCaseError: + return FatherNotInCaseError(case_index=case_index, sample_index=sample_index) + + +def create_mother_case_error(case_index: int, sample_index: int) -> MotherNotInCaseError: + return MotherNotInCaseError(case_index=case_index, sample_index=sample_index) + + +def is_mother_sex_invalid(child: SampleWithRelatives, case: CaseContainingRelatives) -> bool: + mother: SampleWithRelatives | None = case.get_sample(child.mother) + return mother and mother.sex != Sex.FEMALE + + +def create_mother_sex_error(case_index: int, sample_index: int) -> InvalidMotherSexError: + return InvalidMotherSexError(case_index=case_index, sample_index=sample_index) + + +def has_sex_and_subject(sample: HumanSample) -> bool: + return bool(sample.subject_id and sample.sex != SexEnum.unknown) + + +def validate_subject_ids_in_case( + case: CaseContainingRelatives, case_index: int +) -> list[SubjectIdSameAsCaseNameError]: + errors: list[SubjectIdSameAsCaseNameError] = [] + for sample_index, sample in case.enumerated_new_samples: + if sample.subject_id == case.name: + error = SubjectIdSameAsCaseNameError(case_index=case_index, sample_index=sample_index) + errors.append(error) + return errors + + +def validate_concentration_in_case( + case: CaseWithSkipRC, case_index: int, store: Store +) -> list[InvalidConcentrationIfSkipRCError]: + errors: list[InvalidConcentrationIfSkipRCError] = [] + for sample_index, sample in case.enumerated_new_samples: + if application := store.get_application_by_tag(sample.application): + allowed_interval = get_concentration_interval(sample=sample, application=application) + if has_sample_invalid_concentration(sample=sample, allowed_interval=allowed_interval): + error: InvalidConcentrationIfSkipRCError = create_invalid_concentration_error( + case_index=case_index, + sample_index=sample_index, + allowed_interval=allowed_interval, + ) + errors.append(error) + return errors + + +def create_invalid_concentration_error( + case_index: int, sample_index: int, allowed_interval: tuple[float, float] +) -> InvalidConcentrationIfSkipRCError: + return InvalidConcentrationIfSkipRCError( + case_index=case_index, + sample_index=sample_index, + allowed_interval=allowed_interval, + ) + + +def is_invalid_plate_well_format(sample: Sample) -> bool: + """Check if a sample has an invalid well format.""" + correct_well_position_pattern: str = r"^[A-H]:([1-9]|1[0-2])$" + if sample.is_on_plate: + return not bool(re.match(correct_well_position_pattern, sample.well_position)) + return False + + +def is_sample_tube_name_reused(sample: Sample, counter: Counter) -> bool: + """Check if a tube container name is reused across samples.""" + return sample.container == ContainerEnum.tube and counter.get(sample.container_name) > 1 + + +def get_counter_container_names(order: OrderWithCases) -> Counter: + counter = Counter( + sample.container_name + for case_index, case in order.enumerated_new_cases + for sample_index, sample in case.enumerated_new_samples + ) + return counter + + +def get_existing_sample_names(order: OrderWithCases, status_db: Store) -> set[str]: + existing_sample_names: set[str] = set() + for case in order.cases: + if case.is_new: + for sample_index, sample in case.enumerated_existing_samples: + db_sample = status_db.get_sample_by_internal_id(sample.internal_id) + existing_sample_names.add(db_sample.name) + else: + db_case = status_db.get_case_by_internal_id(case.internal_id) + for sample in db_case.samples: + existing_sample_names.add(sample.name) + return existing_sample_names + + +def are_all_samples_unknown(case: Case) -> bool: + """Check if all samples in a case are unknown.""" + return all(sample.status == StatusOptions.UNKNOWN for sample in case.samples) + + +def is_buffer_missing(sample: SampleInCase) -> bool: + applications_requiring_buffer: tuple = ("PAN", "EX", "WGSWPF", "METWPF") + return bool( + sample.application.startswith(tuple(applications_requiring_buffer)) + and not sample.elution_buffer + ) + + +def is_sample_missing_capture_kit(sample: BalsamicSample | BalsamicUmiSample, store: Store) -> bool: + """Returns whether a TGS sample has an application and is missing a capture kit.""" + application: Application | None = store.get_application_by_tag(sample.application) + return ( + application + and application.prep_category == SeqLibraryPrepCategory.TARGETED_GENOME_SEQUENCING + and not sample.capture_kit + ) + + +def is_sample_not_from_collaboration( + customer_id: str, sample: ExistingSample, store: Store +) -> bool: + db_sample: DbSample | None = store.get_sample_by_internal_id(sample.internal_id) + customer: Customer | None = store.get_customer_by_internal_id(customer_id) + return db_sample and customer and db_sample.customer not in customer.collaborators + + +def get_existing_case_names(order: OrderWithCases, status_db: Store) -> set[str]: + existing_case_names: set[str] = set() + for _, case in order.enumerated_existing_cases: + if db_case := status_db.get_case_by_internal_id(case.internal_id): + existing_case_names.add(db_case.name) + return existing_case_names diff --git a/cg/services/orders/validation/rules/order/__init__.py b/cg/services/orders/validation/rules/order/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/orders/validation/rules/order/rules.py b/cg/services/orders/validation/rules/order/rules.py new file mode 100644 index 0000000000..e7bd52b3f3 --- /dev/null +++ b/cg/services/orders/validation/rules/order/rules.py @@ -0,0 +1,47 @@ +from cg.services.orders.validation.errors.order_errors import ( + CustomerCannotSkipReceptionControlError, + CustomerDoesNotExistError, + UserNotAssociatedWithCustomerError, +) +from cg.services.orders.validation.models.order import Order +from cg.store.store import Store + + +def validate_customer_exists( + order: Order, + store: Store, + **kwargs, +) -> list[CustomerDoesNotExistError]: + errors: list[CustomerDoesNotExistError] = [] + if not store.customer_exists(order.customer): + error = CustomerDoesNotExistError() + errors.append(error) + return errors + + +def validate_user_belongs_to_customer( + order: Order, store: Store, **kwargs +) -> list[UserNotAssociatedWithCustomerError]: + has_access: bool = store.is_user_associated_with_customer( + user_id=order._user_id, + customer_internal_id=order.customer, + ) + + errors: list[UserNotAssociatedWithCustomerError] = [] + if not has_access: + error = UserNotAssociatedWithCustomerError() + errors.append(error) + return errors + + +def validate_customer_can_skip_reception_control( + order: Order, + store: Store, + **kwargs, +) -> list[CustomerCannotSkipReceptionControlError]: + errors: list[CustomerCannotSkipReceptionControlError] = [] + + if order.skip_reception_control and not store.is_customer_trusted(order.customer): + error = CustomerCannotSkipReceptionControlError() + errors.append(error) + return errors diff --git a/cg/services/orders/validation/rules/sample/__init__.py b/cg/services/orders/validation/rules/sample/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/orders/validation/rules/sample/rules.py b/cg/services/orders/validation/rules/sample/rules.py new file mode 100644 index 0000000000..bf6a58f255 --- /dev/null +++ b/cg/services/orders/validation/rules/sample/rules.py @@ -0,0 +1,389 @@ +from cg.models.orders.constants import OrderType +from cg.services.orders.validation.errors.sample_errors import ( + ApplicationArchivedError, + ApplicationNotCompatibleError, + ApplicationNotValidError, + BufferInvalidError, + ConcentrationInvalidIfSkipRCError, + ConcentrationRequiredError, + ContainerNameMissingError, + ContainerNameRepeatedError, + IndexNumberMissingError, + IndexNumberOutOfRangeError, + IndexSequenceMismatchError, + IndexSequenceMissingError, + InvalidVolumeError, + OccupiedWellError, + PoolApplicationError, + PoolPriorityError, + SampleNameNotAvailableControlError, + SampleNameNotAvailableError, + SampleNameRepeatedError, + VolumeRequiredError, + WellFormatError, + WellFormatRmlError, + WellPositionMissingError, + WellPositionRmlMissingError, +) +from cg.services.orders.validation.models.order_aliases import ( + OrderWithControlSamples, + OrderWithIndexedSamples, +) +from cg.services.orders.validation.models.sample_aliases import IndexedSample +from cg.services.orders.validation.rules.sample.utils import ( + PlateSamplesValidator, + get_indices_for_repeated_sample_names, + get_indices_for_tube_repeated_container_name, + get_sample_name_not_available_errors, + has_multiple_applications, + has_multiple_priorities, + is_container_name_missing, + is_index_number_missing, + is_index_number_out_of_range, + is_index_sequence_mismatched, + is_index_sequence_missing, + is_invalid_well_format, + is_invalid_well_format_rml, + validate_buffers_are_allowed, + validate_concentration_interval, + validate_concentration_required, +) +from cg.services.orders.validation.rules.utils import ( + is_application_compatible, + is_volume_invalid, + is_volume_missing, +) +from cg.services.orders.validation.workflows.fastq.models.order import FastqOrder +from cg.services.orders.validation.workflows.microsalt.models.order import OrderWithSamples +from cg.store.store import Store + + +def validate_application_compatibility( + order: OrderWithSamples, + store: Store, + **kwargs, +) -> list[ApplicationNotCompatibleError]: + """ + Validate that the applications of all samples in the order are compatible with the order type. + Applicable to all order types. + """ + errors: list[ApplicationNotCompatibleError] = [] + order_type: OrderType = order.order_type + for sample_index, sample in order.enumerated_samples: + compatible: bool = is_application_compatible( + order_type=order_type, + application_tag=sample.application, + store=store, + ) + if not compatible: + error = ApplicationNotCompatibleError(sample_index=sample_index) + errors.append(error) + return errors + + +def validate_application_exists( + order: OrderWithSamples, store: Store, **kwargs +) -> list[ApplicationNotValidError]: + """ + Validate that the applications of all samples in the order exist in the database. + Applicable to all order types. + """ + errors: list[ApplicationNotValidError] = [] + for sample_index, sample in order.enumerated_samples: + if not store.get_application_by_tag(sample.application): + error = ApplicationNotValidError(sample_index=sample_index) + errors.append(error) + return errors + + +def validate_applications_not_archived( + order: OrderWithSamples, store: Store, **kwargs +) -> list[ApplicationArchivedError]: + """ + Validate that none of the applications of the samples in the order are archived. + Applicable to all order types. + """ + errors: list[ApplicationArchivedError] = [] + for sample_index, sample in order.enumerated_samples: + if store.is_application_archived(sample.application): + error = ApplicationArchivedError(sample_index=sample_index) + errors.append(error) + return errors + + +def validate_buffer_skip_rc_condition(order: FastqOrder, **kwargs) -> list[BufferInvalidError]: + """ + Validate that the sample buffers allow skipping reception control if that option is true. + Only applicable to order types that have targeted sequencing applications (TGS). + """ + errors: list[BufferInvalidError] = [] + if order.skip_reception_control: + errors.extend(validate_buffers_are_allowed(order)) + return errors + + +def validate_concentration_interval_if_skip_rc( + order: FastqOrder, store: Store, **kwargs +) -> list[ConcentrationInvalidIfSkipRCError]: + """ + Validate that all samples have an allowed concentration if the order skips reception control. + Only applicable to order types that have targeted sequencing applications (TGS). + """ + errors: list[ConcentrationInvalidIfSkipRCError] = [] + if order.skip_reception_control: + errors.extend(validate_concentration_interval(order=order, store=store)) + return errors + + +def validate_container_name_required( + order: OrderWithSamples, **kwargs +) -> list[ContainerNameMissingError]: + """ + Validate that the container names are present for all samples sent on plates. + Applicable to all order types. + """ + errors: list[ContainerNameMissingError] = [] + for sample_index, sample in order.enumerated_samples: + if is_container_name_missing(sample=sample): + error = ContainerNameMissingError(sample_index=sample_index) + errors.append(error) + return errors + + +def validate_concentration_required_if_skip_rc( + order: FastqOrder, **kwargs +) -> list[ConcentrationRequiredError]: + """ + Validate that all samples have a concentration if the order skips reception control. + Only applicable to order types that have targeted sequencing applications (TGS). + """ + errors: list[ConcentrationRequiredError] = [] + if order.skip_reception_control: + errors.extend(validate_concentration_required(order)) + return errors + + +def validate_index_number_in_range( + order: OrderWithIndexedSamples, **kwargs +) -> list[IndexNumberOutOfRangeError]: + errors: list[IndexNumberOutOfRangeError] = [] + for sample_index, sample in order.enumerated_samples: + if is_index_number_out_of_range(sample): + error = IndexNumberOutOfRangeError(sample_index=sample_index, index=sample.index) + errors.append(error) + return errors + + +def validate_index_number_required( + order: OrderWithIndexedSamples, **kwargs +) -> list[IndexNumberMissingError]: + errors: list[IndexNumberMissingError] = [] + for sample_index, sample in order.enumerated_samples: + if is_index_number_missing(sample): + error = IndexNumberMissingError(sample_index=sample_index) + errors.append(error) + return errors + + +def validate_index_sequence_mismatch( + order: OrderWithIndexedSamples, **kwargs +) -> list[IndexSequenceMismatchError]: + errors: list[IndexSequenceMismatchError] = [] + for sample_index, sample in order.enumerated_samples: + if is_index_sequence_mismatched(sample): + error = IndexSequenceMismatchError( + sample_index=sample_index, index=sample.index, index_number=sample.index_number + ) + errors.append(error) + return errors + + +def validate_index_sequence_required( + order: OrderWithIndexedSamples, **kwargs +) -> list[IndexSequenceMissingError]: + errors: list[IndexSequenceMissingError] = [] + for sample_index, sample in order.enumerated_samples: + if is_index_sequence_missing(sample): + error = IndexSequenceMissingError(sample_index=sample_index) + errors.append(error) + return errors + + +def validate_pools_contain_one_application( + order: OrderWithIndexedSamples, **kwargs +) -> list[PoolApplicationError]: + """ + Validate that the pools in the order contain only samples with the same application. + Only applicable to order types with indexed samples (RML and Fluffy). + """ + errors: list[PoolApplicationError] = [] + for pool, enumerated_samples in order.enumerated_pools.items(): + samples: list[IndexedSample] = [sample for _, sample in enumerated_samples] + if has_multiple_applications(samples): + for sample_index, _ in enumerated_samples: + error = PoolApplicationError(sample_index=sample_index, pool_name=pool) + errors.append(error) + return errors + + +def validate_pools_contain_one_priority( + order: OrderWithIndexedSamples, **kwargs +) -> list[PoolPriorityError]: + """ + Validate that the pools in the order contain only samples with the same priority. + Only applicable to order types with indexed samples (RML and Fluffy). + """ + errors: list[PoolPriorityError] = [] + for pool, enumerated_samples in order.enumerated_pools.items(): + samples: list[IndexedSample] = [sample for _, sample in enumerated_samples] + if has_multiple_priorities(samples): + for sample_index, _ in enumerated_samples: + error = PoolPriorityError(sample_index=sample_index, pool_name=pool) + errors.append(error) + return errors + + +def validate_sample_names_available( + order: OrderWithSamples, store: Store, **kwargs +) -> list[SampleNameNotAvailableError]: + """ + Validate that the sample names do not exists in the database under the same customer. + Applicable to all orders without control samples. + """ + errors: list[SampleNameNotAvailableError] = get_sample_name_not_available_errors( + order=order, store=store, has_order_control=False + ) + return errors + + +def validate_non_control_sample_names_available( + order: OrderWithControlSamples, store: Store, **kwargs +) -> list[SampleNameNotAvailableControlError]: + """ + Validate that non-control sample names do not exists in the database under the same customer. + Applicable to all orders with control samples. + """ + errors: list[SampleNameNotAvailableControlError] = get_sample_name_not_available_errors( + order=order, store=store, has_order_control=True + ) + return errors + + +def validate_sample_names_unique( + order: OrderWithSamples, **kwargs +) -> list[SampleNameRepeatedError]: + """ + Validate that all the sample names are unique within the order. + Applicable to all order types. + """ + sample_indices: list[int] = get_indices_for_repeated_sample_names(order) + return [SampleNameRepeatedError(sample_index=sample_index) for sample_index in sample_indices] + + +def validate_tube_container_name_unique( + order: OrderWithSamples, + **kwargs, +) -> list[ContainerNameRepeatedError]: + """ + Validate that the container names are unique for tube samples within the order. + Applicable to all order types. + """ + errors: list[ContainerNameRepeatedError] = [] + repeated_container_name_indices: list = get_indices_for_tube_repeated_container_name(order) + for sample_index in repeated_container_name_indices: + error = ContainerNameRepeatedError(sample_index=sample_index) + errors.append(error) + return errors + + +def validate_volume_interval(order: OrderWithSamples, **kwargs) -> list[InvalidVolumeError]: + """ + Validate that the volume of all samples is within the allowed interval. + Applicable to all order types. + """ + errors: list[InvalidVolumeError] = [] + for sample_index, sample in order.enumerated_samples: + if is_volume_invalid(sample): + error = InvalidVolumeError(sample_index=sample_index) + errors.append(error) + return errors + + +def validate_volume_required(order: OrderWithSamples, **kwargs) -> list[VolumeRequiredError]: + """ + Validate that all samples have a volume if they are in a container. + Applicable to all order types. + """ + errors: list[VolumeRequiredError] = [] + for sample_index, sample in order.enumerated_samples: + if is_volume_missing(sample): + error = VolumeRequiredError(sample_index=sample_index) + errors.append(error) + return errors + + +def validate_wells_contain_at_most_one_sample( + order: OrderWithSamples, + **kwargs, +) -> list[OccupiedWellError]: + """ + Validate that the wells in the order contain at most one sample. + Applicable to all order types with non-indexed samples. + """ + plate_samples = PlateSamplesValidator(order) + return plate_samples.get_occupied_well_errors() + + +def validate_well_position_format(order: OrderWithSamples, **kwargs) -> list[WellFormatError]: + """ + Validate that the well positions of all samples sent in plates have the correct format. + Applicable to all order types with non-indexed samples. + """ + errors: list[WellFormatError] = [] + for sample_index, sample in order.enumerated_samples: + if is_invalid_well_format(sample=sample): + error = WellFormatError(sample_index=sample_index) + errors.append(error) + return errors + + +def validate_well_position_rml_format( + order: OrderWithIndexedSamples, **kwargs +) -> list[WellFormatRmlError]: + """ + Validate that the well positions of all indexed samples have the correct format. + Applicable to all order types with indexed samples. + """ + errors: list[WellFormatRmlError] = [] + for sample_index, sample in order.enumerated_samples: + if sample.well_position_rml and is_invalid_well_format_rml(sample=sample): + error = WellFormatRmlError(sample_index=sample_index) + errors.append(error) + return errors + + +def validate_well_positions_required( + order: OrderWithSamples, + **kwargs, +) -> list[WellPositionMissingError]: + """ + Validate that all samples sent in plates have well positions. + Applicable to all order types with non-indexed samples + """ + plate_samples = PlateSamplesValidator(order) + return plate_samples.get_well_position_missing_errors() + + +def validate_well_positions_required_rml( + order: OrderWithIndexedSamples, **kwargs +) -> list[WellPositionRmlMissingError]: + """ + Validate that all indexed samples have well positions. + Applicable to all order types with indexed samples. + """ + errors: list[WellPositionRmlMissingError] = [] + for sample_index, sample in order.enumerated_samples: + if sample.is_on_plate and not sample.well_position_rml: + error = WellPositionRmlMissingError(sample_index=sample_index) + errors.append(error) + return errors diff --git a/cg/services/orders/validation/rules/sample/utils.py b/cg/services/orders/validation/rules/sample/utils.py new file mode 100644 index 0000000000..15bf38f765 --- /dev/null +++ b/cg/services/orders/validation/rules/sample/utils.py @@ -0,0 +1,254 @@ +import re +from collections import Counter + +from cg.models.orders.sample_base import ContainerEnum, ControlEnum +from cg.services.orders.validation.constants import ALLOWED_SKIP_RC_BUFFERS, IndexEnum +from cg.services.orders.validation.errors.sample_errors import ( + BufferInvalidError, + ConcentrationInvalidIfSkipRCError, + ConcentrationRequiredError, + OccupiedWellError, + SampleError, + SampleNameNotAvailableControlError, + SampleNameNotAvailableError, + WellPositionMissingError, +) +from cg.services.orders.validation.index_sequences import INDEX_SEQUENCES +from cg.services.orders.validation.models.order_with_samples import OrderWithSamples +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.models.sample_aliases import IndexedSample +from cg.services.orders.validation.rules.utils import ( + get_application_concentration_interval, + get_concentration_interval, + has_sample_invalid_concentration, + is_sample_cfdna, +) +from cg.services.orders.validation.workflows.fastq.models.order import FastqOrder +from cg.services.orders.validation.workflows.fastq.models.sample import FastqSample +from cg.store.models import Application +from cg.store.store import Store + + +class PlateSamplesValidator: + + def __init__(self, order: OrderWithSamples): + self.wells: dict[tuple[str, str], list[int]] = {} + self.plate_samples: list[tuple[int, Sample]] = [] + self._initialize_wells(order) + + def _initialize_wells(self, order: OrderWithSamples): + """ + Construct a dict with keys being a (container_name, well_position) pair. + The value will be a list of sample indices for samples located in the well. + """ + for sample_index, sample in order.enumerated_samples: + if sample.is_on_plate: + self.plate_samples.append((sample_index, sample)) + key: tuple[str, str] = (sample.container_name, sample.well_position) + if not self.wells.get(key): + self.wells[key] = [] + self.wells[key].append(sample_index) + + def get_occupied_well_errors(self) -> list[OccupiedWellError]: + """Get errors for samples assigned to wells that are already occupied.""" + conflicting_samples: list[int] = [] + for samples_indices in self.wells.values(): + if len(samples_indices) > 1: + conflicting_samples.extend(samples_indices[1:]) + return get_occupied_well_errors(conflicting_samples) + + def get_well_position_missing_errors(self) -> list[WellPositionMissingError]: + """Get errors for samples missing well positions.""" + samples_missing_wells: list[int] = [] + for sample_index, sample in self.plate_samples: + if not sample.well_position: + samples_missing_wells.append(sample_index) + return get_missing_well_errors(samples_missing_wells) + + +def get_occupied_well_errors(sample_indices: list[int]) -> list[OccupiedWellError]: + return [OccupiedWellError(sample_index=sample_index) for sample_index in sample_indices] + + +def get_missing_well_errors(sample_indices: list[int]) -> list[WellPositionMissingError]: + return [WellPositionMissingError(sample_index=sample_index) for sample_index in sample_indices] + + +def get_indices_for_repeated_sample_names(order: OrderWithSamples) -> list[int]: + counter = Counter([sample.name for sample in order.samples]) + indices: list[int] = [] + for index, sample in order.enumerated_samples: + if counter.get(sample.name) > 1: + indices.append(index) + return indices + + +def get_sample_name_not_available_errors( + order: OrderWithSamples, store: Store, has_order_control: bool +) -> list[SampleError]: + """Return errors for non-control samples with names already used in the database.""" + errors: list[SampleError] = [] + customer = store.get_customer_by_internal_id(order.customer) + for sample_index, sample in order.enumerated_samples: + if store.get_sample_by_customer_and_name( + sample_name=sample.name, customer_entry_id=[customer.id] + ): + if is_sample_name_allowed_to_be_repeated(has_control=has_order_control, sample=sample): + continue + error = get_appropriate_sample_name_available_error( + has_control=has_order_control, sample_index=sample_index + ) + errors.append(error) + return errors + + +def is_sample_name_allowed_to_be_repeated(has_control: bool, sample: Sample) -> bool: + """ + Return whether a sample name can be used if it is already in the database. + This is the case when the order has control samples and the sample is a control. + """ + return has_control and sample.control in [ControlEnum.positive, ControlEnum.negative] + + +def get_appropriate_sample_name_available_error( + has_control: bool, sample_index: int +) -> SampleError: + """ + Return the appropriate error for a sample name that is not available based on whether the + order has control samples or not. + """ + if has_control: + return SampleNameNotAvailableControlError(sample_index=sample_index) + return SampleNameNotAvailableError(sample_index=sample_index) + + +def is_tube_container_name_redundant(sample: Sample, counter: Counter) -> bool: + return sample.container == ContainerEnum.tube and counter.get(sample.container_name) > 1 + + +def get_indices_for_tube_repeated_container_name(order: OrderWithSamples) -> list[int]: + counter = Counter([sample.container_name for sample in order.samples]) + indices: list[int] = [] + for index, sample in order.enumerated_samples: + if is_tube_container_name_redundant(sample, counter): + indices.append(index) + return indices + + +def is_invalid_well_format(sample: Sample) -> bool: + """Check if a sample has an invalid well format.""" + correct_well_position_pattern: str = r"^[A-H]:([1-9]|1[0-2])$" + if sample.is_on_plate: + return not bool(re.match(correct_well_position_pattern, sample.well_position)) + return False + + +def is_invalid_well_format_rml(sample: IndexedSample) -> bool: + """Check if an indexed sample has an invalid well format.""" + correct_well_position_pattern: str = r"^[A-H]:([1-9]|1[0-2])$" + return not bool(re.match(correct_well_position_pattern, sample.well_position_rml)) + + +def is_container_name_missing(sample: Sample) -> bool: + """Checks if a sample is missing its container name.""" + if sample.is_on_plate and not sample.container_name: + return True + return False + + +def create_invalid_concentration_error( + sample: FastqSample, sample_index: int, store: Store +) -> ConcentrationInvalidIfSkipRCError: + application: Application = store.get_application_by_tag(sample.application) + is_cfdna: bool = is_sample_cfdna(sample) + allowed_interval: tuple[float, float] = get_application_concentration_interval( + application=application, + is_cfdna=is_cfdna, + ) + return ConcentrationInvalidIfSkipRCError( + sample_index=sample_index, + allowed_interval=allowed_interval, + ) + + +def validate_concentration_interval( + order: FastqOrder, store: Store +) -> list[ConcentrationInvalidIfSkipRCError]: + errors: list[ConcentrationInvalidIfSkipRCError] = [] + for sample_index, sample in order.enumerated_samples: + if application := store.get_application_by_tag(sample.application): + allowed_interval: tuple[float, float] = get_concentration_interval( + sample=sample, application=application + ) + if allowed_interval and has_sample_invalid_concentration( + sample=sample, allowed_interval=allowed_interval + ): + error: ConcentrationInvalidIfSkipRCError = create_invalid_concentration_error( + sample=sample, + sample_index=sample_index, + store=store, + ) + errors.append(error) + return errors + + +def validate_concentration_required(order: FastqOrder) -> list[ConcentrationRequiredError]: + errors: list[ConcentrationRequiredError] = [] + for sample_index, sample in order.enumerated_samples: + if not sample.concentration_ng_ul: + error = ConcentrationRequiredError(sample_index=sample_index) + errors.append(error) + return errors + + +def has_multiple_applications(samples: list[IndexedSample]) -> bool: + return len({sample.application for sample in samples}) > 1 + + +def has_multiple_priorities(samples: list[IndexedSample]) -> bool: + return len({sample.priority for sample in samples}) > 1 + + +def is_index_number_missing(sample: IndexedSample) -> bool: + """Checks if a sample is missing its index number. + Note: Index is an attribute on the sample, not its position in the list of samples.""" + return sample.index != IndexEnum.NO_INDEX and not sample.index_number + + +def is_index_number_out_of_range(sample: IndexedSample) -> bool: + """Validates that the sample's index number is in range for its specified index. + Note: Index number is an attribute on the sample, not its position in the list of samples.""" + return sample.index_number and not ( + 1 <= sample.index_number <= len(INDEX_SEQUENCES[sample.index]) + ) + + +def is_index_sequence_missing(sample: IndexedSample) -> bool: + """Checks if a sample is missing its index number. + Note: Index sequence is an attribute on the sample, not its position in the list of samples.""" + return sample.index != IndexEnum.NO_INDEX and not sample.index_sequence + + +def is_index_sequence_mismatched(sample: IndexedSample) -> bool: + """Validates if the sample's index sequence matches the given index and index number. + The index numbers start at 1, creating an offset.""" + return ( + sample.index_sequence + and sample.index != IndexEnum.NO_INDEX + and not is_index_number_out_of_range(sample) + and INDEX_SEQUENCES[sample.index][sample.index_number - 1] != sample.index_sequence + ) + + +def validate_buffers_are_allowed(order: FastqOrder) -> list[BufferInvalidError]: + """ + Validate that the order has only samples with buffers that allow to skip reception control. + We can only allow skipping reception control if there is no need to exchange buffer, + so if the sample has nuclease-free water or Tris-HCL as buffer. + """ + errors: list[BufferInvalidError] = [] + for sample_index, sample in order.enumerated_samples: + if sample.elution_buffer not in ALLOWED_SKIP_RC_BUFFERS: + error = BufferInvalidError(sample_index=sample_index) + errors.append(error) + return errors diff --git a/cg/services/orders/validation/rules/utils.py b/cg/services/orders/validation/rules/utils.py new file mode 100644 index 0000000000..1736ecae22 --- /dev/null +++ b/cg/services/orders/validation/rules/utils.py @@ -0,0 +1,83 @@ +from cg.constants.sample_sources import SourceType +from cg.models.orders.constants import OrderType +from cg.models.orders.sample_base import ContainerEnum +from cg.services.orders.validation.constants import MAXIMUM_VOLUME, MINIMUM_VOLUME +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.models.sample_aliases import SampleWithSkipRC +from cg.store.models import Application +from cg.store.store import Store + + +def is_volume_invalid(sample: Sample) -> bool: + in_container: bool = is_in_container(sample.container) + allowed_volume: bool = is_volume_within_allowed_interval(sample.volume) + return in_container and not allowed_volume + + +def is_in_container(container: ContainerEnum) -> bool: + return container != ContainerEnum.no_container + + +def is_volume_within_allowed_interval(volume: int) -> bool: + return volume and (MINIMUM_VOLUME <= volume <= MAXIMUM_VOLUME) + + +def is_sample_on_plate(sample: Sample) -> bool: + return sample.container == ContainerEnum.plate + + +def is_application_compatible( + order_type: OrderType, + application_tag: str, + store: Store, +) -> bool: + application: Application | None = store.get_application_by_tag(application_tag) + return not application or order_type in application.order_types + + +def is_volume_missing(sample: Sample) -> bool: + """Check if a sample is missing its volume.""" + if is_in_container(sample.container) and not sample.volume: + return True + return False + + +def has_sample_invalid_concentration( + sample: SampleWithSkipRC, allowed_interval: tuple[float, float] +) -> bool: + concentration: float | None = sample.concentration_ng_ul + return concentration and not is_sample_concentration_within_interval( + concentration=concentration, interval=allowed_interval + ) + + +def get_concentration_interval( + sample: SampleWithSkipRC, application: Application +) -> tuple[float, float] | None: + is_cfdna: bool = is_sample_cfdna(sample) + allowed_interval: tuple[float, float] = get_application_concentration_interval( + application=application, is_cfdna=is_cfdna + ) + return allowed_interval + + +def is_sample_cfdna(sample: SampleWithSkipRC) -> bool: + source = sample.source + return source == SourceType.CELL_FREE_DNA + + +def get_application_concentration_interval( + application: Application, is_cfdna: bool +) -> tuple[float, float]: + if is_cfdna: + return ( + application.sample_concentration_minimum_cfdna, + application.sample_concentration_maximum_cfdna, + ) + return application.sample_concentration_minimum, application.sample_concentration_maximum + + +def is_sample_concentration_within_interval( + concentration: float, interval: tuple[float, float] +) -> bool: + return interval[0] <= concentration <= interval[1] diff --git a/cg/services/orders/validation/service.py b/cg/services/orders/validation/service.py new file mode 100644 index 0000000000..c020ece512 --- /dev/null +++ b/cg/services/orders/validation/service.py @@ -0,0 +1,101 @@ +import logging + +from cg.exc import OrderError as OrderValidationError +from cg.models.orders.constants import OrderType +from cg.services.orders.validation.errors.case_errors import CaseError +from cg.services.orders.validation.errors.case_sample_errors import CaseSampleError +from cg.services.orders.validation.errors.order_errors import OrderError +from cg.services.orders.validation.errors.sample_errors import SampleError +from cg.services.orders.validation.errors.validation_errors import ValidationErrors +from cg.services.orders.validation.model_validator.model_validator import ModelValidator +from cg.services.orders.validation.models.order import Order +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.order_type_maps import ( + ORDER_TYPE_MODEL_MAP, + ORDER_TYPE_RULE_SET_MAP, + RuleSet, +) +from cg.services.orders.validation.response_mapper import create_order_validation_response +from cg.services.orders.validation.utils import ( + apply_case_sample_validation, + apply_case_validation, + apply_order_validation, + apply_sample_validation, +) +from cg.store.store import Store + +LOG = logging.getLogger(__name__) + + +class OrderValidationService: + def __init__(self, store: Store): + self.store = store + + def get_validation_response(self, raw_order: dict, order_type: OrderType, user_id: int) -> dict: + model = ORDER_TYPE_MODEL_MAP[order_type] + rule_set = ORDER_TYPE_RULE_SET_MAP[order_type] + errors: ValidationErrors = self._get_errors( + raw_order=raw_order, model=model, rule_set=rule_set, user_id=user_id + ) + return create_order_validation_response(raw_order=raw_order, errors=errors) + + def parse_and_validate(self, raw_order: dict, order_type: OrderType, user_id: int) -> Order: + model = ORDER_TYPE_MODEL_MAP[order_type] + rule_set = ORDER_TYPE_RULE_SET_MAP[order_type] + parsed_order, errors = ModelValidator.validate(order=raw_order, model=model) + if parsed_order: + parsed_order._user_id = user_id + errors: ValidationErrors = self._get_rule_validation_errors( + order=parsed_order, + rule_set=rule_set, + ) + if not errors.is_empty: + LOG.error(errors.get_error_message()) + raise OrderValidationError(message="Order contained errors") + return parsed_order + + def _get_errors( + self, raw_order: dict, model: type[Order], rule_set: RuleSet, user_id: int + ) -> ValidationErrors: + parsed_order, errors = ModelValidator.validate(order=raw_order, model=model) + if parsed_order: + parsed_order._user_id = user_id + errors: ValidationErrors = self._get_rule_validation_errors( + order=parsed_order, rule_set=rule_set + ) + return errors + + def _get_rule_validation_errors(self, order: Order, rule_set: RuleSet) -> ValidationErrors: + + case_errors = [] + case_sample_errors = [] + order_errors: list[OrderError] = apply_order_validation( + rules=rule_set.order_rules, + order=order, + store=self.store, + ) + sample_errors = [] + if isinstance(order, OrderWithCases): + case_errors: list[CaseError] = apply_case_validation( + rules=rule_set.case_rules, + order=order, + store=self.store, + ) + case_sample_errors: list[CaseSampleError] = apply_case_sample_validation( + rules=rule_set.case_sample_rules, + order=order, + store=self.store, + ) + else: + sample_errors: list[SampleError] = apply_sample_validation( + rules=rule_set.sample_rules, + order=order, + store=self.store, + ) + + return ValidationErrors( + case_errors=case_errors, + case_sample_errors=case_sample_errors, + order_errors=order_errors, + sample_errors=sample_errors, + ) diff --git a/cg/services/orders/validation/utils.py b/cg/services/orders/validation/utils.py new file mode 100644 index 0000000000..cc32c9905b --- /dev/null +++ b/cg/services/orders/validation/utils.py @@ -0,0 +1,61 @@ +from typing import Callable + +from cg.models.orders.sample_base import ControlEnum +from cg.services.orders.validation.constants import ElutionBuffer, ExtractionMethod +from cg.services.orders.validation.errors.case_errors import CaseError +from cg.services.orders.validation.errors.case_sample_errors import CaseSampleError +from cg.services.orders.validation.errors.order_errors import OrderError +from cg.services.orders.validation.errors.sample_errors import SampleError +from cg.services.orders.validation.models.order import Order +from cg.store.store import Store + + +def apply_order_validation(rules: list[Callable], order: Order, store: Store) -> list[OrderError]: + errors: list[OrderError] = [] + for rule in rules: + rule_errors: list[OrderError] = rule(order=order, store=store) + errors.extend(rule_errors) + return errors + + +def apply_case_validation(rules: list[Callable], order: Order, store: Store) -> list[CaseError]: + errors: list[CaseError] = [] + for rule in rules: + rule_errors: list[CaseError] = rule(order=order, store=store) + errors.extend(rule_errors) + return errors + + +def apply_case_sample_validation( + rules: list[Callable], order: Order, store: Store +) -> list[CaseSampleError]: + errors: list[CaseSampleError] = [] + for rule in rules: + rule_errors: list[CaseSampleError] = rule(order=order, store=store) + errors.extend(rule_errors) + return errors + + +def apply_sample_validation(rules: list[Callable], order: Order, store: Store) -> list[SampleError]: + errors: list[SampleError] = [] + for rule in rules: + rule_errors: list[SampleError] = rule(order=order, store=store) + errors.extend(rule_errors) + return errors + + +def parse_buffer(buffer: str | None) -> ElutionBuffer | None: + return ElutionBuffer.OTHER if buffer and buffer.startswith("Other") else buffer + + +def parse_control(control: ControlEnum | None) -> ControlEnum: + """Convert the control value into one of the Enum values if it's None.""" + return control or ControlEnum.not_control + + +def parse_extraction_method(extraction_method: str | None) -> ExtractionMethod: + return ( + ExtractionMethod.MAGNAPURE_96 + if extraction_method and extraction_method.startswith(ExtractionMethod.MAGNAPURE_96) + else extraction_method + ) diff --git a/cg/services/orders/validation/workflows/__init__.py b/cg/services/orders/validation/workflows/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/orders/validation/workflows/balsamic/constants.py b/cg/services/orders/validation/workflows/balsamic/constants.py new file mode 100644 index 0000000000..2ece862334 --- /dev/null +++ b/cg/services/orders/validation/workflows/balsamic/constants.py @@ -0,0 +1,13 @@ +from enum import StrEnum + +from cg.constants import DataDelivery + + +class BalsamicDeliveryType(StrEnum): + ANALYSIS = DataDelivery.ANALYSIS_FILES + ANALYSIS_SCOUT = DataDelivery.ANALYSIS_SCOUT + FASTQ_ANALYSIS = DataDelivery.FASTQ_ANALYSIS + FASTQ_SCOUT = DataDelivery.FASTQ_SCOUT + FASTQ_ANALYSIS_SCOUT = DataDelivery.FASTQ_ANALYSIS_SCOUT + SCOUT = DataDelivery.SCOUT + NO_DELIVERY = DataDelivery.NO_DELIVERY diff --git a/cg/services/orders/validation/workflows/balsamic/models/case.py b/cg/services/orders/validation/workflows/balsamic/models/case.py new file mode 100644 index 0000000000..03cda79259 --- /dev/null +++ b/cg/services/orders/validation/workflows/balsamic/models/case.py @@ -0,0 +1,16 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.case import Case +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.workflows.balsamic.models.sample import BalsamicSample + +NewSample = Annotated[BalsamicSample, Tag("new")] +OldSample = Annotated[ExistingSample, Tag("existing")] + + +class BalsamicCase(Case): + cohorts: list[str] | None = None + samples: list[Annotated[NewSample | OldSample, Discriminator(has_internal_id)]] + synopsis: str | None = None diff --git a/cg/services/orders/validation/workflows/balsamic/models/order.py b/cg/services/orders/validation/workflows/balsamic/models/order.py new file mode 100644 index 0000000000..a8ca38de79 --- /dev/null +++ b/cg/services/orders/validation/workflows/balsamic/models/order.py @@ -0,0 +1,24 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_case import ExistingCase +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.workflows.balsamic.constants import BalsamicDeliveryType +from cg.services.orders.validation.workflows.balsamic.models.case import BalsamicCase + +NewCase = Annotated[BalsamicCase, Tag("new")] +OldCase = Annotated[ExistingCase, Tag("existing")] + + +class BalsamicOrder(OrderWithCases): + cases: list[Annotated[NewCase | OldCase, Discriminator(has_internal_id)]] + delivery_type: BalsamicDeliveryType + + @property + def enumerated_new_cases(self) -> list[tuple[int, BalsamicCase | ExistingCase]]: + cases: list[tuple[int, BalsamicCase | ExistingCase]] = [] + for case_index, case in self.enumerated_cases: + if case.is_new: + cases.append((case_index, case)) + return cases diff --git a/cg/services/orders/validation/workflows/balsamic/models/sample.py b/cg/services/orders/validation/workflows/balsamic/models/sample.py new file mode 100644 index 0000000000..dfa7ea6402 --- /dev/null +++ b/cg/services/orders/validation/workflows/balsamic/models/sample.py @@ -0,0 +1,28 @@ +from pydantic import BeforeValidator, Field +from typing_extensions import Annotated + +from cg.models.orders.sample_base import NAME_PATTERN, ControlEnum, SexEnum, StatusEnum +from cg.services.orders.validation.constants import ElutionBuffer, TissueBlockEnum +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_buffer, parse_control + + +class BalsamicSample(Sample): + age_at_sampling: float | None = None + capture_kit: str | None = None + comment: str | None = None + concentration_ng_ul: float | None = None + control: Annotated[ControlEnum, BeforeValidator(parse_control)] = ControlEnum.not_control + elution_buffer: Annotated[ElutionBuffer | None, BeforeValidator(parse_buffer)] = None + formalin_fixation_time: int | None = None + phenotype_groups: list[str] | None = None + phenotype_terms: list[str] | None = None + post_formalin_fixation_time: int | None = None + require_qc_ok: bool = False + sex: SexEnum + source: str + status: StatusEnum | None = None + subject_id: str = Field(pattern=NAME_PATTERN, max_length=128) + tissue_block_size: TissueBlockEnum | None = None + tumour: bool = False + tumour_purity: int | None = None diff --git a/cg/services/orders/validation/workflows/balsamic/validation_rules.py b/cg/services/orders/validation/workflows/balsamic/validation_rules.py new file mode 100644 index 0000000000..140fcc2bf6 --- /dev/null +++ b/cg/services/orders/validation/workflows/balsamic/validation_rules.py @@ -0,0 +1,66 @@ +from cg.services.orders.validation.rules.case.rules import ( + validate_at_most_two_samples_per_case, + validate_case_internal_ids_exist, + validate_case_names_available, + validate_case_names_not_repeated, + validate_existing_cases_belong_to_collaboration, + validate_number_of_normal_samples, +) +from cg.services.orders.validation.rules.case_sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_application_not_archived, + validate_buffer_required, + validate_buffer_skip_rc_condition, + validate_capture_kit_panel_requirement, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_existing_samples_belong_to_collaboration, + validate_sample_names_different_from_case_names, + validate_sample_names_not_repeated, + validate_samples_exist, + validate_subject_ids_different_from_case_names, + validate_subject_ids_different_from_sample_names, + validate_subject_sex_consistency, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) + +BALSAMIC_CASE_RULES: list[callable] = [ + validate_at_most_two_samples_per_case, + validate_case_internal_ids_exist, + validate_case_names_available, + validate_case_names_not_repeated, + validate_existing_cases_belong_to_collaboration, + validate_number_of_normal_samples, +] + +BALSAMIC_CASE_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_application_not_archived, + validate_buffer_required, + validate_buffer_skip_rc_condition, + validate_capture_kit_panel_requirement, + validate_volume_required, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_existing_samples_belong_to_collaboration, + validate_samples_exist, + validate_sample_names_different_from_case_names, + validate_sample_names_not_repeated, + validate_subject_sex_consistency, + validate_subject_ids_different_from_case_names, + validate_subject_ids_different_from_sample_names, + validate_tube_container_name_unique, + validate_volume_interval, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +] diff --git a/cg/services/orders/validation/workflows/balsamic_umi/constants.py b/cg/services/orders/validation/workflows/balsamic_umi/constants.py new file mode 100644 index 0000000000..a1837697ae --- /dev/null +++ b/cg/services/orders/validation/workflows/balsamic_umi/constants.py @@ -0,0 +1,13 @@ +from enum import StrEnum + +from cg.constants import DataDelivery + + +class BalsamicUmiDeliveryType(StrEnum): + ANALYSIS = DataDelivery.ANALYSIS_FILES + ANALYSIS_SCOUT = DataDelivery.ANALYSIS_SCOUT + FASTQ_ANALYSIS = DataDelivery.FASTQ_ANALYSIS + FASTQ_ANALYSIS_SCOUT = DataDelivery.FASTQ_ANALYSIS_SCOUT + FASTQ_SCOUT = DataDelivery.FASTQ_SCOUT + NO_DELIVERY = DataDelivery.NO_DELIVERY + SCOUT = DataDelivery.SCOUT diff --git a/cg/services/orders/validation/workflows/balsamic_umi/models/case.py b/cg/services/orders/validation/workflows/balsamic_umi/models/case.py new file mode 100644 index 0000000000..9452bec2bc --- /dev/null +++ b/cg/services/orders/validation/workflows/balsamic_umi/models/case.py @@ -0,0 +1,14 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.workflows.balsamic.models.case import BalsamicCase +from cg.services.orders.validation.workflows.balsamic_umi.models.sample import BalsamicUmiSample + +NewSample = Annotated[BalsamicUmiSample, Tag("new")] +OldSample = Annotated[ExistingSample, Tag("existing")] + + +class BalsamicUmiCase(BalsamicCase): + samples: list[Annotated[NewSample | OldSample, Discriminator(has_internal_id)]] diff --git a/cg/services/orders/validation/workflows/balsamic_umi/models/order.py b/cg/services/orders/validation/workflows/balsamic_umi/models/order.py new file mode 100644 index 0000000000..b327ee6cfd --- /dev/null +++ b/cg/services/orders/validation/workflows/balsamic_umi/models/order.py @@ -0,0 +1,24 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_case import ExistingCase +from cg.services.orders.validation.workflows.balsamic.models.order import BalsamicOrder +from cg.services.orders.validation.workflows.balsamic_umi.constants import BalsamicUmiDeliveryType +from cg.services.orders.validation.workflows.balsamic_umi.models.case import BalsamicUmiCase + +NewCase = Annotated[BalsamicUmiCase, Tag("new")] +OldCase = Annotated[ExistingCase, Tag("existing")] + + +class BalsamicUmiOrder(BalsamicOrder): + cases: list[Annotated[NewCase | OldCase, Discriminator(has_internal_id)]] + delivery_type: BalsamicUmiDeliveryType + + @property + def enumerated_new_cases(self) -> list[tuple[int, BalsamicUmiCase | ExistingCase]]: + cases: list[tuple[int, BalsamicUmiCase | ExistingCase]] = [] + for case_index, case in self.enumerated_cases: + if case.is_new: + cases.append((case_index, case)) + return cases diff --git a/cg/services/orders/validation/workflows/balsamic_umi/models/sample.py b/cg/services/orders/validation/workflows/balsamic_umi/models/sample.py new file mode 100644 index 0000000000..9a325780bd --- /dev/null +++ b/cg/services/orders/validation/workflows/balsamic_umi/models/sample.py @@ -0,0 +1,5 @@ +from cg.services.orders.validation.workflows.balsamic.models.sample import BalsamicSample + + +class BalsamicUmiSample(BalsamicSample): + pass diff --git a/cg/services/orders/validation/workflows/balsamic_umi/validation_rules.py b/cg/services/orders/validation/workflows/balsamic_umi/validation_rules.py new file mode 100644 index 0000000000..afd21d659d --- /dev/null +++ b/cg/services/orders/validation/workflows/balsamic_umi/validation_rules.py @@ -0,0 +1,7 @@ +from cg.services.orders.validation.workflows.balsamic.validation_rules import ( + BALSAMIC_CASE_RULES, + BALSAMIC_CASE_SAMPLE_RULES, +) + +BALSAMIC_UMI_CASE_RULES: list[callable] = BALSAMIC_CASE_RULES.copy() +BALSAMIC_UMI_CASE_SAMPLE_RULES: list[callable] = BALSAMIC_CASE_SAMPLE_RULES.copy() diff --git a/cg/services/orders/validation/workflows/fastq/constants.py b/cg/services/orders/validation/workflows/fastq/constants.py new file mode 100644 index 0000000000..1e9c70dc42 --- /dev/null +++ b/cg/services/orders/validation/workflows/fastq/constants.py @@ -0,0 +1,8 @@ +from enum import StrEnum + +from cg.constants import DataDelivery + + +class FastqDeliveryType(StrEnum): + FASTQ = DataDelivery.FASTQ + NO_DELIVERY = DataDelivery.NO_DELIVERY diff --git a/cg/services/orders/validation/workflows/fastq/models/order.py b/cg/services/orders/validation/workflows/fastq/models/order.py new file mode 100644 index 0000000000..b4acfcf245 --- /dev/null +++ b/cg/services/orders/validation/workflows/fastq/models/order.py @@ -0,0 +1,12 @@ +from cg.services.orders.validation.models.order_with_samples import OrderWithSamples +from cg.services.orders.validation.workflows.fastq.constants import FastqDeliveryType +from cg.services.orders.validation.workflows.fastq.models.sample import FastqSample + + +class FastqOrder(OrderWithSamples): + delivery_type: FastqDeliveryType + samples: list[FastqSample] + + @property + def enumerated_samples(self) -> enumerate[FastqSample]: + return enumerate(self.samples) diff --git a/cg/services/orders/validation/workflows/fastq/models/sample.py b/cg/services/orders/validation/workflows/fastq/models/sample.py new file mode 100644 index 0000000000..ae77340424 --- /dev/null +++ b/cg/services/orders/validation/workflows/fastq/models/sample.py @@ -0,0 +1,20 @@ +from pydantic import BeforeValidator, Field +from typing_extensions import Annotated + +from cg.models.orders.sample_base import NAME_PATTERN, PriorityEnum, SexEnum +from cg.services.orders.validation.constants import ElutionBuffer +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_buffer + + +class FastqSample(Sample): + capture_kit: str | None = None + concentration_ng_ul: float | None = None + elution_buffer: Annotated[ElutionBuffer, BeforeValidator(parse_buffer)] + priority: PriorityEnum + quantity: int | None = None + require_qc_ok: bool = False + sex: SexEnum + source: str + subject_id: str = Field(pattern=NAME_PATTERN, max_length=128) + tumour: bool = False diff --git a/cg/services/orders/validation/workflows/fastq/validation_rules.py b/cg/services/orders/validation/workflows/fastq/validation_rules.py new file mode 100644 index 0000000000..a93fa75f61 --- /dev/null +++ b/cg/services/orders/validation/workflows/fastq/validation_rules.py @@ -0,0 +1,35 @@ +from cg.services.orders.validation.rules.sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_buffer_skip_rc_condition, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) + +FASTQ_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_buffer_skip_rc_condition, + validate_concentration_required_if_skip_rc, + validate_concentration_interval_if_skip_rc, + validate_container_name_required, + validate_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_wells_contain_at_most_one_sample, + validate_well_position_format, + validate_well_positions_required, +] diff --git a/cg/services/orders/validation/workflows/fluffy/constants.py b/cg/services/orders/validation/workflows/fluffy/constants.py new file mode 100644 index 0000000000..5ccdca7e2c --- /dev/null +++ b/cg/services/orders/validation/workflows/fluffy/constants.py @@ -0,0 +1,8 @@ +from enum import StrEnum + +from cg.constants import DataDelivery + + +class FluffyDeliveryType(StrEnum): + STATINA = DataDelivery.STATINA + NO_DELIVERY = DataDelivery.NO_DELIVERY diff --git a/cg/services/orders/validation/workflows/fluffy/models/order.py b/cg/services/orders/validation/workflows/fluffy/models/order.py new file mode 100644 index 0000000000..d20c3afa2b --- /dev/null +++ b/cg/services/orders/validation/workflows/fluffy/models/order.py @@ -0,0 +1,34 @@ +from cg.services.orders.validation.models.order_with_samples import OrderWithSamples +from cg.services.orders.validation.workflows.fluffy.constants import FluffyDeliveryType +from cg.services.orders.validation.workflows.fluffy.models.sample import FluffySample + + +class FluffyOrder(OrderWithSamples): + delivery_type: FluffyDeliveryType + samples: list[FluffySample] + + @property + def enumerated_samples(self) -> enumerate[FluffySample]: + return enumerate(self.samples) + + @property + def pools(self) -> dict[str, list[FluffySample]]: + """Return a dictionary matching pool names and their respective samples.""" + pools: dict[str, list[FluffySample]] = {} + for sample in self.samples: + if sample.pool not in pools: + pools[sample.pool] = [sample] + else: + pools[sample.pool].append(sample) + return pools + + @property + def enumerated_pools(self) -> dict[str, list[tuple[int, FluffySample]]]: + """Return the pool dictionary with indexes for the samples to map them to validation errors.""" + pools: dict[str, list[tuple[int, FluffySample]]] = {} + for sample_index, sample in self.enumerated_samples: + if sample.pool not in pools: + pools[sample.pool] = [(sample_index, sample)] + else: + pools[sample.pool].append((sample_index, sample)) + return pools diff --git a/cg/services/orders/validation/workflows/fluffy/models/sample.py b/cg/services/orders/validation/workflows/fluffy/models/sample.py new file mode 100644 index 0000000000..c035275300 --- /dev/null +++ b/cg/services/orders/validation/workflows/fluffy/models/sample.py @@ -0,0 +1,39 @@ +import logging + +from pydantic import BeforeValidator, Field, model_validator +from typing_extensions import Annotated + +from cg.models.orders.sample_base import ContainerEnum, ControlEnum, PriorityEnum +from cg.services.orders.validation.constants import IndexEnum +from cg.services.orders.validation.index_sequences import INDEX_SEQUENCES +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_control + +LOG = logging.getLogger(__name__) + + +class FluffySample(Sample): + concentration: float + concentration_sample: float | None = None + container: ContainerEnum | None = Field(default=None, exclude=True) + control: Annotated[ControlEnum, BeforeValidator(parse_control)] = ControlEnum.not_control + priority: PriorityEnum + index: IndexEnum + index_number: int | None = None + index_sequence: str | None = None + pool: str + rml_plate_name: str | None = None + volume: int + well_position_rml: str | None = None + + @model_validator(mode="after") + def set_default_index_sequence(self) -> "FluffySample": + """Set a default index_sequence from the index and index_number.""" + if not self.index_sequence and (self.index and self.index_number): + try: + self.index_sequence = INDEX_SEQUENCES[self.index][self.index_number - 1] + except Exception: + LOG.warning( + f"No index sequence set and no suitable sequence found for index {self.index}, number {self.index_number}" + ) + return self diff --git a/cg/services/orders/validation/workflows/fluffy/validation_rules.py b/cg/services/orders/validation/workflows/fluffy/validation_rules.py new file mode 100644 index 0000000000..4d828907d5 --- /dev/null +++ b/cg/services/orders/validation/workflows/fluffy/validation_rules.py @@ -0,0 +1,37 @@ +from cg.services.orders.validation.rules.sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_index_number_in_range, + validate_index_number_required, + validate_index_sequence_mismatch, + validate_index_sequence_required, + validate_pools_contain_one_application, + validate_pools_contain_one_priority, + validate_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_rml_format, + validate_well_positions_required_rml, +) + +FLUFFY_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_index_number_required, + validate_index_number_in_range, + validate_index_sequence_required, + validate_index_sequence_mismatch, + validate_pools_contain_one_application, + validate_pools_contain_one_priority, + validate_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_rml_format, + validate_well_positions_required_rml, +] diff --git a/cg/services/orders/validation/workflows/metagenome/constants.py b/cg/services/orders/validation/workflows/metagenome/constants.py new file mode 100644 index 0000000000..c6ba881a3a --- /dev/null +++ b/cg/services/orders/validation/workflows/metagenome/constants.py @@ -0,0 +1,8 @@ +from enum import StrEnum + +from cg.constants import DataDelivery + + +class MetagenomeDeliveryType(StrEnum): + FASTQ = DataDelivery.FASTQ + NO_DELIVERY = DataDelivery.NO_DELIVERY diff --git a/cg/services/orders/validation/workflows/metagenome/models/order.py b/cg/services/orders/validation/workflows/metagenome/models/order.py new file mode 100644 index 0000000000..43a2417700 --- /dev/null +++ b/cg/services/orders/validation/workflows/metagenome/models/order.py @@ -0,0 +1,12 @@ +from cg.services.orders.validation.models.order_with_samples import OrderWithSamples +from cg.services.orders.validation.workflows.metagenome.constants import MetagenomeDeliveryType +from cg.services.orders.validation.workflows.metagenome.models.sample import MetagenomeSample + + +class MetagenomeOrder(OrderWithSamples): + delivery_type: MetagenomeDeliveryType + samples: list[MetagenomeSample] + + @property + def enumerated_samples(self) -> enumerate[MetagenomeSample]: + return enumerate(self.samples) diff --git a/cg/services/orders/validation/workflows/metagenome/models/sample.py b/cg/services/orders/validation/workflows/metagenome/models/sample.py new file mode 100644 index 0000000000..75fbb4dc76 --- /dev/null +++ b/cg/services/orders/validation/workflows/metagenome/models/sample.py @@ -0,0 +1,17 @@ +from pydantic import BeforeValidator +from typing_extensions import Annotated + +from cg.models.orders.sample_base import ControlEnum, PriorityEnum +from cg.services.orders.validation.constants import ElutionBuffer +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_buffer, parse_control + + +class MetagenomeSample(Sample): + concentration_ng_ul: float | None = None + control: Annotated[ControlEnum, BeforeValidator(parse_control)] = ControlEnum.not_control + elution_buffer: Annotated[ElutionBuffer, BeforeValidator(parse_buffer)] + priority: PriorityEnum + quantity: int | None = None + require_qc_ok: bool = False + source: str diff --git a/cg/services/orders/validation/workflows/metagenome/validation_rules.py b/cg/services/orders/validation/workflows/metagenome/validation_rules.py new file mode 100644 index 0000000000..0b37ea183d --- /dev/null +++ b/cg/services/orders/validation/workflows/metagenome/validation_rules.py @@ -0,0 +1,29 @@ +from cg.services.orders.validation.rules.sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_container_name_required, + validate_non_control_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) + +METAGENOME_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_container_name_required, + validate_non_control_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +] diff --git a/cg/services/orders/validation/workflows/microbial_fastq/constants.py b/cg/services/orders/validation/workflows/microbial_fastq/constants.py new file mode 100644 index 0000000000..ad911d46cf --- /dev/null +++ b/cg/services/orders/validation/workflows/microbial_fastq/constants.py @@ -0,0 +1,8 @@ +from enum import StrEnum + +from cg.constants import DataDelivery + + +class MicrobialFastqDeliveryType(StrEnum): + FASTQ = DataDelivery.FASTQ + NO_DELIVERY = DataDelivery.NO_DELIVERY diff --git a/cg/services/orders/validation/workflows/microbial_fastq/models/order.py b/cg/services/orders/validation/workflows/microbial_fastq/models/order.py new file mode 100644 index 0000000000..82aff7470f --- /dev/null +++ b/cg/services/orders/validation/workflows/microbial_fastq/models/order.py @@ -0,0 +1,16 @@ +from cg.services.orders.validation.models.order_with_samples import OrderWithSamples +from cg.services.orders.validation.workflows.microbial_fastq.constants import ( + MicrobialFastqDeliveryType, +) +from cg.services.orders.validation.workflows.microbial_fastq.models.sample import ( + MicrobialFastqSample, +) + + +class MicrobialFastqOrder(OrderWithSamples): + delivery_type: MicrobialFastqDeliveryType + samples: list[MicrobialFastqSample] + + @property + def enumerated_samples(self) -> enumerate[MicrobialFastqSample]: + return enumerate(self.samples) diff --git a/cg/services/orders/validation/workflows/microbial_fastq/models/sample.py b/cg/services/orders/validation/workflows/microbial_fastq/models/sample.py new file mode 100644 index 0000000000..ab74f89885 --- /dev/null +++ b/cg/services/orders/validation/workflows/microbial_fastq/models/sample.py @@ -0,0 +1,15 @@ +from pydantic import BeforeValidator +from typing_extensions import Annotated + +from cg.models.orders.sample_base import ControlEnum, PriorityEnum +from cg.services.orders.validation.constants import ElutionBuffer +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_buffer, parse_control + + +class MicrobialFastqSample(Sample): + control: Annotated[ControlEnum, BeforeValidator(parse_control)] = ControlEnum.not_control + elution_buffer: Annotated[ElutionBuffer, BeforeValidator(parse_buffer)] + priority: PriorityEnum + quantity: int | None = None + volume: int diff --git a/cg/services/orders/validation/workflows/microbial_fastq/validation_rules.py b/cg/services/orders/validation/workflows/microbial_fastq/validation_rules.py new file mode 100644 index 0000000000..7e57856714 --- /dev/null +++ b/cg/services/orders/validation/workflows/microbial_fastq/validation_rules.py @@ -0,0 +1,29 @@ +from cg.services.orders.validation.rules.sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_container_name_required, + validate_non_control_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) + +MICROBIAL_FASTQ_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_container_name_required, + validate_non_control_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_wells_contain_at_most_one_sample, + validate_well_position_format, + validate_well_positions_required, +] diff --git a/cg/services/orders/validation/workflows/microsalt/constants.py b/cg/services/orders/validation/workflows/microsalt/constants.py new file mode 100644 index 0000000000..bda53c6c74 --- /dev/null +++ b/cg/services/orders/validation/workflows/microsalt/constants.py @@ -0,0 +1,9 @@ +from enum import StrEnum + +from cg.constants import DataDelivery + + +class MicrosaltDeliveryType(StrEnum): + FASTQ_QC = DataDelivery.FASTQ_QC + FASTQ_QC_ANALYSIS = DataDelivery.FASTQ_QC_ANALYSIS + NO_DELIVERY = DataDelivery.NO_DELIVERY diff --git a/cg/services/orders/validation/workflows/microsalt/models/order.py b/cg/services/orders/validation/workflows/microsalt/models/order.py new file mode 100644 index 0000000000..4b16e17686 --- /dev/null +++ b/cg/services/orders/validation/workflows/microsalt/models/order.py @@ -0,0 +1,12 @@ +from cg.services.orders.validation.models.order_with_samples import OrderWithSamples +from cg.services.orders.validation.workflows.microsalt.constants import MicrosaltDeliveryType +from cg.services.orders.validation.workflows.microsalt.models.sample import MicrosaltSample + + +class MicrosaltOrder(OrderWithSamples): + delivery_type: MicrosaltDeliveryType + samples: list[MicrosaltSample] + + @property + def enumerated_samples(self) -> enumerate[MicrosaltSample]: + return enumerate(self.samples) diff --git a/cg/services/orders/validation/workflows/microsalt/models/sample.py b/cg/services/orders/validation/workflows/microsalt/models/sample.py new file mode 100644 index 0000000000..86a49f1082 --- /dev/null +++ b/cg/services/orders/validation/workflows/microsalt/models/sample.py @@ -0,0 +1,23 @@ +from pydantic import BeforeValidator, Field, PrivateAttr +from typing_extensions import Annotated + +from cg.models.orders.sample_base import ControlEnum, PriorityEnum +from cg.services.orders.validation.constants import ElutionBuffer, ExtractionMethod +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_buffer, parse_control, parse_extraction_method + + +class MicrosaltSample(Sample): + control: Annotated[ControlEnum, BeforeValidator(parse_control)] = ControlEnum.not_control + elution_buffer: Annotated[ElutionBuffer, BeforeValidator(parse_buffer)] + extraction_method: Annotated[ExtractionMethod, BeforeValidator(parse_extraction_method)] + organism: str + organism_other: str | None = None + priority: PriorityEnum + reference_genome: str = Field(max_length=255) + _verified_organism: bool | None = PrivateAttr(default=None) + + def model_dump(self, **kwargs) -> dict: + data = super().model_dump(**kwargs) + data["verified_organism"] = self._verified_organism + return data diff --git a/cg/services/orders/validation/workflows/microsalt/validation_rules.py b/cg/services/orders/validation/workflows/microsalt/validation_rules.py new file mode 100644 index 0000000000..9a6770d5fc --- /dev/null +++ b/cg/services/orders/validation/workflows/microsalt/validation_rules.py @@ -0,0 +1,29 @@ +from cg.services.orders.validation.rules.sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_container_name_required, + validate_non_control_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) + +MICROSALT_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_container_name_required, + validate_non_control_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +] diff --git a/cg/services/orders/validation/workflows/mip_dna/models/case.py b/cg/services/orders/validation/workflows/mip_dna/models/case.py new file mode 100644 index 0000000000..1a77df2902 --- /dev/null +++ b/cg/services/orders/validation/workflows/mip_dna/models/case.py @@ -0,0 +1,23 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.case import Case +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.workflows.mip_dna.models.sample import MipDnaSample + +NewSample = Annotated[MipDnaSample, Tag("new")] +OldSample = Annotated[ExistingSample, Tag("existing")] + + +class MipDnaCase(Case): + cohorts: list[str] | None = None + panels: list[str] + synopsis: str | None = None + samples: list[Annotated[NewSample | OldSample, Discriminator(has_internal_id)]] + + def get_samples_with_father(self) -> list[tuple[MipDnaSample, int]]: + return [(sample, index) for index, sample in self.enumerated_samples if sample.father] + + def get_samples_with_mother(self) -> list[tuple[MipDnaSample, int]]: + return [(sample, index) for index, sample in self.enumerated_samples if sample.mother] diff --git a/cg/services/orders/validation/workflows/mip_dna/models/order.py b/cg/services/orders/validation/workflows/mip_dna/models/order.py new file mode 100644 index 0000000000..9a35c17590 --- /dev/null +++ b/cg/services/orders/validation/workflows/mip_dna/models/order.py @@ -0,0 +1,14 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_case import ExistingCase +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.workflows.mip_dna.models.case import MipDnaCase + +NewCase = Annotated[MipDnaCase, Tag("new")] +OldCase = Annotated[ExistingCase, Tag("existing")] + + +class MipDnaOrder(OrderWithCases): + cases: list[Annotated[NewCase | OldCase, Discriminator(has_internal_id)]] diff --git a/cg/services/orders/validation/workflows/mip_dna/models/sample.py b/cg/services/orders/validation/workflows/mip_dna/models/sample.py new file mode 100644 index 0000000000..8b761a0d3f --- /dev/null +++ b/cg/services/orders/validation/workflows/mip_dna/models/sample.py @@ -0,0 +1,26 @@ +from pydantic import BeforeValidator, Field +from typing_extensions import Annotated + +from cg.models.orders.sample_base import NAME_PATTERN, ControlEnum, SexEnum, StatusEnum +from cg.services.orders.validation.constants import ElutionBuffer, TissueBlockEnum +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_buffer, parse_control + + +class MipDnaSample(Sample): + age_at_sampling: float | None = None + control: Annotated[ControlEnum, BeforeValidator(parse_control)] = ControlEnum.not_control + elution_buffer: Annotated[ElutionBuffer | None, BeforeValidator(parse_buffer)] = None + father: str | None = Field(None, pattern=NAME_PATTERN) + formalin_fixation_time: int | None = None + mother: str | None = Field(None, pattern=NAME_PATTERN) + phenotype_groups: list[str] | None = None + phenotype_terms: list[str] | None = None + post_formalin_fixation_time: int | None = None + require_qc_ok: bool = False + sex: SexEnum + source: str + status: StatusEnum + subject_id: str = Field(pattern=NAME_PATTERN, max_length=128) + tissue_block_size: TissueBlockEnum | None = None + concentration_ng_ul: float | None = None diff --git a/cg/services/orders/validation/workflows/mip_dna/validation_rules.py b/cg/services/orders/validation/workflows/mip_dna/validation_rules.py new file mode 100644 index 0000000000..2f3805af35 --- /dev/null +++ b/cg/services/orders/validation/workflows/mip_dna/validation_rules.py @@ -0,0 +1,76 @@ +from cg.services.orders.validation.rules.case.rules import ( + validate_case_internal_ids_exist, + validate_case_names_available, + validate_case_names_not_repeated, + validate_existing_cases_belong_to_collaboration, + validate_gene_panels_unique, +) +from cg.services.orders.validation.rules.case_sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_application_not_archived, + validate_buffer_required, + validate_buffer_skip_rc_condition, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_existing_samples_belong_to_collaboration, + validate_fathers_are_male, + validate_fathers_in_same_case_as_children, + validate_gene_panels_exist, + validate_mothers_are_female, + validate_mothers_in_same_case_as_children, + validate_not_all_samples_unknown_in_case, + validate_pedigree, + validate_sample_names_different_from_case_names, + validate_sample_names_not_repeated, + validate_samples_exist, + validate_subject_ids_different_from_case_names, + validate_subject_ids_different_from_sample_names, + validate_subject_sex_consistency, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) + +MIP_DNA_CASE_RULES: list[callable] = [ + validate_case_internal_ids_exist, + validate_case_names_available, + validate_case_names_not_repeated, + validate_existing_cases_belong_to_collaboration, + validate_gene_panels_exist, + validate_gene_panels_unique, +] + +MIP_DNA_CASE_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_application_not_archived, + validate_buffer_required, + validate_buffer_skip_rc_condition, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_existing_samples_belong_to_collaboration, + validate_fathers_are_male, + validate_fathers_in_same_case_as_children, + validate_mothers_are_female, + validate_mothers_in_same_case_as_children, + validate_not_all_samples_unknown_in_case, + validate_pedigree, + validate_samples_exist, + validate_sample_names_different_from_case_names, + validate_sample_names_not_repeated, + validate_subject_ids_different_from_case_names, + validate_subject_ids_different_from_sample_names, + validate_subject_sex_consistency, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, + validate_well_position_format, +] diff --git a/cg/services/orders/validation/workflows/mip_rna/constants.py b/cg/services/orders/validation/workflows/mip_rna/constants.py new file mode 100644 index 0000000000..9f1c768ae7 --- /dev/null +++ b/cg/services/orders/validation/workflows/mip_rna/constants.py @@ -0,0 +1,14 @@ +from enum import StrEnum + +from cg.constants import DataDelivery + + +class MipRnaDeliveryType(StrEnum): + ANALYSIS = DataDelivery.ANALYSIS_FILES + ANALYSIS_SCOUT = DataDelivery.ANALYSIS_SCOUT + SCOUT = DataDelivery.SCOUT + FASTQ = DataDelivery.FASTQ + FASTQ_ANALYSIS = DataDelivery.FASTQ_ANALYSIS + FASTQ_SCOUT = DataDelivery.FASTQ_SCOUT + FASTQ_ANALYSIS_SCOUT = DataDelivery.FASTQ_ANALYSIS_SCOUT + NO_DELIVERY = DataDelivery.NO_DELIVERY diff --git a/cg/services/orders/validation/workflows/mip_rna/models/case.py b/cg/services/orders/validation/workflows/mip_rna/models/case.py new file mode 100644 index 0000000000..0214ba1ded --- /dev/null +++ b/cg/services/orders/validation/workflows/mip_rna/models/case.py @@ -0,0 +1,16 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.case import Case +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.workflows.mip_rna.models.sample import MipRnaSample + +NewSample = Annotated[MipRnaSample, Tag("new")] +OldSample = Annotated[ExistingSample, Tag("existing")] + + +class MipRnaCase(Case): + cohorts: list[str] | None = None + synopsis: str | None = None + samples: list[Annotated[NewSample | OldSample, Discriminator(has_internal_id)]] diff --git a/cg/services/orders/validation/workflows/mip_rna/models/order.py b/cg/services/orders/validation/workflows/mip_rna/models/order.py new file mode 100644 index 0000000000..3a009d3234 --- /dev/null +++ b/cg/services/orders/validation/workflows/mip_rna/models/order.py @@ -0,0 +1,16 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_case import ExistingCase +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.workflows.mip_rna.constants import MipRnaDeliveryType +from cg.services.orders.validation.workflows.mip_rna.models.case import MipRnaCase + +NewCase = Annotated[MipRnaCase, Tag("new")] +OldCase = Annotated[ExistingCase, Tag("existing")] + + +class MipRnaOrder(OrderWithCases): + cases: list[Annotated[NewCase | OldCase, Discriminator(has_internal_id)]] + delivery_type: MipRnaDeliveryType diff --git a/cg/services/orders/validation/workflows/mip_rna/models/sample.py b/cg/services/orders/validation/workflows/mip_rna/models/sample.py new file mode 100644 index 0000000000..b0cdb90ec3 --- /dev/null +++ b/cg/services/orders/validation/workflows/mip_rna/models/sample.py @@ -0,0 +1,23 @@ +from pydantic import BeforeValidator, Field +from typing_extensions import Annotated + +from cg.models.orders.sample_base import NAME_PATTERN, ControlEnum, SexEnum +from cg.services.orders.validation.constants import ElutionBuffer, TissueBlockEnum +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_buffer, parse_control + + +class MipRnaSample(Sample): + age_at_sampling: float | None = None + concentration_ng_ul: float | None = None + control: Annotated[ControlEnum, BeforeValidator(parse_control)] = ControlEnum.not_control + elution_buffer: Annotated[ElutionBuffer | None, BeforeValidator(parse_buffer)] = None + formalin_fixation_time: int | None = None + phenotype_groups: list[str] | None = None + phenotype_terms: list[str] | None = None + post_formalin_fixation_time: int | None = None + require_qc_ok: bool = False + sex: SexEnum + source: str + subject_id: str = Field(pattern=NAME_PATTERN, max_length=128) + tissue_block_size: TissueBlockEnum | None = None diff --git a/cg/services/orders/validation/workflows/mip_rna/validation_rules.py b/cg/services/orders/validation/workflows/mip_rna/validation_rules.py new file mode 100644 index 0000000000..d9a11bc1ba --- /dev/null +++ b/cg/services/orders/validation/workflows/mip_rna/validation_rules.py @@ -0,0 +1,58 @@ +from cg.services.orders.validation.rules.case.rules import ( + validate_case_internal_ids_exist, + validate_case_names_available, + validate_case_names_not_repeated, + validate_existing_cases_belong_to_collaboration, +) +from cg.services.orders.validation.rules.case_sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_application_not_archived, + validate_buffer_required, + validate_buffer_skip_rc_condition, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_existing_samples_belong_to_collaboration, + validate_sample_names_different_from_case_names, + validate_sample_names_not_repeated, + validate_samples_exist, + validate_subject_ids_different_from_case_names, + validate_subject_ids_different_from_sample_names, + validate_subject_sex_consistency, + validate_tube_container_name_unique, + validate_volume_interval, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) + +MIP_RNA_CASE_RULES: list[callable] = [ + validate_case_internal_ids_exist, + validate_case_names_available, + validate_case_names_not_repeated, + validate_existing_cases_belong_to_collaboration, +] + +MIP_RNA_CASE_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_application_not_archived, + validate_buffer_required, + validate_buffer_skip_rc_condition, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_existing_samples_belong_to_collaboration, + validate_sample_names_different_from_case_names, + validate_sample_names_not_repeated, + validate_samples_exist, + validate_subject_ids_different_from_case_names, + validate_subject_ids_different_from_sample_names, + validate_subject_sex_consistency, + validate_tube_container_name_unique, + validate_volume_interval, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +] diff --git a/cg/services/orders/validation/workflows/mutant/constants.py b/cg/services/orders/validation/workflows/mutant/constants.py new file mode 100644 index 0000000000..8285b3f19f --- /dev/null +++ b/cg/services/orders/validation/workflows/mutant/constants.py @@ -0,0 +1,67 @@ +from enum import StrEnum + +from cg.constants import DataDelivery + + +class MutantDeliveryType(StrEnum): + FASTQ_ANALYSIS = DataDelivery.FASTQ_ANALYSIS + NO_DELIVERY = DataDelivery.NO_DELIVERY + + +class PreProcessingMethod(StrEnum): + COVID_PRIMER = "Qiagen SARS-CoV-2 Primer Panel" + COVID_SEQUENCING = "COVIDSeq" + OTHER = 'Other (specify in "Comments")' + + +class Primer(StrEnum): + ILLUMINA = "Illumina Artic V3" + NANOPORE = "Nanopore Midnight V1" + + +class Region(StrEnum): + STOCKHOLM = "Stockholm" + UPPSALA = "Uppsala" + SORMLAND = "Sörmland" + OSTERGOTLAND = "Östergötland" + JONKOPINGS_LAN = "Jönköpings län" + KRONOBERG = "Kronoberg" + KALMAR_LAN = "Kalmar län" + GOTLAND = "Gotland" + BLEKINGE = "Blekinge" + SKANE = "Skåne" + HALLAND = "Halland" + VASTRA_GOTALANDSREGIONEN = "Västra Götalandsregionen" + VARMLAND = "Värmland" + OREBRO_LAN = "Örebro län" + VASTMANLAND = "Västmanland" + DALARNA = "Dalarna" + GAVLEBORG = "Gävleborg" + VASTERNORRLAND = "Västernorrland" + JAMTLAND_HARJEDALEN = "Jämtland Härjedalen" + VASTERBOTTEN = "Västerbotten" + NORRBOTTEN = "Norrbotten" + + +class SelectionCriteria(StrEnum): + ALLMAN_OVERVAKNING = "Allmän övervakning" + ALLMAN_OVERVAKNING_OPPENVARD = "Allmän övervakning öppenvård" + ALLMAN_OVERVAKNING_SLUTENVARD = "Allmän övervakning slutenvård" + UTLANDSVISTELSE = "Utlandsvistelse" + RIKTAD_INSAMLING = "Riktad insamling" + UTBROTT = "Utbrott" + VACCINATIONSGENOMBROTT = "Vaccinationsgenombrott" + REINFEKTION = "Reinfektion" + INFORMATION_SAKNAS = "Information saknas" + + +class OriginalLab(StrEnum): + UNILABS_STOCKHOLM = "Unilabs Stockholm" + UNILABS_ESKILSTUNA_LABORATORIUM = "Unilabs Eskilstuna Laboratorium" + NORRLAND_UNIVERSITY_HOSPITAL = "Norrland University Hospital" + LANSSJUKHUSET_SUNDSVALL = "Länssjukhuset Sundsvall" + A05_DIAGNOSTICS = "A05 Diagnostics" + SYNLAB_MEDILAB = "Synlab Medilab" + KAROLINSKA_UNIVERSITY_HOSPITAL_SOLNA = "Karolinska University Hospital Solna" + KAROLINSKA_UNIVERSITY_HOSPITAL_HUDDINGE = "Karolinska University Hospital Huddinge" + LABORATORIEMEDICINSKT_CENTRUM_GOTLAND = "LaboratorieMedicinskt Centrum Gotland" diff --git a/cg/services/orders/validation/workflows/mutant/models/order.py b/cg/services/orders/validation/workflows/mutant/models/order.py new file mode 100644 index 0000000000..5c47295e3e --- /dev/null +++ b/cg/services/orders/validation/workflows/mutant/models/order.py @@ -0,0 +1,12 @@ +from cg.services.orders.validation.models.order_with_samples import OrderWithSamples +from cg.services.orders.validation.workflows.mutant.constants import MutantDeliveryType +from cg.services.orders.validation.workflows.mutant.models.sample import MutantSample + + +class MutantOrder(OrderWithSamples): + delivery_type: MutantDeliveryType + samples: list[MutantSample] + + @property + def enumerated_samples(self) -> enumerate[MutantSample]: + return enumerate(self.samples) diff --git a/cg/services/orders/validation/workflows/mutant/models/sample.py b/cg/services/orders/validation/workflows/mutant/models/sample.py new file mode 100644 index 0000000000..2b0a972feb --- /dev/null +++ b/cg/services/orders/validation/workflows/mutant/models/sample.py @@ -0,0 +1,67 @@ +from datetime import date + +from pydantic import BeforeValidator, PrivateAttr, field_serializer, model_validator +from typing_extensions import Annotated + +from cg.constants.orderforms import ORIGINAL_LAB_ADDRESSES, REGION_CODES +from cg.models.orders.sample_base import ControlEnum, PriorityEnum +from cg.services.orders.validation.constants import ElutionBuffer, ExtractionMethod +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_buffer, parse_control, parse_extraction_method +from cg.services.orders.validation.workflows.mutant.constants import ( + OriginalLab, + PreProcessingMethod, + Primer, + Region, + SelectionCriteria, +) + + +class MutantSample(Sample): + collection_date: date + concentration_sample: float | None = None + control: Annotated[ControlEnum, BeforeValidator(parse_control)] = ControlEnum.not_control + elution_buffer: Annotated[ElutionBuffer, BeforeValidator(parse_buffer)] + extraction_method: Annotated[ExtractionMethod, BeforeValidator(parse_extraction_method)] + _lab_code: str = PrivateAttr(default="SE100 Karolinska") + organism: str + organism_other: str | None = None + original_lab: OriginalLab + original_lab_address: str + pre_processing_method: PreProcessingMethod + primer: Primer + priority: PriorityEnum + quantity: int | None = None + reference_genome: str + region: Region + region_code: str + selection_criteria: SelectionCriteria + _verified_organism: bool | None = PrivateAttr(default=None) + + @model_validator(mode="before") + @classmethod + def set_original_lab_address(cls, data: any) -> any: + if isinstance(data, dict): + is_set = bool(data.get("original_lab_address")) + if not is_set: + data["original_lab_address"] = ORIGINAL_LAB_ADDRESSES[data["original_lab"]] + return data + + @model_validator(mode="before") + @classmethod + def set_region_code(cls, data: any) -> any: + if isinstance(data, dict): + is_set = bool(data.get("region_code")) + if not is_set: + data["region_code"] = REGION_CODES[data["region"]] + return data + + @field_serializer("collection_date") + def serialize_collection_date(self, value: date) -> str: + return value.isoformat() + + def model_dump(self, **kwargs) -> dict: + data = super().model_dump(**kwargs) + data["lab_code"] = self._lab_code + data["verified_organism"] = self._verified_organism + return data diff --git a/cg/services/orders/validation/workflows/mutant/validation_rules.py b/cg/services/orders/validation/workflows/mutant/validation_rules.py new file mode 100644 index 0000000000..5132a29895 --- /dev/null +++ b/cg/services/orders/validation/workflows/mutant/validation_rules.py @@ -0,0 +1,29 @@ +from cg.services.orders.validation.rules.sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_container_name_required, + validate_non_control_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) + +MUTANT_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_container_name_required, + validate_non_control_sample_names_available, + validate_volume_required, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +] diff --git a/cg/services/orders/validation/workflows/order_validation_rules.py b/cg/services/orders/validation/workflows/order_validation_rules.py new file mode 100644 index 0000000000..cb876ebbe6 --- /dev/null +++ b/cg/services/orders/validation/workflows/order_validation_rules.py @@ -0,0 +1,11 @@ +from cg.services.orders.validation.rules.order.rules import ( + validate_customer_can_skip_reception_control, + validate_customer_exists, + validate_user_belongs_to_customer, +) + +ORDER_RULES: list[callable] = [ + validate_customer_can_skip_reception_control, + validate_customer_exists, + validate_user_belongs_to_customer, +] diff --git a/cg/services/orders/validation/workflows/pacbio_long_read/constants.py b/cg/services/orders/validation/workflows/pacbio_long_read/constants.py new file mode 100644 index 0000000000..c6076fc5de --- /dev/null +++ b/cg/services/orders/validation/workflows/pacbio_long_read/constants.py @@ -0,0 +1,8 @@ +from enum import StrEnum + +from cg.constants import DataDelivery + + +class PacbioDeliveryType(StrEnum): + BAM = DataDelivery.BAM + NO_DELIVERY = DataDelivery.NO_DELIVERY diff --git a/cg/services/orders/validation/workflows/pacbio_long_read/models/order.py b/cg/services/orders/validation/workflows/pacbio_long_read/models/order.py new file mode 100644 index 0000000000..21139caa4e --- /dev/null +++ b/cg/services/orders/validation/workflows/pacbio_long_read/models/order.py @@ -0,0 +1,12 @@ +from cg.services.orders.validation.models.order_with_samples import OrderWithSamples +from cg.services.orders.validation.workflows.pacbio_long_read.constants import PacbioDeliveryType +from cg.services.orders.validation.workflows.pacbio_long_read.models.sample import PacbioSample + + +class PacbioOrder(OrderWithSamples): + delivery_type: PacbioDeliveryType + samples: list[PacbioSample] + + @property + def enumerated_samples(self) -> enumerate[PacbioSample]: + return enumerate(self.samples) diff --git a/cg/services/orders/validation/workflows/pacbio_long_read/models/sample.py b/cg/services/orders/validation/workflows/pacbio_long_read/models/sample.py new file mode 100644 index 0000000000..a147315f88 --- /dev/null +++ b/cg/services/orders/validation/workflows/pacbio_long_read/models/sample.py @@ -0,0 +1,15 @@ +from pydantic import Field + +from cg.models.orders.sample_base import NAME_PATTERN, PriorityEnum, SexEnum +from cg.services.orders.validation.models.sample import Sample + + +class PacbioSample(Sample): + concentration_ng_ul: float | None = None + priority: PriorityEnum + quantity: int | None = None + require_qc_ok: bool = False + sex: SexEnum + source: str + subject_id: str | None = Field(pattern=NAME_PATTERN, max_length=128) + tumour: bool = False diff --git a/cg/services/orders/validation/workflows/pacbio_long_read/validation_rules.py b/cg/services/orders/validation/workflows/pacbio_long_read/validation_rules.py new file mode 100644 index 0000000000..264a228aef --- /dev/null +++ b/cg/services/orders/validation/workflows/pacbio_long_read/validation_rules.py @@ -0,0 +1,27 @@ +from cg.services.orders.validation.rules.sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_container_name_required, + validate_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) + +PACBIO_LONG_READ_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_container_name_required, + validate_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_required, + validate_wells_contain_at_most_one_sample, + validate_well_position_format, + validate_well_positions_required, +] diff --git a/cg/services/orders/validation/workflows/rml/constants.py b/cg/services/orders/validation/workflows/rml/constants.py new file mode 100644 index 0000000000..316cd64e96 --- /dev/null +++ b/cg/services/orders/validation/workflows/rml/constants.py @@ -0,0 +1,8 @@ +from enum import StrEnum + +from cg.constants.constants import DataDelivery + + +class RmlDeliveryType(StrEnum): + FASTQ = DataDelivery.FASTQ + NO_DELIVERY = DataDelivery.NO_DELIVERY diff --git a/cg/services/orders/validation/workflows/rml/models/order.py b/cg/services/orders/validation/workflows/rml/models/order.py new file mode 100644 index 0000000000..96a14dbf43 --- /dev/null +++ b/cg/services/orders/validation/workflows/rml/models/order.py @@ -0,0 +1,34 @@ +from cg.services.orders.validation.models.order_with_samples import OrderWithSamples +from cg.services.orders.validation.workflows.rml.constants import RmlDeliveryType +from cg.services.orders.validation.workflows.rml.models.sample import RmlSample + + +class RmlOrder(OrderWithSamples): + delivery_type: RmlDeliveryType + samples: list[RmlSample] + + @property + def enumerated_samples(self) -> enumerate[RmlSample]: + return enumerate(self.samples) + + @property + def pools(self) -> dict[str, list[RmlSample]]: + """Return a dictionary matching pool names and their respective samples.""" + pools: dict[str, list[RmlSample]] = {} + for sample in self.samples: + if sample.pool not in pools: + pools[sample.pool] = [sample] + else: + pools[sample.pool].append(sample) + return pools + + @property + def enumerated_pools(self) -> dict[str, list[tuple[int, RmlSample]]]: + """Return the pool dictionary with indexes for the samples to map them to validation errors.""" + pools: dict[str, list[tuple[int, RmlSample]]] = {} + for sample_index, sample in self.enumerated_samples: + if sample.pool not in pools: + pools[sample.pool] = [(sample_index, sample)] + else: + pools[sample.pool].append((sample_index, sample)) + return pools diff --git a/cg/services/orders/validation/workflows/rml/models/sample.py b/cg/services/orders/validation/workflows/rml/models/sample.py new file mode 100644 index 0000000000..9b0676b502 --- /dev/null +++ b/cg/services/orders/validation/workflows/rml/models/sample.py @@ -0,0 +1,39 @@ +import logging + +from pydantic import BeforeValidator, Field, model_validator +from typing_extensions import Annotated + +from cg.models.orders.sample_base import ContainerEnum, ControlEnum, PriorityEnum +from cg.services.orders.validation.constants import IndexEnum +from cg.services.orders.validation.index_sequences import INDEX_SEQUENCES +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_control + +LOG = logging.getLogger(__name__) + + +class RmlSample(Sample): + concentration: float + concentration_sample: float | None = None + container: ContainerEnum | None = Field(default=None, exclude=True) + control: Annotated[ControlEnum, BeforeValidator(parse_control)] = ControlEnum.not_control + index: IndexEnum + index_number: int | None = None + index_sequence: str | None = None + pool: str + priority: PriorityEnum + rml_plate_name: str | None = None + volume: int + well_position_rml: str | None = None + + @model_validator(mode="after") + def set_default_index_sequence(self) -> "RmlSample": + """Set a default index_sequence from the index and index_number.""" + if not self.index_sequence and (self.index and self.index_number): + try: + self.index_sequence = INDEX_SEQUENCES[self.index][self.index_number - 1] + except Exception: + LOG.warning( + f"No index sequence set and no suitable sequence found for index {self.index}, number {self.index_number}" + ) + return self diff --git a/cg/services/orders/validation/workflows/rml/validation_rules.py b/cg/services/orders/validation/workflows/rml/validation_rules.py new file mode 100644 index 0000000000..e229cf2da7 --- /dev/null +++ b/cg/services/orders/validation/workflows/rml/validation_rules.py @@ -0,0 +1,35 @@ +from cg.services.orders.validation.rules.sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_index_number_in_range, + validate_index_number_required, + validate_index_sequence_mismatch, + validate_index_sequence_required, + validate_pools_contain_one_application, + validate_pools_contain_one_priority, + validate_sample_names_available, + validate_sample_names_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_rml_format, + validate_well_positions_required_rml, +) + +RML_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_index_number_required, + validate_index_number_in_range, + validate_index_sequence_required, + validate_index_sequence_mismatch, + validate_pools_contain_one_application, + validate_pools_contain_one_priority, + validate_sample_names_available, + validate_sample_names_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_rml_format, + validate_well_positions_required_rml, +] diff --git a/cg/services/orders/validation/workflows/rna_fusion/constants.py b/cg/services/orders/validation/workflows/rna_fusion/constants.py new file mode 100644 index 0000000000..e7b6225186 --- /dev/null +++ b/cg/services/orders/validation/workflows/rna_fusion/constants.py @@ -0,0 +1,14 @@ +from enum import StrEnum + +from cg.constants.constants import DataDelivery + + +class RnaFusionDeliveryType(StrEnum): + ANALYSIS_FILES = DataDelivery.ANALYSIS_FILES + ANALYSIS_SCOUT = DataDelivery.ANALYSIS_SCOUT + SCOUT = DataDelivery.SCOUT + FASTQ = DataDelivery.FASTQ + FASTQ_ANALYSIS = DataDelivery.FASTQ_ANALYSIS + FASTQ_SCOUT = DataDelivery.FASTQ_SCOUT + FASTQ_ANALYSIS_SCOUT = DataDelivery.FASTQ_ANALYSIS_SCOUT + NO_DELIVERY = DataDelivery.NO_DELIVERY diff --git a/cg/services/orders/validation/workflows/rna_fusion/models/case.py b/cg/services/orders/validation/workflows/rna_fusion/models/case.py new file mode 100644 index 0000000000..d750c4a24a --- /dev/null +++ b/cg/services/orders/validation/workflows/rna_fusion/models/case.py @@ -0,0 +1,16 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.case import Case +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.workflows.rna_fusion.models.sample import RnaFusionSample + +NewSample = Annotated[RnaFusionSample, Tag("new")] +OldSample = Annotated[ExistingSample, Tag("existing")] + + +class RnaFusionCase(Case): + cohorts: list[str] | None = None + synopsis: str | None = None + samples: list[Annotated[NewSample | OldSample, Discriminator(has_internal_id)]] diff --git a/cg/services/orders/validation/workflows/rna_fusion/models/order.py b/cg/services/orders/validation/workflows/rna_fusion/models/order.py new file mode 100644 index 0000000000..2fe61b6e92 --- /dev/null +++ b/cg/services/orders/validation/workflows/rna_fusion/models/order.py @@ -0,0 +1,16 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_case import ExistingCase +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.workflows.rna_fusion.constants import RnaFusionDeliveryType +from cg.services.orders.validation.workflows.rna_fusion.models.case import RnaFusionCase + +NewCase = Annotated[RnaFusionCase, Tag("new")] +OldCase = Annotated[ExistingCase, Tag("existing")] + + +class RnaFusionOrder(OrderWithCases): + cases: list[Annotated[NewCase | OldCase, Discriminator(has_internal_id)]] + delivery_type: RnaFusionDeliveryType diff --git a/cg/services/orders/validation/workflows/rna_fusion/models/sample.py b/cg/services/orders/validation/workflows/rna_fusion/models/sample.py new file mode 100644 index 0000000000..fb08130ae7 --- /dev/null +++ b/cg/services/orders/validation/workflows/rna_fusion/models/sample.py @@ -0,0 +1,23 @@ +from pydantic import BeforeValidator, Field +from typing_extensions import Annotated + +from cg.models.orders.sample_base import NAME_PATTERN, ControlEnum, SexEnum +from cg.services.orders.validation.constants import ElutionBuffer, TissueBlockEnum +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_buffer, parse_control + + +class RnaFusionSample(Sample): + age_at_sampling: float | None = None + concentration_ng_ul: float | None = None + control: Annotated[ControlEnum, BeforeValidator(parse_control)] = ControlEnum.not_control + elution_buffer: Annotated[ElutionBuffer | None, BeforeValidator(parse_buffer)] = None + formalin_fixation_time: int | None = None + phenotype_groups: list[str] | None = None + phenotype_terms: list[str] | None = None + post_formalin_fixation_time: int | None = None + require_qc_ok: bool = False + sex: SexEnum + source: str + subject_id: str = Field(pattern=NAME_PATTERN, min_length=1, max_length=128) + tissue_block_size: TissueBlockEnum | None = None diff --git a/cg/services/orders/validation/workflows/rna_fusion/validation_rules.py b/cg/services/orders/validation/workflows/rna_fusion/validation_rules.py new file mode 100644 index 0000000000..9a23c6377a --- /dev/null +++ b/cg/services/orders/validation/workflows/rna_fusion/validation_rules.py @@ -0,0 +1,62 @@ +from cg.services.orders.validation.rules.case.rules import ( + validate_case_internal_ids_exist, + validate_case_names_available, + validate_case_names_not_repeated, + validate_existing_cases_belong_to_collaboration, + validate_one_sample_per_case, +) +from cg.services.orders.validation.rules.case_sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_application_not_archived, + validate_buffer_required, + validate_buffer_skip_rc_condition, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_existing_samples_belong_to_collaboration, + validate_sample_names_different_from_case_names, + validate_sample_names_not_repeated, + validate_samples_exist, + validate_subject_ids_different_from_case_names, + validate_subject_ids_different_from_sample_names, + validate_subject_sex_consistency, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) + +RNAFUSION_CASE_RULES: list[callable] = [ + validate_case_internal_ids_exist, + validate_case_names_available, + validate_case_names_not_repeated, + validate_existing_cases_belong_to_collaboration, + validate_one_sample_per_case, +] + +RNAFUSION_CASE_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_application_not_archived, + validate_buffer_required, + validate_buffer_skip_rc_condition, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_existing_samples_belong_to_collaboration, + validate_samples_exist, + validate_sample_names_different_from_case_names, + validate_sample_names_not_repeated, + validate_subject_ids_different_from_case_names, + validate_subject_ids_different_from_sample_names, + validate_subject_sex_consistency, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +] diff --git a/cg/services/orders/validation/workflows/taxprofiler/constants.py b/cg/services/orders/validation/workflows/taxprofiler/constants.py new file mode 100644 index 0000000000..c10362c704 --- /dev/null +++ b/cg/services/orders/validation/workflows/taxprofiler/constants.py @@ -0,0 +1,9 @@ +from enum import StrEnum + +from cg.constants import DataDelivery + + +class TaxprofilerDeliveryType(StrEnum): + ANALYSIS = DataDelivery.ANALYSIS_FILES + FASTQ_ANALYSIS = DataDelivery.FASTQ_ANALYSIS + NO_DELIVERY = DataDelivery.NO_DELIVERY diff --git a/cg/services/orders/validation/workflows/taxprofiler/models/order.py b/cg/services/orders/validation/workflows/taxprofiler/models/order.py new file mode 100644 index 0000000000..26a425c647 --- /dev/null +++ b/cg/services/orders/validation/workflows/taxprofiler/models/order.py @@ -0,0 +1,12 @@ +from cg.services.orders.validation.models.order_with_samples import OrderWithSamples +from cg.services.orders.validation.workflows.taxprofiler.constants import TaxprofilerDeliveryType +from cg.services.orders.validation.workflows.taxprofiler.models.sample import TaxprofilerSample + + +class TaxprofilerOrder(OrderWithSamples): + delivery_type: TaxprofilerDeliveryType + samples: list[TaxprofilerSample] + + @property + def enumerated_samples(self) -> enumerate[TaxprofilerSample]: + return enumerate(self.samples) diff --git a/cg/services/orders/validation/workflows/taxprofiler/models/sample.py b/cg/services/orders/validation/workflows/taxprofiler/models/sample.py new file mode 100644 index 0000000000..dc281c2d85 --- /dev/null +++ b/cg/services/orders/validation/workflows/taxprofiler/models/sample.py @@ -0,0 +1,17 @@ +from pydantic import BeforeValidator +from typing_extensions import Annotated + +from cg.models.orders.sample_base import ControlEnum, PriorityEnum +from cg.services.orders.validation.constants import ElutionBuffer +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_buffer, parse_control + + +class TaxprofilerSample(Sample): + concentration_ng_ul: float | None = None + control: Annotated[ControlEnum, BeforeValidator(parse_control)] = ControlEnum.not_control + elution_buffer: Annotated[ElutionBuffer, BeforeValidator(parse_buffer)] + priority: PriorityEnum + quantity: int | None = None + require_qc_ok: bool = False + source: str diff --git a/cg/services/orders/validation/workflows/taxprofiler/validation_rules.py b/cg/services/orders/validation/workflows/taxprofiler/validation_rules.py new file mode 100644 index 0000000000..790bdb8568 --- /dev/null +++ b/cg/services/orders/validation/workflows/taxprofiler/validation_rules.py @@ -0,0 +1,29 @@ +from cg.services.orders.validation.rules.sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_container_name_required, + validate_non_control_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) + +TAXPROFILER_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_container_name_required, + validate_non_control_sample_names_available, + validate_sample_names_unique, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +] diff --git a/cg/services/orders/validation/workflows/tomte/constants.py b/cg/services/orders/validation/workflows/tomte/constants.py new file mode 100644 index 0000000000..597536e16e --- /dev/null +++ b/cg/services/orders/validation/workflows/tomte/constants.py @@ -0,0 +1,10 @@ +from enum import StrEnum + +from cg.constants.constants import DataDelivery + + +class TomteDeliveryType(StrEnum): + ANALYSIS_FILES = DataDelivery.ANALYSIS_FILES + FASTQ = DataDelivery.FASTQ + FASTQ_ANALYSIS = DataDelivery.FASTQ_ANALYSIS + NO_DELIVERY = DataDelivery.NO_DELIVERY diff --git a/cg/services/orders/validation/workflows/tomte/models/case.py b/cg/services/orders/validation/workflows/tomte/models/case.py new file mode 100644 index 0000000000..ab4504e4ea --- /dev/null +++ b/cg/services/orders/validation/workflows/tomte/models/case.py @@ -0,0 +1,23 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.case import Case +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.workflows.tomte.models.sample import TomteSample + +NewSample = Annotated[TomteSample, Tag("new")] +OldSample = Annotated[ExistingSample, Tag("existing")] + + +class TomteCase(Case): + cohorts: list[str] | None = None + panels: list[str] + synopsis: str | None = None + samples: list[Annotated[NewSample | OldSample, Discriminator(has_internal_id)]] + + def get_samples_with_father(self) -> list[tuple[TomteSample, int]]: + return [(sample, index) for index, sample in self.enumerated_samples if sample.father] + + def get_samples_with_mother(self) -> list[tuple[TomteSample, int]]: + return [(sample, index) for index, sample in self.enumerated_samples if sample.mother] diff --git a/cg/services/orders/validation/workflows/tomte/models/order.py b/cg/services/orders/validation/workflows/tomte/models/order.py new file mode 100644 index 0000000000..e046b27e74 --- /dev/null +++ b/cg/services/orders/validation/workflows/tomte/models/order.py @@ -0,0 +1,16 @@ +from pydantic import Discriminator, Tag +from typing_extensions import Annotated + +from cg.services.orders.validation.models.discriminators import has_internal_id +from cg.services.orders.validation.models.existing_case import ExistingCase +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.workflows.tomte.constants import TomteDeliveryType +from cg.services.orders.validation.workflows.tomte.models.case import TomteCase + +NewCase = Annotated[TomteCase, Tag("new")] +OldCase = Annotated[ExistingCase, Tag("existing")] + + +class TomteOrder(OrderWithCases): + cases: list[Annotated[NewCase | OldCase, Discriminator(has_internal_id)]] + delivery_type: TomteDeliveryType diff --git a/cg/services/orders/validation/workflows/tomte/models/sample.py b/cg/services/orders/validation/workflows/tomte/models/sample.py new file mode 100644 index 0000000000..b70941cf95 --- /dev/null +++ b/cg/services/orders/validation/workflows/tomte/models/sample.py @@ -0,0 +1,28 @@ +from pydantic import BeforeValidator, Field +from typing_extensions import Annotated + +from cg.constants.constants import GenomeVersion +from cg.models.orders.sample_base import NAME_PATTERN, ControlEnum, SexEnum, StatusEnum +from cg.services.orders.validation.constants import ElutionBuffer, TissueBlockEnum +from cg.services.orders.validation.models.sample import Sample +from cg.services.orders.validation.utils import parse_buffer, parse_control + + +class TomteSample(Sample): + age_at_sampling: float | None = None + control: Annotated[ControlEnum, BeforeValidator(parse_control)] = ControlEnum.not_control + elution_buffer: Annotated[ElutionBuffer | None, BeforeValidator(parse_buffer)] = None + father: str | None = Field(None, pattern=NAME_PATTERN) + formalin_fixation_time: int | None = None + mother: str | None = Field(None, pattern=NAME_PATTERN) + phenotype_groups: list[str] | None = None + phenotype_terms: list[str] | None = None + post_formalin_fixation_time: int | None = None + reference_genome: GenomeVersion + require_qc_ok: bool = False + sex: SexEnum + source: str + status: StatusEnum + subject_id: str = Field(pattern=NAME_PATTERN, min_length=1, max_length=128) + tissue_block_size: TissueBlockEnum | None = None + concentration_ng_ul: float | None = None diff --git a/cg/services/orders/validation/workflows/tomte/validation_rules.py b/cg/services/orders/validation/workflows/tomte/validation_rules.py new file mode 100644 index 0000000000..a6e46ed1dc --- /dev/null +++ b/cg/services/orders/validation/workflows/tomte/validation_rules.py @@ -0,0 +1,74 @@ +from cg.services.orders.validation.rules.case.rules import ( + validate_case_internal_ids_exist, + validate_case_names_available, + validate_case_names_not_repeated, + validate_existing_cases_belong_to_collaboration, + validate_gene_panels_unique, +) +from cg.services.orders.validation.rules.case_sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_application_not_archived, + validate_buffer_required, + validate_buffer_skip_rc_condition, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_existing_samples_belong_to_collaboration, + validate_fathers_are_male, + validate_fathers_in_same_case_as_children, + validate_gene_panels_exist, + validate_mothers_are_female, + validate_mothers_in_same_case_as_children, + validate_pedigree, + validate_sample_names_different_from_case_names, + validate_sample_names_not_repeated, + validate_samples_exist, + validate_subject_ids_different_from_case_names, + validate_subject_ids_different_from_sample_names, + validate_subject_sex_consistency, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) + +TOMTE_CASE_RULES: list[callable] = [ + validate_case_internal_ids_exist, + validate_case_names_available, + validate_case_names_not_repeated, + validate_existing_cases_belong_to_collaboration, + validate_gene_panels_exist, + validate_gene_panels_unique, +] + +TOMTE_CASE_SAMPLE_RULES: list[callable] = [ + validate_application_compatibility, + validate_application_exists, + validate_application_not_archived, + validate_buffer_required, + validate_buffer_skip_rc_condition, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_existing_samples_belong_to_collaboration, + validate_fathers_are_male, + validate_fathers_in_same_case_as_children, + validate_mothers_are_female, + validate_mothers_in_same_case_as_children, + validate_pedigree, + validate_samples_exist, + validate_sample_names_different_from_case_names, + validate_sample_names_not_repeated, + validate_subject_ids_different_from_case_names, + validate_subject_ids_different_from_sample_names, + validate_subject_sex_consistency, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +] diff --git a/cg/store/crud/create.py b/cg/store/crud/create.py index e26140f995..418c901eac 100644 --- a/cg/store/crud/create.py +++ b/cg/store/crud/create.py @@ -8,7 +8,6 @@ from cg.constants import DataDelivery, Priority, Workflow from cg.constants.archiving import PDC_ARCHIVE_LOCATION from cg.models.orders.constants import OrderType -from cg.models.orders.order import OrderIn from cg.services.illumina.data_transfer.models import ( IlluminaFlowCellDTO, IlluminaSampleSequencingMetricsDTO, @@ -400,17 +399,14 @@ def add_organism( **kwargs, ) - def add_order(self, order_data: OrderIn): - customer: Customer = self.get_customer_by_internal_id(order_data.customer) - workflow: str = order_data.samples[0].data_analysis + def add_order(self, customer: Customer, ticket_id: int, **kwargs) -> Order: + """Build a new Order record.""" order = Order( - customer_id=customer.id, - ticket_id=order_data.ticket, - workflow=workflow, + customer=customer, + order_date=datetime.now(), + ticket_id=ticket_id, + **kwargs, ) - session: Session = get_session() - session.add(order) - session.commit() return order @staticmethod diff --git a/cg/store/crud/read.py b/cg/store/crud/read.py index f8e024946a..c062bb77ec 100644 --- a/cg/store/crud/read.py +++ b/cg/store/crud/read.py @@ -17,6 +17,7 @@ from cg.constants.sequencing import DNA_PREP_CATEGORIES, SeqLibraryPrepCategory from cg.exc import CaseNotFoundError, CgError, OrderNotFoundError, SampleNotFoundError from cg.models.orders.constants import OrderType +from cg.models.orders.sample_base import SexEnum from cg.server.dto.samples.collaborator_samples_request import CollaboratorSamplesRequest from cg.services.orders.order_service.models import OrderQueryParams from cg.store.base import BaseHandler @@ -947,7 +948,7 @@ def get_organism_by_internal_id(self, internal_id: str) -> Organism: internal_id=internal_id, ).first() - def get_all_organisms(self) -> list[Organism]: + def get_all_organisms(self) -> Query[Organism]: """Return all organisms ordered by organism internal id.""" return self._get_query(table=Organism).order_by(Organism.internal_id) @@ -967,7 +968,7 @@ def get_panels(self) -> list[Panel]: """Returns all panels.""" return self._get_query(table=Panel).order_by(Panel.abbrev).all() - def get_user_by_email(self, email: str) -> User: + def get_user_by_email(self, email: str) -> User | None: """Return a user by email from the database.""" return apply_user_filter( users=self._get_query(table=User), @@ -975,6 +976,23 @@ def get_user_by_email(self, email: str) -> User: filter_functions=[UserFilter.BY_EMAIL], ).first() + def is_user_associated_with_customer(self, user_id: int, customer_internal_id: str) -> bool: + user: User | None = apply_user_filter( + users=self._get_query(table=User), + user_id=user_id, + customer_internal_id=customer_internal_id, + filter_functions=[UserFilter.BY_ID, UserFilter.BY_CUSTOMER_INTERNAL_ID], + ).first() + return bool(user) + + def is_customer_trusted(self, customer_internal_id: str) -> bool: + customer: Customer | None = self.get_customer_by_internal_id(customer_internal_id) + return bool(customer and customer.is_trusted) + + def customer_exists(self, customer_internal_id: str) -> bool: + customer: Customer | None = self.get_customer_by_internal_id(customer_internal_id) + return bool(customer) + def get_samples_to_receive(self, external: bool = False) -> list[Sample]: """Return samples to receive.""" records: Query = self._get_join_sample_application_version_query() @@ -1569,6 +1587,13 @@ def get_cases_for_sequencing_qc(self) -> list[Case]: ], ).all() + def is_application_archived(self, application_tag: str) -> bool: + application: Application | None = self.get_application_by_tag(application_tag) + return application and application.is_archived + + def does_gene_panel_exist(self, abbreviation: str) -> bool: + return bool(self.get_panel_by_abbreviation(abbreviation)) + def get_pac_bio_smrt_cell_by_internal_id(self, internal_id: str) -> PacbioSMRTCell: return apply_pac_bio_smrt_cell_filters( filter_functions=[PacBioSMRTCellFilter.BY_INTERNAL_ID], @@ -1587,6 +1612,23 @@ def get_case_ids_for_samples(self, sample_ids: list[int]) -> list[str]: 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 + def _get_related_samples_query( self, sample: Sample, diff --git a/cg/store/filters/status_user_filters.py b/cg/store/filters/status_user_filters.py index 61111d6598..6f832f33d5 100644 --- a/cg/store/filters/status_user_filters.py +++ b/cg/store/filters/status_user_filters.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Query -from cg.store.models import User +from cg.store.models import Customer, User def filter_user_by_email(users: Query, email: str, **kwargs) -> Query: @@ -11,21 +11,35 @@ def filter_user_by_email(users: Query, email: str, **kwargs) -> Query: return users.filter(User.email == email) +def filter_user_by_id(users: Query, user_id: int, **kwargs) -> Query: + return users.filter(User.id == user_id) + + +def filter_user_by_customer_internal_id(users: Query, customer_internal_id: str, **kwargs) -> Query: + return users.join(User.customers).filter(Customer.internal_id == customer_internal_id) + + class UserFilter(Enum): """Define User filter functions.""" BY_EMAIL: Callable = filter_user_by_email + BY_ID: Callable = filter_user_by_id + BY_CUSTOMER_INTERNAL_ID: Callable = filter_user_by_customer_internal_id def apply_user_filter( users: Query, filter_functions: list[Callable], email: str | None = None, + user_id: int | None = None, + customer_internal_id: str | None = None, ) -> Query: """Apply filtering functions and return filtered results.""" for filter_function in filter_functions: users: Query = filter_function( users=users, email=email, + user_id=user_id, + customer_internal_id=customer_internal_id, ) return users diff --git a/cg/store/models.py b/cg/store/models.py index f2fb0fccc6..377a034e9d 100644 --- a/cg/store/models.py +++ b/cg/store/models.py @@ -815,6 +815,11 @@ class Sample(Base, PriorityMixin): back_populates="sample", cascade="all, delete" ) + def __init__(self, **kwargs): + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + def __str__(self) -> str: return f"{self.internal_id} ({self.name})" diff --git a/cg/store/store.py b/cg/store/store.py index 505924f37f..dedfaf1dc8 100644 --- a/cg/store/store.py +++ b/cg/store/store.py @@ -28,10 +28,18 @@ def commit_to_store(self): """Commit pending changes to the store.""" self.session.commit() + def add_item_to_store(self, item: ModelBase): + """Add an item to the store.""" + self.session.add(item) + def add_multiple_items_to_store(self, items: list[ModelBase]): """Add multiple items to the store.""" self.session.add_all(items) + def no_autoflush_context(self): + """Return a context manager that disables autoflush for the session.""" + return self.session.no_autoflush + def rollback(self): """Rollback any pending change to the store.""" self.session.rollback() diff --git a/tests/apps/orderform/test_excel_orderform_parser.py b/tests/apps/orderform/test_excel_orderform_parser.py index 348c889828..ad54a5ff5e 100644 --- a/tests/apps/orderform/test_excel_orderform_parser.py +++ b/tests/apps/orderform/test_excel_orderform_parser.py @@ -1,8 +1,8 @@ from pathlib import Path from cg.apps.orderform.excel_orderform_parser import ExcelOrderformParser +from cg.models.orders.constants import OrderType from cg.models.orders.excel_sample import ExcelSample -from cg.models.orders.order import OrderType from cg.models.orders.orderform_schema import Orderform diff --git a/tests/conftest.py b/tests/conftest.py index 8174ffccf0..34bcdbe368 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -61,7 +61,7 @@ from cg.services.deliver_files.rsync.service import DeliveryRsyncService from cg.services.illumina.backup.encrypt_service import IlluminaRunEncryptionService from cg.services.illumina.data_transfer.data_transfer_service import IlluminaDataTransferService -from cg.services.orders.store_order_services.constants import MAF_ORDER_ID +from cg.services.orders.storing.constants import MAF_ORDER_ID from cg.store.database import create_all_tables, drop_all_tables, initialize_database from cg.store.models import ( Application, @@ -121,9 +121,12 @@ "tests.fixture_plugins.observations_fixtures.observations_api_fixtures", "tests.fixture_plugins.observations_fixtures.observations_input_files_fixtures", "tests.fixture_plugins.orders_fixtures.order_form_fixtures", - "tests.fixture_plugins.orders_fixtures.order_store_service_fixtures", "tests.fixture_plugins.orders_fixtures.order_to_submit_fixtures", - "tests.fixture_plugins.orders_fixtures.status_data_fixtures", + "tests.fixture_plugins.orders_fixtures.order_fixtures", + "tests.fixture_plugins.orders_fixtures.path_fixtures", + "tests.fixture_plugins.orders_fixtures.services_fixtures", + "tests.fixture_plugins.orders_fixtures.store_fixtures", + "tests.fixture_plugins.orders_fixtures.store_service_fixtures", "tests.fixture_plugins.pacbio_fixtures.context_fixtures", "tests.fixture_plugins.pacbio_fixtures.dto_fixtures", "tests.fixture_plugins.pacbio_fixtures.file_data_fixtures", @@ -659,9 +662,14 @@ def madeline_api(madeline_output: Path) -> MockMadelineAPI: @pytest.fixture(scope="session") -def ticket_id() -> str: +def ticket_id_as_int() -> int: + return 123456 + + +@pytest.fixture(scope="session") +def ticket_id(ticket_id_as_int: int) -> str: """Return a ticket number for testing.""" - return "123456" + return str(ticket_id_as_int) @pytest.fixture @@ -738,12 +746,6 @@ def apps_dir(fixtures_dir: Path) -> Path: return Path(fixtures_dir, "apps") -@pytest.fixture(scope="session") -def cgweb_orders_dir(fixtures_dir: Path) -> Path: - """Return the path to the cgweb_orders dir.""" - return Path(fixtures_dir, "cgweb_orders") - - @pytest.fixture(scope="session") def data_dir(fixtures_dir: Path) -> Path: """Return the path to the data dir.""" @@ -1612,7 +1614,7 @@ def base_store( ), store.add_application( tag=apptag_rna, - prep_category="tgs", + prep_category="wts", description="RNA seq, poly-A based priming", percent_kth=80, percent_reads_guaranteed=75, @@ -1657,10 +1659,10 @@ def base_store( organism = store.add_organism("C. jejuni", "C. jejuni") store.session.add(organism) - store.session.commit() order: Order = Order(customer_id=1, id=MAF_ORDER_ID, ticket_id="100000000") store.add_multiple_items_to_store([order]) + store.session.commit() yield store @@ -2400,6 +2402,16 @@ def store_with_users(store: Store, helpers: StoreHelpers) -> Generator[Store, No yield store +@pytest.fixture +def customer_without_users(store_with_users: Store): + return store_with_users.add_customer( + internal_id="internal_id", + name="some_name", + invoice_address="some_address", + invoice_reference="some_reference", + ) + + @pytest.fixture def store_with_cases_and_customers( store: Store, helpers: StoreHelpers @@ -4102,15 +4114,6 @@ def taxprofiler_mock_analysis_finish( ) -@pytest.fixture(scope="function") -def taxprofiler_config(taxprofiler_dir: Path, taxprofiler_case_id: str) -> None: - """Create CSV sample sheet file for testing.""" - Path.mkdir(Path(taxprofiler_dir, taxprofiler_case_id), parents=True, exist_ok=True) - Path(taxprofiler_dir, taxprofiler_case_id, f"{taxprofiler_case_id}_samplesheet").with_suffix( - FileExtensions.CSV - ).touch(exist_ok=True) - - @pytest.fixture(scope="function") def taxprofiler_deliverables_response_data( create_multiqc_html_file, diff --git a/tests/fixture_plugins/orders_fixtures/order_fixtures.py b/tests/fixture_plugins/orders_fixtures/order_fixtures.py new file mode 100644 index 0000000000..3b290c0aa1 --- /dev/null +++ b/tests/fixture_plugins/orders_fixtures/order_fixtures.py @@ -0,0 +1,137 @@ +"""Fixtures for orders parsed into their respective models.""" + +import pytest + +from cg.services.orders.validation.workflows.balsamic.models.order import BalsamicOrder +from cg.services.orders.validation.workflows.fastq.models.order import FastqOrder +from cg.services.orders.validation.workflows.fluffy.models.order import FluffyOrder +from cg.services.orders.validation.workflows.metagenome.models.order import MetagenomeOrder +from cg.services.orders.validation.workflows.microbial_fastq.models.order import MicrobialFastqOrder +from cg.services.orders.validation.workflows.microsalt.models.order import MicrosaltOrder +from cg.services.orders.validation.workflows.mip_dna.models.order import MipDnaOrder +from cg.services.orders.validation.workflows.mip_rna.models.order import MipRnaOrder +from cg.services.orders.validation.workflows.mutant.models.order import MutantOrder +from cg.services.orders.validation.workflows.pacbio_long_read.models.order import PacbioOrder +from cg.services.orders.validation.workflows.rml.models.order import RmlOrder +from cg.services.orders.validation.workflows.rna_fusion.models.order import RnaFusionOrder +from cg.services.orders.validation.workflows.taxprofiler.models.order import TaxprofilerOrder +from cg.services.orders.validation.workflows.tomte.models.order import TomteOrder + + +@pytest.fixture +def balsamic_order(balsamic_order_to_submit: dict) -> BalsamicOrder: + balsamic_order_to_submit["user_id"] = 1 + balsamic_order = BalsamicOrder.model_validate(balsamic_order_to_submit) + balsamic_order._generated_ticket_id = 123456 + for case_index, sample_index, sample in balsamic_order.enumerated_new_samples: + sample._generated_lims_id = f"ACC{case_index}-{sample_index}" + return balsamic_order + + +@pytest.fixture +def fastq_order(fastq_order_to_submit: dict) -> FastqOrder: + fastq_order = FastqOrder.model_validate(fastq_order_to_submit) + fastq_order._generated_ticket_id = 123456 + return fastq_order + + +@pytest.fixture +def fluffy_order(fluffy_order_to_submit: dict, ticket_id_as_int: int) -> FluffyOrder: + """Parse Fluffy order example.""" + fluffy_order = FluffyOrder.model_validate(fluffy_order_to_submit) + fluffy_order._generated_ticket_id = ticket_id_as_int + return fluffy_order + + +@pytest.fixture +def metagenome_order( + metagenome_order_to_submit: dict, + ticket_id_as_int: int, +) -> MetagenomeOrder: + """Parse metagenome order example.""" + order = MetagenomeOrder.model_validate(metagenome_order_to_submit) + order._generated_ticket_id = ticket_id_as_int + return order + + +@pytest.fixture +def microbial_fastq_order( + microbial_fastq_order_to_submit: dict, ticket_id_as_int: int +) -> MicrobialFastqOrder: + order = MicrobialFastqOrder.model_validate(microbial_fastq_order_to_submit) + order._generated_ticket_id = ticket_id_as_int + return order + + +@pytest.fixture +def microsalt_order(microbial_order_to_submit: dict) -> MicrosaltOrder: + order = MicrosaltOrder.model_validate(microbial_order_to_submit) + order._generated_ticket_id = 123456 + return order + + +@pytest.fixture +def mip_dna_order(mip_dna_order_to_submit: dict) -> MipDnaOrder: + mip_dna_order_to_submit["user_id"] = 1 + mip_dna_order = MipDnaOrder.model_validate(mip_dna_order_to_submit) + for case_index, sample_index, sample in mip_dna_order.enumerated_new_samples: + sample._generated_lims_id = f"ACC{case_index}-{sample_index}" + mip_dna_order._generated_ticket_id = 123456 + return mip_dna_order + + +@pytest.fixture +def mip_rna_order(mip_rna_order_to_submit: dict) -> MipRnaOrder: + mip_rna_order_to_submit["user_id"] = 1 + mip_rna_order = MipRnaOrder.model_validate(mip_rna_order_to_submit) + for case_index, sample_index, sample in mip_rna_order.enumerated_new_samples: + sample._generated_lims_id = f"ACC{case_index}-{sample_index}" + mip_rna_order._generated_ticket_id = 123456 + return mip_rna_order + + +@pytest.fixture +def mutant_order(sarscov2_order_to_submit: dict, ticket_id_as_int: int) -> MutantOrder: + """Parse mutant order example.""" + order = MutantOrder.model_validate(sarscov2_order_to_submit) + order._generated_ticket_id = ticket_id_as_int + return order + + +@pytest.fixture +def pacbio_order(pacbio_order_to_submit: dict, ticket_id_as_int: int) -> PacbioOrder: + order = PacbioOrder.model_validate(pacbio_order_to_submit) + order._generated_ticket_id = ticket_id_as_int + return order + + +@pytest.fixture +def rml_order(rml_order_to_submit: dict, ticket_id_as_int: int) -> RmlOrder: + """Parse rml order example.""" + rml_order = RmlOrder.model_validate(rml_order_to_submit) + rml_order._generated_ticket_id = ticket_id_as_int + return rml_order + + +@pytest.fixture +def rnafusion_order(rnafusion_order_to_submit: dict) -> RnaFusionOrder: + """Parse RNAFusion order example.""" + rnafusion_order = RnaFusionOrder.model_validate(rnafusion_order_to_submit) + rnafusion_order._generated_ticket_id = 123456 + return rnafusion_order + + +@pytest.fixture +def taxprofiler_order(taxprofiler_order_to_submit: dict, ticket_id_as_int: int) -> TaxprofilerOrder: + """Parse Taxprofiler order example.""" + taxprofiler_order = TaxprofilerOrder.model_validate(taxprofiler_order_to_submit) + taxprofiler_order._generated_ticket_id = ticket_id_as_int + return taxprofiler_order + + +@pytest.fixture +def tomte_order(tomte_order_to_submit: dict, ticket_id_as_int: int) -> TomteOrder: + """Parse Tomte order example.""" + tomte_order = TomteOrder.model_validate(tomte_order_to_submit) + tomte_order._generated_ticket_id = ticket_id_as_int + return tomte_order diff --git a/tests/fixture_plugins/orders_fixtures/order_store_service_fixtures.py b/tests/fixture_plugins/orders_fixtures/order_store_service_fixtures.py deleted file mode 100644 index e5c188c18f..0000000000 --- a/tests/fixture_plugins/orders_fixtures/order_store_service_fixtures.py +++ /dev/null @@ -1,63 +0,0 @@ -import pytest - -from cg.services.orders.order_lims_service.order_lims_service import OrderLimsService -from cg.services.orders.store_order_services.store_case_order import StoreCaseOrderService -from cg.services.orders.store_order_services.store_fastq_order_service import StoreFastqOrderService -from cg.services.orders.store_order_services.store_metagenome_order import ( - StoreMetagenomeOrderService, -) -from cg.services.orders.store_order_services.store_microbial_fastq_order_service import ( - StoreMicrobialFastqOrderService, -) -from cg.services.orders.store_order_services.store_microbial_order import StoreMicrobialOrderService -from cg.services.orders.store_order_services.store_pacbio_order_service import ( - StorePacBioOrderService, -) -from cg.services.orders.store_order_services.store_pool_order import StorePoolOrderService -from cg.store.store import Store -from tests.mocks.limsmock import MockLimsAPI - - -@pytest.fixture -def store_generic_order_service(base_store: Store, lims_api: MockLimsAPI) -> StoreCaseOrderService: - return StoreCaseOrderService(status_db=base_store, lims_service=OrderLimsService(lims_api)) - - -@pytest.fixture -def store_pool_order_service(base_store: Store, lims_api: MockLimsAPI) -> StorePoolOrderService: - return StorePoolOrderService(status_db=base_store, lims_service=OrderLimsService(lims_api)) - - -@pytest.fixture -def store_fastq_order_service(base_store: Store, lims_api: MockLimsAPI) -> StoreFastqOrderService: - return StoreFastqOrderService(status_db=base_store, lims_service=OrderLimsService(lims_api)) - - -@pytest.fixture -def store_pacbio_order_service(base_store: Store, lims_api: MockLimsAPI) -> StorePacBioOrderService: - return StorePacBioOrderService(status_db=base_store, lims_service=OrderLimsService(lims_api)) - - -@pytest.fixture -def store_metagenome_order_service( - base_store: Store, lims_api: MockLimsAPI -) -> StoreMetagenomeOrderService: - return StoreMetagenomeOrderService( - status_db=base_store, lims_service=OrderLimsService(lims_api) - ) - - -@pytest.fixture -def store_microbial_order_service( - base_store: Store, lims_api: MockLimsAPI -) -> StoreMicrobialOrderService: - return StoreMicrobialOrderService(status_db=base_store, lims_service=OrderLimsService(lims_api)) - - -@pytest.fixture -def store_microbial_fastq_order_service( - base_store: Store, lims_api: MockLimsAPI -) -> StoreMicrobialFastqOrderService: - return StoreMicrobialFastqOrderService( - status_db=base_store, lims_service=OrderLimsService(lims_api) - ) diff --git a/tests/fixture_plugins/orders_fixtures/order_to_submit_fixtures.py b/tests/fixture_plugins/orders_fixtures/order_to_submit_fixtures.py index 4d71b98234..69eb36aeee 100644 --- a/tests/fixture_plugins/orders_fixtures/order_to_submit_fixtures.py +++ b/tests/fixture_plugins/orders_fixtures/order_to_submit_fixtures.py @@ -1,16 +1,18 @@ +"""Fixtures for orders parsed from JSON files into dictionaries.""" + from pathlib import Path import pytest from cg.constants.constants import FileFormat from cg.io.controller import ReadFile -from cg.models.orders.constants import OrderType -from cg.models.orders.order import OrderIn + +# Valid orders @pytest.fixture(scope="session") def balsamic_order_to_submit(cgweb_orders_dir: Path) -> dict: - """Load an example cancer order.""" + """Load an example Balsamic order.""" return ReadFile.get_content_from_file( file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "balsamic.json") ) @@ -18,39 +20,47 @@ def balsamic_order_to_submit(cgweb_orders_dir: Path) -> dict: @pytest.fixture(scope="session") def fastq_order_to_submit(cgweb_orders_dir) -> dict: - """Load an example FASTQ order.""" + """Load an example Fastq order.""" return ReadFile.get_content_from_file( file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "fastq.json") ) @pytest.fixture(scope="session") -def metagenome_order_to_submit(cgweb_orders_dir: Path) -> dict: - """Load an example metagenome order.""" +def fluffy_order_to_submit(cgweb_orders_dir: Path) -> dict: + """Load an example Fluffy order.""" return ReadFile.get_content_from_file( - file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "metagenome.json") + file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "fluffy.json") ) @pytest.fixture(scope="session") -def microbial_order_to_submit(cgweb_orders_dir: Path) -> dict: - """Load an example microbial order.""" +def metagenome_order_to_submit(cgweb_orders_dir: Path) -> dict: + """Load an example Metagenome order.""" return ReadFile.get_content_from_file( - file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "microsalt.json") + file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "metagenome.json") ) -@pytest.fixture +@pytest.fixture(scope="session") def microbial_fastq_order_to_submit(cgweb_orders_dir: Path) -> dict: - """Load an example microbial order.""" + """Load an example Microbial fastq order.""" return ReadFile.get_content_from_file( file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "microbial_fastq.json") ) @pytest.fixture(scope="session") -def mip_order_to_submit(cgweb_orders_dir: Path) -> dict: - """Load an example MIP order.""" +def microbial_order_to_submit(cgweb_orders_dir: Path) -> dict: + """Load an example Microsalt order.""" + return ReadFile.get_content_from_file( + file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "microsalt.json") + ) + + +@pytest.fixture(scope="session") +def mip_dna_order_to_submit(cgweb_orders_dir: Path) -> dict: + """Load an example MIP-DNA order.""" return ReadFile.get_content_from_file( file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "mip.json") ) @@ -58,7 +68,7 @@ def mip_order_to_submit(cgweb_orders_dir: Path) -> dict: @pytest.fixture(scope="session") def mip_rna_order_to_submit(cgweb_orders_dir: Path) -> dict: - """Load an example RNA order.""" + """Load an example MIP-RNA order.""" return ReadFile.get_content_from_file( file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "mip_rna.json") ) @@ -73,71 +83,51 @@ def pacbio_order_to_submit(cgweb_orders_dir) -> dict: @pytest.fixture(scope="session") -def rnafusion_order_to_submit(cgweb_orders_dir: Path) -> dict: - """Load an example RNA order.""" +def rml_order_to_submit(cgweb_orders_dir: Path) -> dict: + """Load an example RML order.""" return ReadFile.get_content_from_file( - file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "rnafusion.json") + file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "rml.json") ) @pytest.fixture(scope="session") -def rml_order_to_submit(cgweb_orders_dir: Path) -> dict: - """Load an example RML order.""" +def rnafusion_order_to_submit(cgweb_orders_dir: Path) -> dict: + """Load an example RNA Fusion order.""" return ReadFile.get_content_from_file( - file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "rml.json") + file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "rnafusion.json") ) -@pytest.fixture(scope="session") +@pytest.fixture def sarscov2_order_to_submit(cgweb_orders_dir: Path) -> dict: - """Load an example sarscov2 order.""" + """Load an example Mutant order.""" return ReadFile.get_content_from_file( file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "sarscov2.json") ) +@pytest.fixture(scope="session") +def taxprofiler_order_to_submit(cgweb_orders_dir: Path) -> dict: + """Load an example Taxprofiler order.""" + return ReadFile.get_content_from_file( + file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "taxprofiler.json") + ) + + @pytest.fixture(scope="session") def tomte_order_to_submit(cgweb_orders_dir: Path) -> dict: - """Load an example TOMTE order.""" + """Load an example Tomte order.""" return ReadFile.get_content_from_file( file_format=FileFormat.JSON, file_path=Path(cgweb_orders_dir, "tomte.json") ) +# Invalid orders + + @pytest.fixture(scope="session") -def all_orders_to_submit( - balsamic_order_to_submit: dict, - fastq_order_to_submit: dict, - metagenome_order_to_submit: dict, - microbial_order_to_submit: dict, - mip_order_to_submit: dict, - mip_rna_order_to_submit: dict, - pacbio_order_to_submit: dict, - rml_order_to_submit: dict, - rnafusion_order_to_submit: dict, - sarscov2_order_to_submit: dict, -) -> dict[str, OrderIn]: - """Returns a dict of parsed order for each order type.""" - return { - OrderType.BALSAMIC: OrderIn.parse_obj(balsamic_order_to_submit, project=OrderType.BALSAMIC), - OrderType.FASTQ: OrderIn.parse_obj(fastq_order_to_submit, project=OrderType.FASTQ), - OrderType.FLUFFY: OrderIn.parse_obj(rml_order_to_submit, project=OrderType.FLUFFY), - OrderType.METAGENOME: OrderIn.parse_obj( - metagenome_order_to_submit, project=OrderType.METAGENOME - ), - OrderType.MICROSALT: OrderIn.parse_obj( - microbial_order_to_submit, project=OrderType.MICROSALT - ), - OrderType.MIP_DNA: OrderIn.parse_obj(mip_order_to_submit, project=OrderType.MIP_DNA), - OrderType.MIP_RNA: OrderIn.parse_obj(mip_rna_order_to_submit, project=OrderType.MIP_RNA), - OrderType.PACBIO_LONG_READ: OrderIn.parse_obj( - pacbio_order_to_submit, project=OrderType.PACBIO_LONG_READ - ), - OrderType.RML: OrderIn.parse_obj(rml_order_to_submit, project=OrderType.RML), - OrderType.RNAFUSION: OrderIn.parse_obj( - rnafusion_order_to_submit, project=OrderType.RNAFUSION - ), - OrderType.SARS_COV_2: OrderIn.parse_obj( - sarscov2_order_to_submit, project=OrderType.SARS_COV_2 - ), - } +def invalid_balsamic_order_to_submit(invalid_cgweb_orders_dir: Path) -> dict: + """Load an invalid example Balsamic order.""" + return ReadFile.get_content_from_file( + file_format=FileFormat.JSON, file_path=Path(invalid_cgweb_orders_dir, "balsamic_FAIL.json") + ) diff --git a/tests/fixture_plugins/orders_fixtures/path_fixtures.py b/tests/fixture_plugins/orders_fixtures/path_fixtures.py new file mode 100644 index 0000000000..74eea596cf --- /dev/null +++ b/tests/fixture_plugins/orders_fixtures/path_fixtures.py @@ -0,0 +1,15 @@ +from pathlib import Path + +import pytest + + +@pytest.fixture(scope="session") +def cgweb_orders_dir(fixtures_dir: Path) -> Path: + """Return the path to the cgweb_orders dir.""" + return Path(fixtures_dir, "cgweb_orders") + + +@pytest.fixture(scope="session") +def invalid_cgweb_orders_dir(fixtures_dir: Path) -> Path: + """Return the path to the invalid_cgweb_orders dir.""" + return Path(fixtures_dir, "invalid_cgweb_orders") diff --git a/tests/fixture_plugins/orders_fixtures/services_fixtures.py b/tests/fixture_plugins/orders_fixtures/services_fixtures.py new file mode 100644 index 0000000000..ea0823371f --- /dev/null +++ b/tests/fixture_plugins/orders_fixtures/services_fixtures.py @@ -0,0 +1,55 @@ +import pytest + +from cg.clients.freshdesk.freshdesk_client import FreshdeskClient +from cg.services.orders.storing.service_registry import ( + StoringServiceRegistry, + setup_storing_service_registry, +) +from cg.services.orders.submitter.service import OrderSubmitter +from cg.services.orders.submitter.ticket_handler import TicketHandler +from cg.services.orders.validation.model_validator.model_validator import ModelValidator +from cg.services.orders.validation.service import OrderValidationService +from cg.store.store import Store +from tests.mocks.limsmock import MockLimsAPI + + +@pytest.fixture +def freshdesk_client() -> FreshdeskClient: + return FreshdeskClient(base_url="https://mock.freshdesk.com", api_key="mock_api_key") + + +@pytest.fixture +def model_validator() -> ModelValidator: + return ModelValidator() + + +@pytest.fixture +def order_validation_service(store_to_submit_and_validate_orders: Store) -> OrderValidationService: + return OrderValidationService(store_to_submit_and_validate_orders) + + +@pytest.fixture(scope="function") +def order_submitter( + ticket_handler: TicketHandler, + storing_service_registry: StoringServiceRegistry, + order_validation_service: OrderValidationService, +) -> OrderSubmitter: + return OrderSubmitter( + ticket_handler=ticket_handler, + storing_registry=storing_service_registry, + validation_service=order_validation_service, + ) + + +@pytest.fixture +def storing_service_registry( + store_to_submit_and_validate_orders: Store, lims_api: MockLimsAPI +) -> StoringServiceRegistry: + return setup_storing_service_registry( + lims=lims_api, status_db=store_to_submit_and_validate_orders + ) + + +@pytest.fixture +def ticket_handler(store: Store, freshdesk_client: FreshdeskClient) -> TicketHandler: + return TicketHandler(db=store, client=freshdesk_client, system_email_id=12345, env="production") diff --git a/tests/fixture_plugins/orders_fixtures/status_data_fixtures.py b/tests/fixture_plugins/orders_fixtures/status_data_fixtures.py deleted file mode 100644 index adff0bc761..0000000000 --- a/tests/fixture_plugins/orders_fixtures/status_data_fixtures.py +++ /dev/null @@ -1,119 +0,0 @@ -import pytest - -from cg.models.orders.constants import OrderType -from cg.models.orders.order import OrderIn -from cg.services.orders.store_order_services.store_case_order import StoreCaseOrderService -from cg.services.orders.store_order_services.store_fastq_order_service import StoreFastqOrderService -from cg.services.orders.store_order_services.store_metagenome_order import ( - StoreMetagenomeOrderService, -) -from cg.services.orders.store_order_services.store_microbial_fastq_order_service import ( - StoreMicrobialFastqOrderService, -) -from cg.services.orders.store_order_services.store_microbial_order import StoreMicrobialOrderService -from cg.services.orders.store_order_services.store_pacbio_order_service import ( - StorePacBioOrderService, -) -from cg.services.orders.store_order_services.store_pool_order import StorePoolOrderService - - -@pytest.fixture -def balsamic_status_data( - balsamic_order_to_submit: dict, store_generic_order_service: StoreCaseOrderService -) -> dict: - """Parse balsamic order example.""" - project: OrderType = OrderType.BALSAMIC - order: OrderIn = OrderIn.parse_obj(obj=balsamic_order_to_submit, project=project) - return store_generic_order_service.order_to_status(order=order) - - -@pytest.fixture -def fastq_status_data( - fastq_order_to_submit, store_fastq_order_service: StoreFastqOrderService -) -> dict: - """Parse fastq order example.""" - project: OrderType = OrderType.FASTQ - order: OrderIn = OrderIn.parse_obj(obj=fastq_order_to_submit, project=project) - return store_fastq_order_service.order_to_status(order=order) - - -@pytest.fixture -def pacbio_status_data( - pacbio_order_to_submit: dict, store_pacbio_order_service: StorePacBioOrderService -) -> dict: - """Parse pacbio order example.""" - project: OrderType = OrderType.PACBIO_LONG_READ - order: OrderIn = OrderIn.parse_obj(obj=pacbio_order_to_submit, project=project) - return store_pacbio_order_service.order_to_status(order=order) - - -@pytest.fixture -def metagenome_status_data( - metagenome_order_to_submit: dict, store_metagenome_order_service: StoreMetagenomeOrderService -) -> dict: - """Parse metagenome order example.""" - project: OrderType = OrderType.METAGENOME - order: OrderIn = OrderIn.parse_obj(obj=metagenome_order_to_submit, project=project) - - return store_metagenome_order_service.order_to_status(order=order) - - -@pytest.fixture -def microbial_status_data( - microbial_order_to_submit: dict, store_microbial_order_service: StoreMicrobialOrderService -) -> dict: - """Parse microbial order example.""" - project: OrderType = OrderType.MICROSALT - order: OrderIn = OrderIn.parse_obj(obj=microbial_order_to_submit, project=project) - return store_microbial_order_service.order_to_status(order=order) - - -@pytest.fixture -def microbial_fastq_status_data( - microbial_fastq_order_to_submit: dict, - store_microbial_fastq_order_service: StoreMicrobialFastqOrderService, -) -> dict: - """Parse microbial order example.""" - project: OrderType = OrderType.MICROBIAL_FASTQ - order: OrderIn = OrderIn.parse_obj(obj=microbial_fastq_order_to_submit, project=project) - return store_microbial_fastq_order_service.order_to_status(order=order) - - -@pytest.fixture -def mip_rna_status_data( - mip_rna_order_to_submit: dict, store_generic_order_service: StoreCaseOrderService -) -> dict: - """Parse rna order example.""" - project: OrderType = OrderType.MIP_RNA - order: OrderIn = OrderIn.parse_obj(obj=mip_rna_order_to_submit, project=project) - return store_generic_order_service.order_to_status(order=order) - - -@pytest.fixture -def mip_status_data( - mip_order_to_submit: dict, store_generic_order_service: StoreCaseOrderService -) -> dict: - """Parse scout order example.""" - project: OrderType = OrderType.MIP_DNA - order: OrderIn = OrderIn.parse_obj(obj=mip_order_to_submit, project=project) - return store_generic_order_service.order_to_status(order=order) - - -@pytest.fixture -def rml_status_data( - rml_order_to_submit: dict, store_pool_order_service: StorePoolOrderService -) -> dict: - """Parse rml order example.""" - project: OrderType = OrderType.RML - order: OrderIn = OrderIn.parse_obj(obj=rml_order_to_submit, project=project) - return store_pool_order_service.order_to_status(order=order) - - -@pytest.fixture -def tomte_status_data( - tomte_order_to_submit: dict, store_generic_order_service: StoreCaseOrderService -) -> dict: - """Parse TOMTE order example.""" - project: OrderType = OrderType.TOMTE - order: OrderIn = OrderIn.parse_obj(obj=tomte_order_to_submit, project=project) - return store_generic_order_service.order_to_status(order=order) diff --git a/tests/fixture_plugins/orders_fixtures/store_fixtures.py b/tests/fixture_plugins/orders_fixtures/store_fixtures.py new file mode 100644 index 0000000000..e5f86d461e --- /dev/null +++ b/tests/fixture_plugins/orders_fixtures/store_fixtures.py @@ -0,0 +1,47 @@ +"""Store fixtures for the order services tests.""" + +import pytest + +from cg.models.orders.constants import OrderType +from cg.services.orders.storing.constants import MAF_ORDER_ID +from cg.store.models import ApplicationVersion, Customer, Order +from cg.store.store import Store +from tests.store_helpers import StoreHelpers + + +@pytest.fixture +def store_to_submit_and_validate_orders( + store: Store, helpers: StoreHelpers, customer_id: str +) -> Store: + app_tags: dict[str, list[OrderType]] = { + "PANKTTR100": [OrderType.BALSAMIC], + "WGSPCFC030": [OrderType.FASTQ, OrderType.MIP_DNA], + "RMLP15R100": [OrderType.FLUFFY, OrderType.RML], + "RMLP15R200": [OrderType.FLUFFY, OrderType.RML], + "RMLP15R400": [OrderType.FLUFFY, OrderType.RML], + "RMLP15R500": [OrderType.FLUFFY, OrderType.RML], + "METPCFR030": [OrderType.METAGENOME], + "METWPFR030": [OrderType.METAGENOME, OrderType.TAXPROFILER], + "MWRNXTR003": [OrderType.MICROBIAL_FASTQ, OrderType.MICROSALT], + "MWXNXTR003": [OrderType.MICROSALT], + "VWGNXTR001": [OrderType.MICROSALT], + "WGSWPFC030": [OrderType.MIP_DNA], + "RNAPOAR025": [OrderType.MIP_RNA, OrderType.RNAFUSION, OrderType.TOMTE], + "LWPBELB070": [OrderType.PACBIO_LONG_READ], + "VWGDPTR001": [OrderType.SARS_COV_2], + } + for tag, orders in app_tags.items(): + application_version: ApplicationVersion = helpers.ensure_application_version( + store=store, application_tag=tag + ) + application_version.application.order_types = orders + customer: Customer = helpers.ensure_customer(store=store, customer_id=customer_id) + helpers.ensure_user(store=store, customer=customer) + helpers.ensure_panel(store=store, panel_abbreviation="AID") + helpers.ensure_panel(store=store, panel_abbreviation="Ataxi") + helpers.ensure_panel(store=store, panel_abbreviation="IEM") + helpers.ensure_panel(store=store, panel_abbreviation="OMIM-AUTO") + order = Order(customer_id=1, id=MAF_ORDER_ID, ticket_id=100000000) + store.add_item_to_store(order) + store.commit_to_store() + return store diff --git a/tests/fixture_plugins/orders_fixtures/store_service_fixtures.py b/tests/fixture_plugins/orders_fixtures/store_service_fixtures.py new file mode 100644 index 0000000000..9a7b86c9aa --- /dev/null +++ b/tests/fixture_plugins/orders_fixtures/store_service_fixtures.py @@ -0,0 +1,82 @@ +import pytest + +from cg.services.orders.lims_service.service import OrderLimsService +from cg.services.orders.storing.implementations.case_order_service import StoreCaseOrderService +from cg.services.orders.storing.implementations.fastq_order_service import StoreFastqOrderService +from cg.services.orders.storing.implementations.metagenome_order_service import ( + StoreMetagenomeOrderService, +) +from cg.services.orders.storing.implementations.microbial_fastq_order_service import ( + StoreMicrobialFastqOrderService, +) +from cg.services.orders.storing.implementations.microbial_order_service import ( + StoreMicrobialOrderService, +) +from cg.services.orders.storing.implementations.pacbio_order_service import StorePacBioOrderService +from cg.services.orders.storing.implementations.pool_order_service import StorePoolOrderService +from cg.store.store import Store +from tests.mocks.limsmock import MockLimsAPI + + +@pytest.fixture +def store_generic_order_service( + store_to_submit_and_validate_orders: Store, lims_api: MockLimsAPI +) -> StoreCaseOrderService: + return StoreCaseOrderService( + status_db=store_to_submit_and_validate_orders, lims_service=OrderLimsService(lims_api) + ) + + +@pytest.fixture +def store_pool_order_service( + store_to_submit_and_validate_orders: Store, lims_api: MockLimsAPI +) -> StorePoolOrderService: + return StorePoolOrderService( + status_db=store_to_submit_and_validate_orders, lims_service=OrderLimsService(lims_api) + ) + + +@pytest.fixture +def store_fastq_order_service( + store_to_submit_and_validate_orders: Store, lims_api: MockLimsAPI +) -> StoreFastqOrderService: + return StoreFastqOrderService( + status_db=store_to_submit_and_validate_orders, lims_service=OrderLimsService(lims_api) + ) + + +@pytest.fixture +def store_pacbio_order_service( + store_to_submit_and_validate_orders: Store, lims_api: MockLimsAPI +) -> StorePacBioOrderService: + return StorePacBioOrderService( + status_db=store_to_submit_and_validate_orders, lims_service=OrderLimsService(lims_api) + ) + + +@pytest.fixture +def store_metagenome_order_service( + store_to_submit_and_validate_orders: Store, lims_api: MockLimsAPI +) -> StoreMetagenomeOrderService: + return StoreMetagenomeOrderService( + status_db=store_to_submit_and_validate_orders, lims_service=OrderLimsService(lims_api) + ) + + +@pytest.fixture +def store_microbial_order_service( + store_to_submit_and_validate_orders: Store, + lims_api: MockLimsAPI, +) -> StoreMicrobialOrderService: + return StoreMicrobialOrderService( + status_db=store_to_submit_and_validate_orders, lims_service=OrderLimsService(lims_api) + ) + + +@pytest.fixture +def store_microbial_fastq_order_service( + store_to_submit_and_validate_orders: Store, lims_api: MockLimsAPI +) -> StoreMicrobialFastqOrderService: + return StoreMicrobialFastqOrderService( + status_db=store_to_submit_and_validate_orders, lims_service=OrderLimsService(lims_api) + ) diff --git a/tests/fixtures/cgweb_orders/balsamic.json b/tests/fixtures/cgweb_orders/balsamic.json index 17179dde03..342f1798ac 100644 --- a/tests/fixtures/cgweb_orders/balsamic.json +++ b/tests/fixtures/cgweb_orders/balsamic.json @@ -1,43 +1,61 @@ { - "name": "#123456", - "customer": "cust000", - "comment": "", - "samples": [ - { - "age_at_sampling": "17.18192", - "application": "WGSPCFC030", - "capture_kit": "other", - "cohorts": [ - "" - ], - "comment": "other Elution buffer", - "container": "96 well plate", - "concentration_ng_ul": "18", - "container_name": "p1", - "data_analysis": "balsamic", - "data_delivery": "fastq-analysis-scout", - "elution_buffer": "Other (specify in 'Comments')", - "family_name": "family1", - "formalin_fixation_time": "1", - "name": "s1", - "phenotype_groups": [ - "" - ], - "phenotype_terms": [ - "" - ], - "post_formalin_fixation_time": "2", - "priority": "standard", - "quantity": "2", - "sex": "male", - "source": "blood", - "subject_id": "subject1", - "synopsis": "", - "tissue_block_size": "small", - "tumour": true, - "tumour_purity": "75", - "volume": "1", - "well_position": "A:1" - } - ] -} + "cases": [ + { + "cohorts": null, + "name": "BalsamicCase", + "panels": null, + "priority": "standard", + "samples": [ + { + "age_at_sampling": "17.2", + "application": "PANKTTR100", + "capture_kit": "GMCKsolid", + "comment": "This is a sample comment", + "concentration_ng_ul": null, + "container": "96 well plate", + "container_name": "BalsamicPlate", + "control": null, + "data_analysis": null, + "data_delivery": null, + "elution_buffer": "Tris-HCl", + "family_name": null, + "father": null, + "formalin_fixation_time": "15", + "mother": null, + "name": "BalsamicSample", + "phenotype_groups": [ + "PhGroup" + ], + "phenotype_terms": [ + "PhTerm" + ], + "post_formalin_fixation_time": "3", + "priority": null, + "quantity": null, + "reference_genome": null, + "require_qc_ok": false, + "sex": "male", + "source": "cytology (FFPE)", + "source_comment": null, + "status": null, + "subject_id": "Subject1", + "tissue_block_size": "large", + "tumour": true, + "tumour_purity": "13", + "volume": 42, + "well_position": "A:1" + } + ], + "synopsis": "A synopsis" + } + ], + "comment": null, + "customer": "cust000", + "data_analysis": "balsamic", + "data_delivery": null, + "delivery_type": "analysis-scout", + "name": "BalsamicOrder", + "project_type": "balsamic", + "ticket": null, + "user_id": 1 +} \ No newline at end of file diff --git a/tests/fixtures/cgweb_orders/fastq.json b/tests/fixtures/cgweb_orders/fastq.json index e23dce1559..64ad2a93a3 100644 --- a/tests/fixtures/cgweb_orders/fastq.json +++ b/tests/fixtures/cgweb_orders/fastq.json @@ -1,39 +1,129 @@ { - "name": "Fastq order", - "customer": "cust002", - "comment": "", - "samples": [ - { - "application": "WGSPCFC060", - "comment": "", - "container": "Tube", - "container_name": "prov1", - "data_analysis": "raw-data", - "data_delivery": "fastq", - "elution_buffer": "Nuclease-free water", - "name": "prov1", - "priority": "priority", - "sex": "male", - "source": "blood", - "tumour": false, - "volume": "1", - "well_position": "" - }, - { - "application": "WGSPCFC060", - "comment": "", - "container": "Tube", - "container_name": "prov2", - "data_analysis": "raw-data", - "data_delivery": "fastq", - "elution_buffer": "Nuclease-free water", - "name": "prov2", - "priority": "priority", - "sex": "male", - "source": "cell line", - "tumour": true, - "volume": "2", - "well_position": "" - } - ] -} + "comment": "TestComment", + "customer": "cust000", + "data_delivery": null, + "delivery_type": "fastq", + "name": "FastqOrder", + "project_type": "fastq", + "samples": [ + { + "age_at_sampling": null, + "application": "WGSPCFC030", + "capture_kit": null, + "collection_date": null, + "comment": "This is a test comment", + "concentration": null, + "concentration_ng_ul": 65, + "concentration_sample": null, + "container": "96 well plate", + "container_name": "Plate1", + "control": null, + "custom_index": null, + "data_analysis": null, + "data_delivery": null, + "elution_buffer": "Nuclease-free water", + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "Sample1", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "standard", + "quantity": "15", + "reagent_label": null, + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": false, + "rml_plate_name": null, + "selection_criteria": null, + "sex": "male", + "source": "blood", + "source_comment": null, + "status": null, + "subject_id": "subject1", + "tissue_block_size": null, + "tumour": false, + "tumour_purity": null, + "verified_organism": null, + "volume": 54, + "well_position": "A:1", + "well_position_rml": null + }, + { + "age_at_sampling": null, + "application": "WGSPCFC030", + "capture_kit": null, + "collection_date": null, + "comment": "This is another test comment", + "concentration": null, + "concentration_ng_ul": 33, + "concentration_sample": null, + "container": "96 well plate", + "container_name": "Plate1", + "control": null, + "custom_index": null, + "data_analysis": null, + "data_delivery": null, + "elution_buffer": "Nuclease-free water", + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "Sample2", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "standard", + "quantity": "15", + "reagent_label": null, + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": true, + "rml_plate_name": null, + "selection_criteria": null, + "sex": "female", + "source": "blood", + "source_comment": null, + "status": null, + "subject_id": "subject2", + "tissue_block_size": null, + "tumour": true, + "tumour_purity": null, + "verified_organism": null, + "volume": 54, + "well_position": "B:1", + "well_position_rml": null + } + ], + "user_id": 1 +} \ No newline at end of file diff --git a/tests/fixtures/cgweb_orders/fluffy.json b/tests/fixtures/cgweb_orders/fluffy.json new file mode 100644 index 0000000000..091f9796a7 --- /dev/null +++ b/tests/fixtures/cgweb_orders/fluffy.json @@ -0,0 +1,249 @@ +{ + "cases": [], + "comment": null, + "customer": "cust000", + "data_delivery": null, + "delivery_type": "statina", + "name": "1604.19.rml", + "project_type": "fluffy", + "samples": [ + { + "age_at_sampling": null, + "application": "RMLP15R100", + "capture_kit": null, + "collection_date": null, + "comment": "comment", + "concentration": "2", + "concentration_ng_ul": null, + "concentration_sample": "4", + "container": null, + "container_name": null, + "control": "positive", + "custom_index": null, + "customer": "cust000", + "data_analysis": "FLUFFY", + "data_delivery": "statina", + "elution_buffer": null, + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": "IDT DupSeq 10 bp Set B", + "index_number": "3", + "index_sequence": "", + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "fluffysample1", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": "pool1", + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "research", + "quantity": null, + "reagent_label": "C01 IDT_10nt_568 (TGTGAGCGAA-AACTCCGATC)", + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": false, + "rml_plate_name": "plate1", + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "20", + "well_position": null, + "well_position_rml": "A:1" + }, + { + "age_at_sampling": null, + "application": "RMLP15R200", + "capture_kit": null, + "collection_date": null, + "comment": "", + "concentration": "2", + "concentration_ng_ul": null, + "concentration_sample": null, + "container": null, + "container_name": null, + "control": "negative", + "custom_index": null, + "customer": "cust000", + "data_analysis": "FLUFFY", + "data_delivery": "statina", + "elution_buffer": null, + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": "TruSeq DNA HT Dual-index (D7-D5)", + "index_number": "3", + "index_sequence": "C01 - D701-D503 (ATTACTCG-CCTATCCT)", + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "fluffysample2", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": "pool2", + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "clinical_trials", + "quantity": null, + "reagent_label": "C01 - D701-D503 (ATTACTCG-CCTATCCT)", + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": false, + "rml_plate_name": "", + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "21", + "well_position": null, + "well_position_rml": "" + }, + { + "age_at_sampling": null, + "application": "RMLP15R400", + "capture_kit": null, + "collection_date": null, + "comment": "", + "concentration": "2", + "concentration_ng_ul": null, + "concentration_sample": null, + "container": null, + "container_name": null, + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "FLUFFY", + "data_delivery": "statina", + "elution_buffer": null, + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": "NEXTflex® v2 UDI Barcodes 1 - 96", + "index_number": "3", + "index_sequence": "UDI 3 (CGCTGCTC-GGCAGATC)", + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "fluffysample3", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": "pool3", + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "standard", + "quantity": null, + "reagent_label": "UDI3 (CGCTGCTC-GGCAGATC)", + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": false, + "rml_plate_name": "", + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "22", + "well_position": null, + "well_position_rml": "" + }, + { + "age_at_sampling": null, + "application": "RMLP15R500", + "capture_kit": null, + "collection_date": null, + "comment": "", + "concentration": "2", + "concentration_ng_ul": null, + "concentration_sample": null, + "container": null, + "container_name": null, + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "FLUFFY", + "data_delivery": "statina", + "elution_buffer": null, + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": "NEXTflex® v2 UDI Barcodes 1 - 96", + "index_number": "3", + "index_sequence": "UDI 3 (CGCTGCTC-GGCAGATC)", + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "fluffysample4", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": "pool4", + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "priority", + "quantity": null, + "reagent_label": "UDI 3 (CGCTGCTC-GGCAGATC)", + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": false, + "rml_plate_name": "", + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "23", + "well_position": null, + "well_position_rml": "A:1" + } + ], + "ticket": null, + "user_id": 1 +} \ No newline at end of file diff --git a/tests/fixtures/cgweb_orders/metagenome.json b/tests/fixtures/cgweb_orders/metagenome.json index a29bb7ff98..d5724c0b68 100644 --- a/tests/fixtures/cgweb_orders/metagenome.json +++ b/tests/fixtures/cgweb_orders/metagenome.json @@ -1,43 +1,43 @@ { - "name": "Metagenome", - "customer": "cust000", - "comment": "", - "samples": [ - { - "name": "Bristol", - "container": "96 well plate", - "application": "METLIFR020", - "data_analysis": "raw-data", - "data_delivery": "fastq", - "require_qc_ok": false, - "elution_buffer": "Tris-HCl", - "source": "faeces", - "priority": "standard", - "container_name": "Platen", - "well_position": "A:1", - "concentration_sample": "2", - "quantity": "10", - "extraction_method": "best", - "volume": "1", - "comment": "5 on the chart" - }, - { - "name": "Trefyrasex", - "container": "Tube", - "application": "METNXTR020", - "data_analysis": "raw-data", - "data_delivery": "fastq", - "require_qc_ok": true, - "elution_buffer": "Nuclease-free water", - "source": "blood", - "priority": "priority", - "container_name": "Tuben", - "well_position": "", - "concentration_sample": "1", - "quantity": "2", - "extraction_method": "unknown", - "volume": "2", - "comment": "test" - } - ] -} + "comment": null, + "customer": "cust000", + "data_delivery": "fastq", + "dataAnalysis": "raw-data", + "delivery_type": "fastq", + "name": "Metagenome", + "project_type": "metagenome", + "samples": [ + { + "application": "METPCFR030", + "comment": "5 on the chart", + "container": "96 well plate", + "container_name": "Platen", + "elution_buffer": "Tris-HCl", + "name": "Bristol", + "organism": null, + "priority": "standard", + "processedAt": "2025-01-03T09:00:27.876Z", + "quantity": "10", + "require_qc_ok": false, + "source": "blood", + "volume": 20, + "well_position": "A:1" + }, + { + "application": "METWPFR030", + "comment": "test", + "container": "Tube", + "container_name": "Tuben", + "elution_buffer": "Nuclease-free water", + "name": "Trefyrasex", + "organism": null, + "priority": "priority", + "processedAt": "2025-01-03T09:00:27.876Z", + "quantity": "2", + "require_qc_ok": true, + "source": "buccal swab", + "volume": 21 + } + ], + "user_id": 1 +} \ No newline at end of file diff --git a/tests/fixtures/cgweb_orders/microbial_fastq.json b/tests/fixtures/cgweb_orders/microbial_fastq.json index 57497dcf02..e85d9116da 100644 --- a/tests/fixtures/cgweb_orders/microbial_fastq.json +++ b/tests/fixtures/cgweb_orders/microbial_fastq.json @@ -1,33 +1,36 @@ { "name": "Microbial Fastq order", - "customer": "cust002", + "customer": "cust000", + "delivery_type": "fastq", + "project_type": "microbial-fastq", + "user_id": 0, "comment": "", "samples": [ { - "application": "WGSPCFC060", + "application": "MWRNXTR003", "comment": "sample comment", "container": "Tube", "container_name": "prov1", - "data_analysis": "microsalt", + "data_analysis": "raw-data", "data_delivery": "fastq", "elution_buffer": "Nuclease-free water", "name": "prov1", "priority": "priority", - "volume": "1", + "volume": "100", "well_position": "" }, { - "application": "WGSPCFC060", + "application": "MWRNXTR003", "comment": "sample comment", - "container": "Tube", + "container": "96 well plate", "container_name": "prov2", "data_analysis": "raw-data", "data_delivery": "fastq", "elution_buffer": "Nuclease-free water", "name": "prov2", "priority": "priority", - "volume": "2", - "well_position": "" + "volume": "20", + "well_position": "A:1" } ] } \ No newline at end of file diff --git a/tests/fixtures/cgweb_orders/microsalt.json b/tests/fixtures/cgweb_orders/microsalt.json index 1273901f63..3fc22ea8f9 100644 --- a/tests/fixtures/cgweb_orders/microsalt.json +++ b/tests/fixtures/cgweb_orders/microsalt.json @@ -1,92 +1,309 @@ { - "name": "Microbial samples", - "customer": "cust002", - "comment": "Order comment", - "samples": [ - { - "name": "all-fields", - "application": "MWRNXTR003", - "data_analysis": "microsalt", - "data_delivery": "fastq", - "volume": "1", - "priority": "research", - "require_qc_ok": true, - "organism": "M.upium", - "reference_genome": "NC_111", - "elution_buffer": "Nuclease-free water", - "extraction_method": "MagNaPure 96 (contact Clinical Genomics before submission)", - "container": "96 well plate", - "container_name": "name of plate", - "well_position": "A:1", - "comment": "plate comment" - }, - { - "name": "required-fields", - "application": "MWRNXTR003", - "data_analysis": "microsalt", - "data_delivery": "fastq", - "volume": "2", - "priority": "standard", - "require_qc_ok": true, - "organism": "C. difficile", - "reference_genome": "NC_222", - "elution_buffer": "Nuclease-free water", - "extraction_method": "MagNaPure 96 (contact Clinical Genomics before submission)", - "container": "Tube", - "container_name": "required-fields", - "well_position": "", - "comment": "" - }, - { - "name": "plate-fields", - "application": "MWRNXTR003", - "data_analysis": "microsalt", - "data_delivery": "fastq", - "volume": "3", - "priority": "research", - "require_qc_ok": true, - "organism": "C. difficile", - "reference_genome": "NC_333", - "elution_buffer": "Nuclease-free water", - "extraction_method": "MagNaPure 96 (contact Clinical Genomics before submission)", - "container": "96 well plate", - "container_name": "name of plate", - "well_position": "A:2", - "comment": "" - }, - { - "name": "other-species-fields", - "application": "MWRNXTR003", - "data_analysis": "microsalt", - "data_delivery": "fastq", - "volume": "4", - "priority": "research", - "require_qc_ok": true, - "organism": "M.upium", - "reference_genome": "NC_444", - "elution_buffer": "Nuclease-free water", - "extraction_method": "MagNaPure 96 (contact Clinical Genomics before submission)", - "container": "Tube", - "container_name": "other-species-fields", - "well_position": "", - "comment": "" - }, - { - "name": "optional-fields", - "application": "MWRNXTR003", - "data_analysis": "microsalt", - "data_delivery": "fastq", - "volume": "5", - "priority": "research", - "require_qc_ok": false, - "organism": "C. difficile", - "reference_genome": "NC_555", - "elution_buffer": "Nuclease-free water", - "extraction_method": "MagNaPure 96 (contact Clinical Genomics before submission)", - "container": "Tube", - "container_name": "optional-fields", - "well_position": "", - "comment": "optional comment" - } - ] -} + "cases": [], + "comment": null, + "customer": "cust000", + "data_analysis": null, + "data_delivery": null, + "delivery_type": "fastq_qc-analysis", + "name": "1603.11.microbial", + "project_type": "microsalt", + "samples": [ + { + "age_at_sampling": null, + "application": "MWRNXTR003", + "capture_kit": null, + "collection_date": null, + "comment": "comments", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": "2", + "container": "96 well plate", + "container_name": "plate1", + "control": "positive", + "custom_index": null, + "customer": "cust000", + "data_analysis": "MIP DNA", + "data_delivery": "fastq qc + analysis", + "elution_buffer": "Other (specify in \"Comments\")", + "extraction_method": "MagNaPure 96 (contact Clinical Genomics before submission)", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "microbialsample1", + "organism": "C. jejuni", + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "research", + "quantity": "3", + "reagent_label": null, + "reference_genome": "NC_000001", + "region": null, + "region_code": null, + "require_qc_ok": null, + "rml_plate_name": null, + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "20", + "well_position": "A:1", + "well_position_rml": null + }, + { + "age_at_sampling": null, + "application": "MWXNXTR003", + "capture_kit": null, + "collection_date": null, + "comment": "", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": null, + "container": "96 well plate", + "container_name": "plate1", + "control": "negative", + "custom_index": null, + "customer": "cust000", + "data_analysis": "MIP DNA", + "data_delivery": "fastq qc + analysis", + "elution_buffer": "Nuclease-free water", + "extraction_method": "MagNaPure 96 (contact Clinical Genomics before submission)", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "microbialsample2", + "organism": "C. jejuni", + "organism_other": "", + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "standard", + "quantity": null, + "reagent_label": null, + "reference_genome": "NC_000002", + "region": null, + "region_code": null, + "require_qc_ok": null, + "rml_plate_name": null, + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "21", + "well_position": "B:1", + "well_position_rml": null + }, + { + "age_at_sampling": null, + "application": "VWGNXTR001", + "capture_kit": null, + "collection_date": null, + "comment": "", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": null, + "container": "Tube", + "container_name": "", + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "MIP DNA", + "data_delivery": "fastq qc + analysis", + "elution_buffer": "Tris-HCl", + "extraction_method": "EZ1", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "microbialsample3", + "organism": "C. difficile", + "organism_other": "", + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "priority", + "quantity": null, + "reagent_label": null, + "reference_genome": "NC_000003", + "region": null, + "region_code": null, + "require_qc_ok": null, + "rml_plate_name": null, + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "22", + "well_position": "", + "well_position_rml": null + }, + { + "age_at_sampling": null, + "application": "VWGNXTR001", + "capture_kit": null, + "collection_date": null, + "comment": "", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": null, + "container": "Tube", + "container_name": "", + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "MIP DNA", + "data_delivery": "fastq qc + analysis", + "elution_buffer": "Nuclease-free water", + "extraction_method": "QIAsymphony", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "microbialsample4", + "organism": "E. faecalis", + "organism_other": "", + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "express", + "quantity": null, + "reagent_label": null, + "reference_genome": "NC_000004", + "region": null, + "region_code": null, + "require_qc_ok": null, + "rml_plate_name": null, + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "23", + "well_position": "", + "well_position_rml": null + }, + { + "age_at_sampling": null, + "application": "MWRNXTR003", + "capture_kit": null, + "collection_date": null, + "comment": "", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": null, + "container": "Tube", + "container_name": "", + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "MIP DNA", + "data_delivery": "fastq qc + analysis", + "elution_buffer": "Tris-HCl", + "extraction_method": "Qiagen MagAttract", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "microbialsample5", + "organism": "E. faecium", + "organism_other": "", + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "research", + "quantity": null, + "reagent_label": null, + "reference_genome": "NC_000005", + "region": null, + "region_code": null, + "require_qc_ok": null, + "rml_plate_name": null, + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "24", + "well_position": "", + "well_position_rml": null + } + ], + "ticket": null, + "user_id": 1 +} \ No newline at end of file diff --git a/tests/fixtures/cgweb_orders/mip.json b/tests/fixtures/cgweb_orders/mip.json index 202c4337e0..03e063feaf 100644 --- a/tests/fixtures/cgweb_orders/mip.json +++ b/tests/fixtures/cgweb_orders/mip.json @@ -1,128 +1,180 @@ { - "name": "#123456", - "customer": "cust000", - "comment": "", - "samples": [ - { - "age_at_sampling": "17.18192", - "application": "WGSPCFC030", - "cohorts":["Other"], - "comment": "comment", - "container": "96 well plate", - "container_name": "CMMS", - "data_analysis": "mip-dna", - "data_delivery": "scout", - "family_name": "family1", - "father": "sample3", - "mother": "sample2", - "name": "sample1", - "panels": [ - "IEM" - ], - "phenotype_groups":["Phenotype-group"], - "phenotype_terms":["HP:0012747","HP:0025049"], - "priority": "standard", - "quantity": "220", - "sex": "female", - "source": "tissue (fresh frozen)", - "status": "affected", - "subject_id": "subject1", - "synopsis":"As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for.", - "tumour": true, - "volume": "1", - "well_position": "A:1" - }, - { - "age_at_sampling": "2.0", - "application": "WGSPCFC030", - "cohorts": ["Other"], - "comment": "this is a sample comment", - "container": "96 well plate", - "container_name": "CMMS", - "data_analysis": "mip-dna", - "data_delivery": "scout", - "family_name": "family1", - "name": "sample2", - "panels": [ - "IEM" - ], - "synopsis":"As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for.", - "phenotype_groups": [ - "" - ], - "phenotype_terms": [ - "" - ], - "priority": "standard", - "quantity": "220", - "sex": "female", - "source": "tissue (fresh frozen)", - "status": "affected", - "subject_id": "subject2", - "tumour": false, - "volume": "2", - "well_position": "B:1" - }, - { - "age_at_sampling": "3.0", - "application": "WGSPCFC030", - "cohorts": ["Other"], - "container": "96 well plate", - "container_name": "CMMS", - "data_analysis": "mip-dna", - "data_delivery": "scout", - "family_name": "family1", - "name": "sample3", - "panels": [ - "IEM" - ], - "phenotype_groups": [ - "" - ], - "phenotype_terms": [ - "" - ], - "priority": "standard", - "quantity": "220", - "sex": "male", - "source": "tissue (fresh frozen)", - "status": "affected", - "subject_id": "subject3", - "synopsis":"As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for.", - "tumour": false, - "volume": "3", - "well_position": "C:1" - }, - { - "age_at_sampling": "4.0", - "application": "WGSPCFC030", - "cohorts": ["Other"], - "container": "96 well plate", - "container_name": "CMMS", - "data_analysis": "mip-dna", - "data_delivery": "scout", - "family_name": "family2", - "name": "sample4", - "panels": [ - "IEM" - ], - "phenotype_groups": [ - "" - ], - "phenotype_terms": [ - "" - ], - "priority": "standard", - "quantity": "220", - "sex": "female", - "source": "tissue (fresh frozen)", - "status": "affected", - "subject_id": "subjectsample4", - "synopsis": "As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for.", - "tumour": false, - "volume": "4", - "well_position": "D:1" - } - ] -} - + "cases": [ + { + "cohorts": null, + "name": "MipCase1", + "panels": [ + "AID" + ], + "priority": "standard", + "samples": [ + { + "age_at_sampling": null, + "application": "WGSPCFC030", + "capture_kit": null, + "comment": "Test comment", + "concentration_ng_ul": null, + "container": "96 well plate", + "container_name": "MipPlate", + "control": null, + "data_analysis": null, + "data_delivery": null, + "elution_buffer": "Nuclease-free water", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "mother": null, + "name": "MipSample1", + "phenotype_groups": [ + "PhGroup" + ], + "phenotype_terms": [ + "PhTerm1", + "PhTerm2" + ], + "post_formalin_fixation_time": null, + "priority": null, + "quantity": null, + "reference_genome": null, + "require_qc_ok": false, + "sex": "male", + "source": "blood", + "source_comment": null, + "status": "affected", + "subject_id": "Subject1", + "tissue_block_size": null, + "tumour": false, + "tumour_purity": null, + "volume": 54, + "well_position": "A:1" + }, + { + "age_at_sampling": null, + "application": "WGSPCFC030", + "capture_kit": null, + "comment": "Test comment", + "concentration_ng_ul": null, + "container": "96 well plate", + "container_name": "MipPlate", + "control": null, + "data_analysis": null, + "data_delivery": null, + "elution_buffer": "Nuclease-free water", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "mother": null, + "name": "MipSample2", + "phenotype_groups": null, + "phenotype_terms": null, + "post_formalin_fixation_time": null, + "priority": null, + "quantity": null, + "reference_genome": null, + "require_qc_ok": false, + "sex": "female", + "source": "blood", + "source_comment": null, + "status": "affected", + "subject_id": "Subject2", + "tissue_block_size": null, + "tumour": false, + "tumour_purity": null, + "volume": 54, + "well_position": "B:1" + }, + { + "age_at_sampling": null, + "application": "WGSPCFC030", + "capture_kit": null, + "comment": null, + "concentration_ng_ul": null, + "container": "96 well plate", + "container_name": "MipPlate", + "control": null, + "data_analysis": null, + "data_delivery": null, + "elution_buffer": "Nuclease-free water", + "family_name": null, + "father": "MipSample1", + "formalin_fixation_time": null, + "mother": "MipSample2", + "name": "MipSample3", + "phenotype_groups": ["Phenotype-group"], + "phenotype_terms": ["HP:0012747", "HP:0025049"], + "post_formalin_fixation_time": null, + "priority": null, + "quantity": null, + "reference_genome": null, + "require_qc_ok": false, + "sex": "female", + "source": "blood", + "source_comment": null, + "status": "affected", + "subject_id": "Subject3", + "tissue_block_size": null, + "tumour": false, + "tumour_purity": null, + "volume": 54, + "well_position": "C:1" + } + ], + "synopsis": "This is a long string to test the buffer length because surely this is the best way to do this and there are no better ways of doing this." + }, + { + "cohorts": null, + "name": "MipCase2", + "panels": [ + "Ataxi" + ], + "priority": "standard", + "samples": [ + { + "age_at_sampling": null, + "application": "WGSWPFC030", + "capture_kit": null, + "comment": null, + "concentration_ng_ul": null, + "container": "96 well plate", + "container_name": "MipPlate", + "control": null, + "data_analysis": null, + "data_delivery": null, + "elution_buffer": "Nuclease-free water", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "mother": null, + "name": "MipSample4", + "phenotype_groups": null, + "phenotype_terms": null, + "post_formalin_fixation_time": null, + "priority": null, + "quantity": null, + "reference_genome": null, + "require_qc_ok": false, + "sex": "male", + "source": "blood", + "source_comment": null, + "status": "affected", + "subject_id": "Subject4", + "tissue_block_size": null, + "tumour": false, + "tumour_purity": null, + "volume": 54, + "well_position": "D:1" + } + ], + "synopsis": null + } + ], + "comment": null, + "customer": "cust000", + "data_analysis": "mip-dna", + "data_delivery": null, + "delivery_type": "analysis-scout", + "name": "MipOrder", + "project_type": "mip-dna", + "ticket": null, + "user_id": 1 +} \ No newline at end of file diff --git a/tests/fixtures/cgweb_orders/mip_rna.json b/tests/fixtures/cgweb_orders/mip_rna.json index 2b38b64498..b680270379 100644 --- a/tests/fixtures/cgweb_orders/mip_rna.json +++ b/tests/fixtures/cgweb_orders/mip_rna.json @@ -1,60 +1,95 @@ { - "name": "#123456", - "customer": "cust003", - "comment": "", - "samples": [ - { - "application": "RNAPOAR025", - "cohorts": [ - "" - ], - "container": "96 well plate", - "container_name": "CMMS", - "data_analysis": "mip-rna", - "data_delivery": "scout", - "family_name": "family1", - "name": "sample1-rna-t1", - "phenotype_groups": [ - "" - ], - "phenotype_terms": [ - "" - ], - "priority": "standard", - "quantity": "220", - "sex": "female", - "source": "tissue (fresh frozen)", - "synopsis": "", - "subject_id": "subject-sample1-rna-t1", - "volume": "1", - "well_position": "A:1" - }, - { - "application": "RNAPOAR025", - "cohorts": [ - "" - ], - "comment": "this is a sample comment", - "container": "96 well plate", - "container_name": "CMMS", - "data_analysis": "mip-rna", - "data_delivery": "scout", - "family_name": "family1", - "name": "sample1-rna-t2", - "phenotype_groups": [ - "" - ], - "phenotype_terms": [ - "" - ], - "priority": "standard", - "quantity": "220", - "sex": "female", - "source": "tissue (fresh frozen)", - "synopsis": "", - "subject_id": "subject-sample1-rna-t2", - "volume": "2", - "well_position": "B:1" - } - ] -} + "cases": [ + { + "cohorts": [ + "Cohort1", + "Cohort2" + ], + "name": "MipRNACase", + "panels": null, + "priority": "research", + "samples": [ + { + "age_at_sampling": "29", + "application": "RNAPOAR025", + "comment": "This is a sample comment", + "container": "96 well plate", + "container_name": "MipRNAContainer", + "control": "negative", + "elution_buffer": "Nuclease-free water", + "name": "MipRNASample1", + "phenotype_groups": [ + "phengroup1", + "phengroup2" + ], + "phenotype_terms": [ + "phenterm1", + "phenterm2" + ], + "processedAt": "2024-12-18T09:59:27.336Z", + "require_qc_ok": true, + "sex": "female", + "source": "buccal swab", + "subject_id": "miprnasubject1", + "volume": 54, + "well_position": "A:1" + }, + { + "age_at_sampling": "43", + "application": "RNAPOAR025", + "comment": "This is another sample comment", + "container": "96 well plate", + "container_name": "MipRNAContainer", + "elution_buffer": "Tris-HCl", + "name": "MipRNASample2", + "phenotype_groups": [ + "phengroup3" + ], + "phenotype_terms": [ + "phenterm4" + ], + "processedAt": "2024-12-18T09:59:27.337Z", + "require_qc_ok": true, + "sex": "female", + "source": "blood", + "subject_id": "miprnasubject2", + "volume": 54, + "well_position": "B:1" + } + ], + "synopsis": "This is a synopsis" + }, + { + "cohorts": null, + "name": "MipRNACase2", + "panels": null, + "priority": "research", + "samples": [ + { + "age_at_sampling": "66", + "application": "RNAPOAR025", + "container": "96 well plate", + "container_name": "MipRNAContainer", + "name": "MipRNASample3", + "processedAt": "2024-12-18T09:59:27.337Z", + "require_qc_ok": true, + "sex": "female", + "source": "blood", + "subject_id": "miprnasubject3", + "volume": 54, + "well_position": "C:1" + } + ], + "synopsis": null + } + ], + "comment": "This is an order comment", + "customer": "cust000", + "data_delivery": "analysis-scout", + "dataAnalysis": "mip-rna", + "delivery_type": "analysis-scout", + "name": "MipRnaOrder", + "project_type": "mip-rna", + "ticket": null, + "user_id": 1 +} \ No newline at end of file diff --git a/tests/fixtures/cgweb_orders/pacbio.json b/tests/fixtures/cgweb_orders/pacbio.json index 69ea4a7cab..300dc04b07 100644 --- a/tests/fixtures/cgweb_orders/pacbio.json +++ b/tests/fixtures/cgweb_orders/pacbio.json @@ -1,10 +1,13 @@ { "name": "PacbioOrder", "customer": "cust000", + "delivery_type": "bam", + "project_type": "pacbio-long-read", + "user_id": 0, "comment": "", "samples": [ { - "application": "WGSPCFC060", + "application": "LWPBELB070", "comment": "", "container": "Tube", "container_name": "prov1", @@ -13,16 +16,17 @@ "elution_buffer": "Nuclease-free water", "name": "prov1", "priority": "priority", - "sex": "female", + "sex": "male", "source": "blood", "tumour": false, "volume": "25", + "require_qc_ok": false, "well_position": "", "buffer": "Nuclease-free water", "subject_id": "subject2" }, { - "application": "WGSPCFC060", + "application": "LWPBELB070", "comment": "", "container": "Tube", "container_name": "prov2", @@ -35,9 +39,29 @@ "source": "cell line", "tumour": true, "volume": "35", + "require_qc_ok": false, "well_position": "", "buffer": "Nuclease-free water", "subject_id": "subject1" + }, + { + "application": "LWPBELB070", + "comment": "", + "container": "96 well plate", + "container_name": "plate1", + "data_analysis": "raw-data", + "data_delivery": "bam", + "elution_buffer": "Nuclease-free water", + "name": "prov3", + "priority": "priority", + "sex": "male", + "source": "blood", + "tumour": false, + "volume": "35", + "require_qc_ok": false, + "well_position": "A:1", + "buffer": "Nuclease-free water", + "subject_id": "subject3" } ] } \ No newline at end of file diff --git a/tests/fixtures/cgweb_orders/rml.json b/tests/fixtures/cgweb_orders/rml.json index 41ae4b810f..d79932115d 100644 --- a/tests/fixtures/cgweb_orders/rml.json +++ b/tests/fixtures/cgweb_orders/rml.json @@ -1,77 +1,249 @@ { - "name": "#123456", - "customer": "cust000", - "comment": "order comment", - "samples": [ - { - "application": "RMLP05R800", - "comment": "test comment", - "concentration": "5", - "concentration_sample": "6", - "control": "negative", - "data_analysis": "raw-data", - "data_delivery": "fastq", - "index": "IDT DupSeq 10 bp Set B", - "index_number": "1", - "index_sequence": "A01 - D701-D501 (ATTACTCG-TATAGCCT)", - "name": "sample1", - "pool": "pool-1", - "priority": "research", - "volume": "30" - }, - { - "application": "RMLP05R800", - "comment": "", - "concentration": "5", - "concentration_sample": "6", - "control": "positive", - "data_analysis": "raw-data", - "data_delivery": "fastq", - "index": "IDT DupSeq 10 bp Set B", - "index_number": "2", - "index_sequence": "B01 - D701-D502 (ATTACTCG-ATAGAGGC)", - "name": "sample2", - "pool": "pool-1", - "priority": "research", - "rml_plate_name": "", - "volume": "30", - "well_position_rml": "" - }, - { - "application": "RMLP05R800", - "comment": "test comment", - "concentration": "5", - "concentration_sample": "6", - "control": "", - "data_analysis": "raw-data", - "data_delivery": "fastq", - "index": "IDT DupSeq 10 bp Set B", - "index_number": "3", - "index_sequence": "A01 - D701-D501 (ATTACTCG-TATAGCCT)", - "name": "sample3", - "pool": "pool-2", - "priority": "research", - "rml_plate_name": "plate1", - "volume": "30", - "well_position_rml": "A:1" - }, - { - "application": "RMLP05R800", - "comment": "", - "concentration": "5", - "concentration_sample": "6", - "control": "", - "data_analysis": "raw-data", - "data_delivery": "fastq", - "index": "IDT DupSeq 10 bp Set B", - "index_number": "4", - "index_sequence": "B01 - D701-D502 (ATTACTCG-ATAGAGGC)", - "name": "sample4", - "pool": "pool-2", - "priority": "research", - "rml_plate_name": "plate1", - "volume": "30", - "well_position_rml": "A:1" - } - ] -} + "cases": [], + "comment": null, + "customer": "cust000", + "data_delivery": null, + "delivery_type": "fastq", + "name": "1604.19.rml", + "project_type": "rml", + "samples": [ + { + "age_at_sampling": null, + "application": "RMLP15R100", + "capture_kit": null, + "collection_date": null, + "comment": "comment", + "concentration": "2", + "concentration_ng_ul": null, + "concentration_sample": "4", + "container": null, + "container_name": null, + "control": "positive", + "custom_index": null, + "customer": "cust000", + "data_analysis": "RAW-DATA", + "data_delivery": "fastq", + "elution_buffer": null, + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": "IDT DupSeq 10 bp Set B", + "index_number": "3", + "index_sequence": "", + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "rmlsample1", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": "pool1", + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "research", + "quantity": null, + "reagent_label": "C01 IDT_10nt_568 (TGTGAGCGAA-AACTCCGATC)", + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": false, + "rml_plate_name": "plate1", + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "20", + "well_position": null, + "well_position_rml": "A:1" + }, + { + "age_at_sampling": null, + "application": "RMLP15R200", + "capture_kit": null, + "collection_date": null, + "comment": "", + "concentration": "2", + "concentration_ng_ul": null, + "concentration_sample": null, + "container": null, + "container_name": null, + "control": "negative", + "custom_index": null, + "customer": "cust000", + "data_analysis": "RAW-DATA", + "data_delivery": "fastq", + "elution_buffer": null, + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": "TruSeq DNA HT Dual-index (D7-D5)", + "index_number": "3", + "index_sequence": "C01 - D701-D503 (ATTACTCG-CCTATCCT)", + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "rmlsample2", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": "pool2", + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "clinical_trials", + "quantity": null, + "reagent_label": "C01 - D701-D503 (ATTACTCG-CCTATCCT)", + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": false, + "rml_plate_name": "rmlplate2", + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "21", + "well_position": null, + "well_position_rml": "A:1" + }, + { + "age_at_sampling": null, + "application": "RMLP15R400", + "capture_kit": null, + "collection_date": null, + "comment": "", + "concentration": "2", + "concentration_ng_ul": null, + "concentration_sample": null, + "container": null, + "container_name": null, + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "RAW-DATA", + "data_delivery": "fastq", + "elution_buffer": null, + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": "NEXTflex® v2 UDI Barcodes 1 - 96", + "index_number": "3", + "index_sequence": "UDI 3 (CGCTGCTC-GGCAGATC)", + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "rmlsample3", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": "pool3", + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "standard", + "quantity": null, + "reagent_label": "UDI3 (CGCTGCTC-GGCAGATC)", + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": false, + "rml_plate_name": "rmlplate3", + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "22", + "well_position": null, + "well_position_rml": "A:1" + }, + { + "age_at_sampling": null, + "application": "RMLP15R500", + "capture_kit": null, + "collection_date": null, + "comment": "", + "concentration": "2", + "concentration_ng_ul": null, + "concentration_sample": null, + "container": null, + "container_name": null, + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "RAW-DATA", + "data_delivery": "fastq", + "elution_buffer": null, + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": "NEXTflex® v2 UDI Barcodes 1 - 96", + "index_number": "3", + "index_sequence": "UDI 3 (CGCTGCTC-GGCAGATC)", + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "rmlsample4", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": "pool4", + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "priority", + "quantity": null, + "reagent_label": "UDI 3 (CGCTGCTC-GGCAGATC)", + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": false, + "rml_plate_name": "rmplate4", + "selection_criteria": null, + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "23", + "well_position": null, + "well_position_rml": "A:1" + } + ], + "ticket": null, + "user_id": 1 +} \ No newline at end of file diff --git a/tests/fixtures/cgweb_orders/rnafusion.json b/tests/fixtures/cgweb_orders/rnafusion.json index 203329ffc7..d9fcfaf8e4 100644 --- a/tests/fixtures/cgweb_orders/rnafusion.json +++ b/tests/fixtures/cgweb_orders/rnafusion.json @@ -1,60 +1,70 @@ { - "name": "#123456", - "customer": "cust003", - "comment": "", - "samples": [ - { - "application": "RNAPOAR025", - "cohorts": [ - "" - ], - "container": "96 well plate", - "container_name": "CMMS", - "data_analysis": "rnafusion", - "data_delivery": "scout", - "family_name": "family1", - "name": "sample1-rna-t1", - "phenotype_groups": [ - "" - ], - "phenotype_terms": [ - "" - ], - "priority": "standard", - "quantity": "220", - "sex": "female", - "source": "tissue (fresh frozen)", - "synopsis": "", - "subject_id": "subject-sample1-rna-t1", - "volume": "1", - "well_position": "A:1" - }, - { - "application": "RNAPOAR025", - "cohorts": [ - "" - ], - "comment": "this is a sample comment", - "container": "96 well plate", - "container_name": "CMMS", - "data_analysis": "rnafusion", - "data_delivery": "scout", - "family_name": "family2", - "name": "sample1-rna-t2", - "phenotype_groups": [ - "" - ], - "phenotype_terms": [ - "" - ], - "priority": "standard", - "quantity": "220", - "sex": "female", - "source": "tissue (fresh frozen)", - "synopsis": "", - "subject_id": "subject-sample1-rna-t2", - "volume": "2", - "well_position": "B:1" - } - ] + "name": "RNAfusion-order", + "customer": "cust000", + "delivery_type": "fastq-analysis", + "project_type": "rnafusion", + "comment": "", + "cases": [ + { + "cohorts": null, + "name": "RnaFusionCase", + "panels": null, + "priority": "standard", + "samples": [ + { + "application": "RNAPOAR025", + "cohorts": [""], + "container": "96 well plate", + "container_name": "CMMS", + "data_analysis": null, + "data_delivery": null, + "family_name": "family1", + "name": "sample1-rna-t1", + "phenotype_groups": [""], + "phenotype_terms": [""], + "priority": "standard", + "quantity": "220", + "require_qc_ok": false, + "sex": "female", + "source": "tissue (fresh frozen)", + "synopsis": "", + "subject_id": "subject-sample1-rna-t1", + "volume": "120", + "well_position": "A:1" + } + ], + "synopsis": "A synopsis" + }, + { + "cohorts": null, + "name": "RnaFusionCase2", + "panels": null, + "priority": "standard", + "samples": [ + { + "application": "RNAPOAR025", + "cohorts": [""], + "comment": "this is a sample comment", + "container": "96 well plate", + "container_name": "CMMS", + "data_analysis": null, + "data_delivery": null, + "family_name": "family2", + "name": "sample1-rna-t2", + "phenotype_groups": [""], + "phenotype_terms": [""], + "priority": "standard", + "quantity": "220", + "require_qc_ok": false, + "sex": "female", + "source": "tissue (fresh frozen)", + "synopsis": "", + "subject_id": "subject-sample1-rna-t2", + "volume": "20", + "well_position": "B:1" + } + ], + "synopsis": "A synopsis" + } + ] } diff --git a/tests/fixtures/cgweb_orders/sarscov2.json b/tests/fixtures/cgweb_orders/sarscov2.json index d8af7dbaa2..6bbbdd0cf0 100644 --- a/tests/fixtures/cgweb_orders/sarscov2.json +++ b/tests/fixtures/cgweb_orders/sarscov2.json @@ -1,161 +1,368 @@ { - "name": "Sars-CoV-2 samples", - "customer": "cust002", - "comment": "Order comment", - "samples": [ - { - "application": "VWGDPTR001", - "collection_date": "2021-05-05", - "comment": "plate comment", - "container": "96 well plate", - "container_name": "name of plate", - "data_analysis": "mutant", - "data_delivery": "fastq", - "elution_buffer": "Nuclease-free water", - "extraction_method": "MagNaPure 96", - "lab_code": "SE110 Växjö", - "name": "all-fields", - "organism": "SARS CoV-2", - "original_lab": "Karolinska University Hospital Solna", - "original_lab_address": "171 76 Stockholm", - "pre_processing_method": "COVIDSeq", - "primer": "Illumina Artic V3", - "priority": "research", - "reference_genome": "NC_111", - "region": "Stockholm", - "region_code": "01", - "require_qc_ok": true, - "selection_criteria": "1. Allmän övervakning", - "volume": "1", - "well_position": "A:1" - }, - { - "application": "VWGDPTR001", - "collection_date": "2021-05-05", - "comment": "", - "container": "Tube", - "container_name": "required-fields", - "data_analysis": "mutant", - "data_delivery": "fastq", - "elution_buffer": "Nuclease-free water", - "extraction_method": "MagNaPure 96", - "lab_code": "SE110 Växjö", - "name": "required-fields", - "organism": "SARS CoV-2", - "original_lab": "Karolinska University Hospital Solna", - "original_lab_address": "171 76 Stockholm", - "pre_processing_method": "COVIDSeq", - "primer": "Illumina Artic V3", - "priority": "standard", - "reference_genome": "NC_222", - "region": "Stockholm", - "region_code": "01", - "require_qc_ok": true, - "selection_criteria": "1. Allmän övervakning", - "volume": "2", - "well_position": "" - }, - { - "application": "VWGDPTR001", - "collection_date": "2021-05-05", - "comment": "", - "container": "96 well plate", - "container_name": "name of plate", - "data_analysis": "mutant", - "data_delivery": "fastq", - "elution_buffer": "Nuclease-free water", - "extraction_method": "MagNaPure 96", - "lab_code": "SE110 Växjö", - "name": "plate-fields", - "organism": "SARS CoV-2", - "original_lab": "Karolinska University Hospital Solna", - "original_lab_address": "171 76 Stockholm", - "pre_processing_method": "COVIDSeq", - "primer": "Nanopore Midnight V1", - "priority": "research", - "reference_genome": "NC_333", - "region": "Stockholm", - "region_code": "01", - "require_qc_ok": true, - "selection_criteria": "1. Allmän övervakning", - "volume": "3", - "well_position": "A:2" - }, - { - "application": "VWGDPTR001", - "collection_date": "2021-05-05", - "comment": "", - "container": "Tube", - "container_name": "other-species-fields", - "data_analysis": "mutant", - "data_delivery": "fastq", - "elution_buffer": "Nuclease-free water", - "extraction_method": "MagNaPure 96", - "lab_code": "SE110 Växjö", - "name": "other-species-fields", - "organism": "SARS CoV-2", - "original_lab": "Karolinska University Hospital Solna", - "original_lab_address": "171 76 Stockholm", - "pre_processing_method": "COVIDSeq", - "primer": "Nanopore Midnight V1", - "priority": "research", - "reference_genome": "NC_444", - "region": "Stockholm", - "region_code": "01", - "require_qc_ok": true, - "selection_criteria": "1. Allmän övervakning", - "volume": "4", - "well_position": "" - }, - { - "application": "VWGDPTR001", - "collection_date": "2021-05-05", - "comment": "optional comment", - "container": "Tube", - "container_name": "optional-fields", - "data_analysis": "mutant", - "data_delivery": "fastq", - "elution_buffer": "Nuclease-free water", - "extraction_method": "MagNaPure 96", - "lab_code": "SE110 Växjö", - "name": "optional-fields", - "organism": "SARS CoV-2", - "original_lab": "Karolinska University Hospital Solna", - "original_lab_address": "171 76 Stockholm", - "pre_processing_method": "COVIDSeq", - "primer": "Nanopore Midnight V1", - "priority": "research", - "reference_genome": "NC_555", - "region": "Stockholm", - "region_code": "01", - "require_qc_ok": false, - "selection_criteria": "1. Allmän övervakning", - "volume": "5", - "well_position": "" - }, - { - "application": "VWGDPTR001", - "collection_date": "2021-05-05", - "comment": "optional comment", - "container": "Tube", - "container_name": "optional-fields", - "data_analysis": "mutant", - "data_delivery": "fastq", - "elution_buffer": "Nuclease-free water", - "extraction_method": "MagNaPure 96", - "lab_code": "SE110 Växjö", - "name": "missing-region-code-and-original-lab-address", - "organism": "SARS CoV-2", - "original_lab": "Karolinska University Hospital Solna", - "pre_processing_method": "COVIDSeq", - "primer": "Nanopore Midnight V1", - "priority": "research", - "reference_genome": "NC_555", - "region": "Stockholm", - "require_qc_ok": false, - "selection_criteria": "1. Allmän övervakning", - "volume": "5", - "well_position": "" - } - ] -} + "cases": [], + "comment": null, + "customer": "cust000", + "data_analysis": null, + "data_delivery": null, + "delivery_type": "fastq-analysis", + "name": "2184.9.sarscov2", + "project_type": "sars-cov-2", + "samples": [ + { + "age_at_sampling": null, + "application": "VWGDPTR001", + "capture_kit": null, + "collection_date": "2021-05-05", + "comment": "sample comment", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": "2", + "container": "96 well plate", + "container_name": "plate1", + "control": "positive", + "custom_index": null, + "customer": "cust000", + "data_analysis": "Mutant", + "data_delivery": "fastq", + "elution_buffer": "Nuclease-free water", + "extraction_method": "MagNaPure 96", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "control-positive", + "organism": "SARS-CoV-2", + "organism_other": "", + "original_lab": "Karolinska University Hospital Solna", + "original_lab_address": "", + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": "COVIDSeq", + "primer": "Illumina Artic V3", + "priority": "research", + "quantity": "3", + "reagent_label": null, + "reference_genome": "NC_111", + "region": "Stockholm", + "region_code": "", + "require_qc_ok": null, + "rml_plate_name": null, + "selection_criteria": "Allmän övervakning", + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "20", + "well_position": "A:1", + "well_position_rml": null + }, + { + "age_at_sampling": null, + "application": "VWGDPTR001", + "capture_kit": null, + "collection_date": "2021-05-06", + "comment": "", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": null, + "container": "Tube", + "container_name": "", + "control": "negative", + "custom_index": null, + "customer": "cust000", + "data_analysis": "Mutant", + "data_delivery": "fastq", + "elution_buffer": "Other (specify in \"Comments\")", + "extraction_method": "EZ1", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "control-negative", + "organism": "SARS-CoV-2", + "organism_other": "", + "original_lab": "Synlab Medilab", + "original_lab_address": "183 53 Täby", + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": "Qiagen SARS-CoV-2 Primer Panel", + "primer": "Illumina Artic V3", + "priority": "research", + "quantity": null, + "reagent_label": null, + "reference_genome": "NC_000002", + "region": "Uppsala", + "region_code": "03", + "require_qc_ok": null, + "rml_plate_name": null, + "selection_criteria": "Allmän övervakning öppenvård", + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "21", + "well_position": "", + "well_position_rml": null + }, + { + "age_at_sampling": null, + "application": "VWGDPTR001", + "capture_kit": null, + "collection_date": "2021-05-07", + "comment": "", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": null, + "container": "Tube", + "container_name": "", + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "Mutant", + "data_delivery": "fastq", + "elution_buffer": "Tris-HCl", + "extraction_method": "QIAsymphony", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "sarscov2sample3", + "organism": "SARS-CoV-2", + "organism_other": "", + "original_lab": "A05 Diagnostics", + "original_lab_address": "171 65 Solna", + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": "COVIDSeq", + "primer": "Illumina Artic V3", + "priority": "research", + "quantity": null, + "reagent_label": null, + "reference_genome": "NC_000003", + "region": "Sörmland", + "region_code": "04", + "require_qc_ok": null, + "rml_plate_name": null, + "selection_criteria": "Allmän övervakning slutenvård", + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "22", + "well_position": "", + "well_position_rml": null + }, + { + "age_at_sampling": null, + "application": "VWGDPTR001", + "capture_kit": null, + "collection_date": "2021-05-08", + "comment": "", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": null, + "container": "Tube", + "container_name": "", + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "Mutant", + "data_delivery": "fastq", + "elution_buffer": "Tris-HCl", + "extraction_method": "Qiagen MagAttract", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "sarscov2sample4", + "organism": "SARS-CoV-2", + "organism_other": "", + "original_lab": "Karolinska University Hospital Solna", + "original_lab_address": "171 76 Stockholm", + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": "COVIDSeq", + "primer": "Illumina Artic V3", + "priority": "research", + "quantity": null, + "reagent_label": null, + "reference_genome": "NC_000004", + "region": "Östergötland", + "region_code": "05", + "require_qc_ok": null, + "rml_plate_name": null, + "selection_criteria": "Utlandsvistelse", + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "23", + "well_position": "", + "well_position_rml": null + }, + { + "age_at_sampling": null, + "application": "VWGDPTR001", + "capture_kit": null, + "collection_date": "2021-05-09", + "comment": "", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": null, + "container": "Tube", + "container_name": "", + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "Mutant", + "data_delivery": "fastq", + "elution_buffer": "Tris-HCl", + "extraction_method": "Other (specify in \"Comments\")", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "sarscov2sample5", + "organism": "SARS-CoV-2", + "organism_other": "", + "original_lab": "Karolinska University Hospital Huddinge", + "original_lab_address": "141 86 Stockholm", + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": "COVIDSeq", + "primer": "Illumina Artic V3", + "priority": "research", + "quantity": null, + "reagent_label": null, + "reference_genome": "NC_000005", + "region": "Jönköpings län", + "region_code": "06", + "require_qc_ok": null, + "rml_plate_name": null, + "selection_criteria": "Riktad insamling", + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "24", + "well_position": "", + "well_position_rml": null + }, + { + "age_at_sampling": null, + "application": "VWGDPTR001", + "capture_kit": null, + "collection_date": "2021-05-10", + "comment": "", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": null, + "container": "Tube", + "container_name": "", + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "Mutant", + "data_delivery": "fastq", + "elution_buffer": "Tris-HCl", + "extraction_method": "MagNaPure 96", + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "sarscov2sample6", + "organism": "other", + "organism_other": "unknown", + "original_lab": "LaboratorieMedicinskt Centrum Gotland", + "original_lab_address": "621 84 Visby", + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": "COVIDSeq", + "primer": "Illumina Artic V3", + "priority": "research", + "quantity": null, + "reagent_label": null, + "reference_genome": "NC_000006", + "region": "Kronoberg", + "region_code": "07", + "require_qc_ok": null, + "rml_plate_name": null, + "selection_criteria": "Utbrott", + "sex": null, + "source": null, + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": "25", + "well_position": "", + "well_position_rml": null + } + ], + "ticket": null, + "user_id": 1 +} \ No newline at end of file diff --git a/tests/fixtures/cgweb_orders/taxprofiler.json b/tests/fixtures/cgweb_orders/taxprofiler.json new file mode 100644 index 0000000000..59d56f6878 --- /dev/null +++ b/tests/fixtures/cgweb_orders/taxprofiler.json @@ -0,0 +1,191 @@ +{ + "cases": [], + "comment": null, + "customer": "cust000", + "data_analysis": null, + "data_delivery": null, + "delivery_type": "fastq-analysis", + "name": "taxprofiler-order", + "project_type": "taxprofiler", + "samples": [ + { + "age_at_sampling": null, + "application": "METWPFR030", + "capture_kit": null, + "collection_date": null, + "comment": "comments", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": null, + "container": "96 well plate", + "container_name": "plate1", + "control": "positive", + "custom_index": null, + "customer": "cust000", + "data_analysis": "TAXPROFILER", + "data_delivery": "fastq qc + analysis", + "elution_buffer": "Tris-HCl", + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "taxprofilersample1", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "research", + "quantity": "3", + "reagent_label": null, + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": false, + "rml_plate_name": null, + "selection_criteria": null, + "sex": null, + "source": "blood", + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": 20, + "well_position": "A:1", + "well_position_rml": null + }, + { + "age_at_sampling": null, + "application": "METWPFR030", + "capture_kit": null, + "collection_date": null, + "comment": "comments", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": null, + "container": "96 well plate", + "container_name": "plate1", + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "TAXPROFILER", + "data_delivery": "fastq qc + analysis", + "elution_buffer": "Tris-HCl", + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "taxprofilersample2", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "research", + "quantity": "6", + "reagent_label": null, + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": false, + "rml_plate_name": null, + "selection_criteria": null, + "sex": null, + "source": "blood", + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": 25, + "well_position": "B:2", + "well_position_rml": null + }, + { + "age_at_sampling": null, + "application": "METWPFR030", + "capture_kit": null, + "collection_date": null, + "comment": "comments", + "concentration": null, + "concentration_ng_ul": null, + "concentration_sample": null, + "container": "96 well plate", + "container_name": "plate1", + "control": "", + "custom_index": null, + "customer": "cust000", + "data_analysis": "TAXPROFILER", + "data_delivery": "fastq qc + analysis", + "elution_buffer": "Tris-HCl", + "extraction_method": null, + "family_name": null, + "father": null, + "formalin_fixation_time": null, + "index": null, + "index_number": null, + "index_sequence": null, + "internal_id": null, + "lab_code": null, + "mother": null, + "name": "taxprofilersample3", + "organism": null, + "organism_other": null, + "original_lab": null, + "original_lab_address": null, + "phenotype_groups": null, + "phenotype_terms": null, + "pool": null, + "post_formalin_fixation_time": null, + "pre_processing_method": null, + "primer": null, + "priority": "research", + "quantity": "5", + "reagent_label": null, + "reference_genome": null, + "region": null, + "region_code": null, + "require_qc_ok": false, + "rml_plate_name": null, + "selection_criteria": null, + "sex": null, + "source": "blood", + "status": null, + "subject_id": null, + "tissue_block_size": null, + "tumour": null, + "tumour_purity": null, + "verified_organism": null, + "volume": 22, + "well_position": "C:3", + "well_position_rml": null + } + ], + "ticket": null, + "user_id": 1 +} \ No newline at end of file diff --git a/tests/fixtures/cgweb_orders/tomte.json b/tests/fixtures/cgweb_orders/tomte.json index 4753dc93df..c20a313bca 100644 --- a/tests/fixtures/cgweb_orders/tomte.json +++ b/tests/fixtures/cgweb_orders/tomte.json @@ -1,131 +1,150 @@ { "name": "#123456", - "customer": "cust003", + "customer": "cust000", "comment": "", - "samples": [ + "delivery_type": "fastq-analysis", + "project_type": "tomte", + "cases": [ { - "age_at_sampling": "17.18192", - "application": "RNAPOAR025", - "cohorts":["Other"], - "comment": "comment", - "container": "96 well plate", - "container_name": "CMMS", - "data_analysis": "tomte", - "data_delivery": "scout", - "family_name": "family1", - "father": "sample3", - "mother": "sample2", - "name": "sample1", - "panels": [ - "IEM" - ], - "phenotype_groups":["Phenotype-group"], - "phenotype_terms":["HP:0012747","HP:0025049"], + "cohorts": null, + "name": "TomteCase", + "panels": ["OMIM-AUTO"], "priority": "standard", - "quantity": "220", - "reference_genome": "hg19", - "sex": "female", - "source": "fibroblast", - "status": "affected", - "subject_id": "subject1", - "synopsis":"As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for.", - "tumour": true, - "volume": "1", - "well_position": "A:1" - }, - { - "age_at_sampling": "2.0", - "application": "RNAPOAR025", - "cohorts": ["Other"], - "comment": "this is a sample comment", - "container": "96 well plate", - "container_name": "CMMS", - "data_analysis": "tomte", - "data_delivery": "scout", - "family_name": "family1", - "name": "sample2", - "panels": [ - "IEM" - ], - "synopsis":"As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for.", - "phenotype_groups": [ - "" - ], - "phenotype_terms": [ - "" - ], - "priority": "standard", - "quantity": "220", - "reference_genome": "hg19", - "sex": "female", - "source": "fibroblast", - "status": "affected", - "subject_id": "subject2", - "tumour": false, - "volume": "2", - "well_position": "B:1" - }, - { - "age_at_sampling": "3.0", - "application": "RNAPOAR025", - "cohorts": ["Other"], - "container": "96 well plate", - "container_name": "CMMS", - "data_analysis": "tomte", - "data_delivery": "scout", - "family_name": "family1", - "name": "sample3", - "panels": [ - "IEM" - ], - "phenotype_groups": [ - "" - ], - "phenotype_terms": [ - "" - ], - "priority": "standard", - "quantity": "220", - "reference_genome": "hg19", - "sex": "male", - "source": "tissue (fresh frozen)", - "status": "affected", - "subject_id": "subject3", - "synopsis":"As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for.", - "tumour": false, - "volume": "3", - "well_position": "C:1" - }, - { - "age_at_sampling": "4.0", - "application": "RNAPOAR025", - "cohorts": ["Other"], - "container": "96 well plate", - "container_name": "CMMS", - "data_analysis": "tomte", - "data_delivery": "scout", - "family_name": "family2", - "name": "sample4", - "panels": [ - "IEM" - ], - "phenotype_groups": [ - "" - ], - "phenotype_terms": [ - "" - ], - "priority": "standard", - "quantity": "220", - "reference_genome": "hg19", - "sex": "female", - "source": "tissue (fresh frozen)", - "status": "affected", - "subject_id": "subjectsample4", - "synopsis": "As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for.", - "tumour": false, - "volume": "4", - "well_position": "D:1" + "samples": [ + { + "age_at_sampling": "17.18192", + "application": "RNAPOAR025", + "cohorts": [ + "Other" + ], + "comment": "comment", + "container": "96 well plate", + "container_name": "CMMS", + "data_analysis": "tomte", + "data_delivery": "scout", + "family_name": "family1", + "father": "sample3", + "mother": "sample2", + "name": "sample1", + "panels": ["IEM"], + "phenotype_groups": [ + "Phenotype-group" + ], + "phenotype_terms": [ + "HP:0012747", + "HP:0025049" + ], + "priority": "standard", + "quantity": "220", + "reference_genome": "hg19", + "require_qc_ok": false, + "sex": "female", + "source": "fibroblast", + "status": "affected", + "subject_id": "subject1", + "synopsis": "As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for.", + "tumour": true, + "volume": "110", + "well_position": "A:1" + }, + { + "age_at_sampling": "2.0", + "application": "RNAPOAR025", + "cohorts": [ + "Other" + ], + "comment": "this is a sample comment", + "container": "96 well plate", + "container_name": "CMMS", + "data_analysis": "tomte", + "data_delivery": "scout", + "family_name": "family1", + "name": "sample2", + "panels": ["IEM"], + "synopsis": "As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for.", + "phenotype_groups": [ + "" + ], + "phenotype_terms": [ + "" + ], + "priority": "standard", + "quantity": "220", + "reference_genome": "hg19", + "require_qc_ok": false, + "sex": "female", + "source": "fibroblast", + "status": "affected", + "subject_id": "subject2", + "tumour": false, + "volume": "25", + "well_position": "B:1" + }, + { + "age_at_sampling": "3.0", + "application": "RNAPOAR025", + "cohorts": [ + "Other" + ], + "container": "96 well plate", + "container_name": "CMMS", + "data_analysis": "tomte", + "data_delivery": "scout", + "family_name": "family1", + "name": "sample3", + "panels": ["OMIM-AUTO"], + "phenotype_groups": [ + "" + ], + "phenotype_terms": [ + "" + ], + "priority": "standard", + "quantity": "220", + "reference_genome": "hg19", + "require_qc_ok": false, + "sex": "male", + "source": "tissue (fresh frozen)", + "status": "affected", + "subject_id": "subject3", + "synopsis": "As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for.", + "tumour": false, + "volume": "30", + "well_position": "C:1" + }, + { + "age_at_sampling": "4.0", + "application": "RNAPOAR025", + "cohorts": [ + "Other" + ], + "container": "96 well plate", + "container_name": "CMMS", + "data_analysis": "tomte", + "data_delivery": "scout", + "family_name": "family2", + "name": "sample4", + "panels": ["OMIM-AUTO"], + "phenotype_groups": [ + "" + ], + "phenotype_terms": [ + "" + ], + "priority": "standard", + "quantity": "220", + "reference_genome": "hg19", + "require_qc_ok": false, + "sex": "female", + "source": "tissue (fresh frozen)", + "status": "affected", + "subject_id": "subjectsample4", + "synopsis": "As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for.", + "tumour": false, + "volume": "45", + "well_position": "D:1" + } + ] } ] } diff --git a/tests/fixtures/invalid_cgweb_orders/balsamic_FAIL.json b/tests/fixtures/invalid_cgweb_orders/balsamic_FAIL.json new file mode 100644 index 0000000000..f75ae2ddcd --- /dev/null +++ b/tests/fixtures/invalid_cgweb_orders/balsamic_FAIL.json @@ -0,0 +1,61 @@ +{ + "cases": [ + { + "cohorts": null, + "name": "B", + "panels": null, + "priority": "FAIL", + "samples": [ + { + "age_at_sampling": "17.2", + "application": "PANKTTR100", + "capture_kit": "GMCKsolid", + "comment": "This is a sample comment", + "concentration_ng_ul": null, + "container": "96 well plate", + "container_name": "BalsamicPlate", + "control": "FAIL", + "data_analysis": null, + "data_delivery": null, + "elution_buffer": "Tris-HCl", + "family_name": null, + "father": null, + "formalin_fixation_time": "15", + "mother": null, + "name": "BalsamicSample", + "phenotype_groups": [ + "PhGroup" + ], + "phenotype_terms": [ + "PhTerm" + ], + "post_formalin_fixation_time": "3", + "priority": null, + "quantity": null, + "reference_genome": null, + "require_qc_ok": false, + "sex": "male", + "source": "cytology (FFPE)", + "source_comment": null, + "status": null, + "subject_id": "Subject1", + "tissue_block_size": "large", + "tumour": true, + "tumour_purity": "13", + "volume": 42, + "well_position": "A:1" + } + ], + "synopsis": "A synopsis" + } + ], + "comment": null, + "customer": "cust000", + "data_analysis": "balsamic", + "data_delivery": null, + "delivery_type": "analysis-scout", + "name": "BalsamicOrder", + "project_type": "balsamic", + "ticket": null, + "user_id": 1 +} \ No newline at end of file diff --git a/tests/meta/orders/conftest.py b/tests/meta/orders/conftest.py deleted file mode 100644 index ceb7665020..0000000000 --- a/tests/meta/orders/conftest.py +++ /dev/null @@ -1,44 +0,0 @@ -from pathlib import Path - -import pytest - -from cg.clients.freshdesk.freshdesk_client import FreshdeskClient -from cg.meta.orders import OrdersAPI -from cg.meta.orders.ticket_handler import TicketHandler -from cg.services.orders.submitters.order_submitter_registry import ( - OrderSubmitterRegistry, - setup_order_submitter_registry, -) -from cg.store.models import Order -from cg.store.store import Store -from tests.mocks.limsmock import MockLimsAPI - - -@pytest.fixture -def freshdesk_client(): - return FreshdeskClient(base_url="https://mock.freshdesk.com", api_key="mock_api_key") - - -@pytest.fixture(scope="function") -def orders_api( - base_store: Store, - ticket_handler: TicketHandler, - lims_api: MockLimsAPI, - order_submitter_registry: OrderSubmitterRegistry, -) -> OrdersAPI: - return OrdersAPI( - lims=lims_api, - status=base_store, - ticket_handler=ticket_handler, - submitter_registry=order_submitter_registry, - ) - - -@pytest.fixture -def ticket_handler(store: Store, freshdesk_client: FreshdeskClient) -> TicketHandler: - return TicketHandler(db=store, client=freshdesk_client, system_email_id=12345, env="production") - - -@pytest.fixture -def order_submitter_registry(base_store: Store, lims_api: MockLimsAPI) -> OrderSubmitterRegistry: - return setup_order_submitter_registry(lims=lims_api, status_db=base_store) diff --git a/tests/meta/orders/test_meta_orders_api.py b/tests/meta/orders/test_meta_orders_api.py deleted file mode 100644 index 2f1a9317f6..0000000000 --- a/tests/meta/orders/test_meta_orders_api.py +++ /dev/null @@ -1,640 +0,0 @@ -import datetime as dt -from unittest.mock import Mock, patch - -import pytest - -from cg.clients.freshdesk.models import TicketResponse -from cg.constants import DataDelivery -from cg.constants.constants import Workflow -from cg.constants.subject import Sex -from cg.exc import OrderError, TicketCreationError -from cg.meta.orders import OrdersAPI -from cg.models.orders.order import OrderIn, OrderType -from cg.models.orders.samples import MipDnaSample -from cg.services.orders.validate_order_services.validate_case_order import ValidateCaseOrderService -from cg.store.models import Case, Customer, Order, Pool, Sample -from cg.store.store import Store -from tests.store_helpers import StoreHelpers - - -def monkeypatch_process_lims(monkeypatch, order_data) -> None: - lims_project_data = {"id": "ADM1234", "date": dt.datetime.now()} - lims_map = {sample.name: f"ELH123A{index}" for index, sample in enumerate(order_data.samples)} - monkeypatch.setattr( - "cg.services.orders.order_lims_service.order_lims_service.OrderLimsService.process_lims", - lambda *args, **kwargs: (lims_project_data, lims_map), - ) - - -def mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id: str): - """Helper function to mock Freshdesk ticket creation.""" - mock_create_ticket.return_value = TicketResponse( - id=int(ticket_id), - description="This is a test description.", - subject="Support needed..", - status=2, - priority=1, - ) - - -def mock_freshdesk_reply_to_ticket(mock_reply_to_ticket): - """Helper function to mock Freshdesk reply to ticket.""" - mock_reply_to_ticket.return_value = None - - -def test_too_long_order_name(): - # GIVEN order with more than allowed characters name - long_name = "A super long order name that is longer than sixty-four characters." - assert len(long_name) > Sample.order.property.columns[0].type.length - - # WHEN placing it in the pydantic order model - # THEN an error is raised - with pytest.raises(ValueError): - OrderIn(name=long_name, customer="", comment="", samples=[]) - - -@pytest.mark.parametrize( - "order_type", - [ - OrderType.BALSAMIC, - OrderType.FASTQ, - OrderType.FLUFFY, - OrderType.METAGENOME, - OrderType.MICROSALT, - OrderType.MIP_DNA, - OrderType.MIP_RNA, - OrderType.RML, - OrderType.RNAFUSION, - OrderType.SARS_COV_2, - ], -) -def test_submit( - all_orders_to_submit: dict, - base_store: Store, - monkeypatch: pytest.MonkeyPatch, - order_type: OrderType, - orders_api: OrdersAPI, - ticket_id: str, - user_mail: str, - user_name: str, -): - with ( - patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" - ) as mock_create_ticket, - patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" - ) as mock_reply_to_ticket, - ): - mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id) - mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) - - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - monkeypatch_process_lims(monkeypatch, order_data) - - # GIVEN an order and an empty store - assert not base_store._get_query(table=Sample).first() - - # WHEN submitting the order - - result = orders_api.submit( - project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail - ) - - # THEN the result should contain the ticket number for the order - for record in result["records"]: - if isinstance(record, Pool): - assert record.ticket == ticket_id - elif isinstance(record, Sample): - assert record.original_ticket == ticket_id - elif isinstance(record, Case): - for link_obj in record.links: - assert link_obj.sample.original_ticket == ticket_id - - -@pytest.mark.parametrize( - "order_type", - [OrderType.MIP_DNA, OrderType.MIP_RNA, OrderType.BALSAMIC], -) -def test_submit_ticketexception( - all_orders_to_submit, - orders_api: OrdersAPI, - order_type: OrderType, - user_mail: str, - user_name: str, -): - # GIVEN a mock Freshdesk ticket creation that raises TicketCreationError - with patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket", - side_effect=TicketCreationError("ERROR"), - ): - # GIVEN an order that does not have a name (ticket_nr) - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - order_data.name = "dummy_name" - - # WHEN the order is submitted and a TicketCreationError raised - # THEN the TicketCreationError is not excepted - with pytest.raises(TicketCreationError): - orders_api.submit( - project=order_type, - order_in=order_data, - user_name=user_name, - user_mail=user_mail, - ) - - -@pytest.mark.parametrize( - "order_type", - [OrderType.MIP_DNA, OrderType.MIP_RNA, OrderType.BALSAMIC], -) -def test_submit_illegal_sample_customer( - all_orders_to_submit: dict, - monkeypatch: pytest.MonkeyPatch, - order_type: OrderType, - orders_api: OrdersAPI, - sample_store: Store, - user_mail: str, - user_name: str, -): - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - monkeypatch_process_lims(monkeypatch, order_data) - # GIVEN we have an order with a customer that is not in the same customer group as customer - # that the samples originate from - new_customer = sample_store.add_customer( - "customer999", - "customer 999", - scout_access=True, - invoice_address="dummy street", - invoice_reference="dummy nr", - ) - sample_store.session.add(new_customer) - existing_sample: Sample = sample_store._get_query(table=Sample).first() - existing_sample.customer = new_customer - sample_store.session.add(existing_sample) - sample_store.session.commit() - for sample in order_data.samples: - sample.internal_id = existing_sample.internal_id - - # WHEN calling submit - # THEN an OrderError should be raised on illegal customer - with pytest.raises(OrderError): - orders_api.submit( - project=order_type, - order_in=order_data, - user_name=user_name, - user_mail=user_mail, - ) - - -@pytest.mark.parametrize( - "order_type", - [OrderType.MIP_DNA, OrderType.MIP_RNA, OrderType.BALSAMIC], -) -def test_submit_scout_legal_sample_customer( - all_orders_to_submit: dict, - monkeypatch: pytest.MonkeyPatch, - order_type: OrderType, - orders_api: OrdersAPI, - sample_store: Store, - user_mail: str, - user_name: str, - ticket_id: str, -): - with ( - patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" - ) as mock_create_ticket, - patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" - ) as mock_reply_to_ticket, - ): - mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id) - mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - monkeypatch_process_lims(monkeypatch, order_data) - # GIVEN we have an order with a customer that is in the same customer group as customer - # that the samples originate from - collaboration = sample_store.add_collaboration("customer999only", "customer 999 only group") - sample_store.session.add(collaboration) - sample_customer = sample_store.add_customer( - "customer1", - "customer 1", - scout_access=True, - invoice_address="dummy street 1", - invoice_reference="dummy nr", - ) - order_customer = sample_store.add_customer( - "customer2", - "customer 2", - scout_access=True, - invoice_address="dummy street 2", - invoice_reference="dummy nr", - ) - sample_customer.collaborations.append(collaboration) - order_customer.collaborations.append(collaboration) - sample_store.session.add(sample_customer) - sample_store.session.add(order_customer) - existing_sample: Sample = sample_store._get_query(table=Sample).first() - existing_sample.customer = sample_customer - sample_store.session.commit() - order_data.customer = order_customer.internal_id - - for sample in order_data.samples: - sample.internal_id = existing_sample.internal_id - break - - # WHEN calling submit - # THEN an OrderError should not be raised on illegal customer - orders_api.submit( - project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail - ) - - -@pytest.mark.parametrize( - "order_type", - [OrderType.MIP_DNA, OrderType.MIP_RNA, OrderType.BALSAMIC], -) -def test_submit_duplicate_sample_case_name( - all_orders_to_submit: dict, - monkeypatch: pytest.MonkeyPatch, - order_type: OrderType, - orders_api: OrdersAPI, - ticket_id: str, - user_mail: str, - user_name: str, -): - # GIVEN we have an order with a case that is already in the database - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - store = orders_api.status - customer: Customer = store.get_customer_by_internal_id(customer_internal_id=order_data.customer) - for sample in order_data.samples: - case_id = sample.family_name - if not store.get_case_by_name_and_customer(customer=customer, case_name=case_id): - case: Case = store.add_case( - data_analysis=Workflow.MIP_DNA, - data_delivery=DataDelivery.SCOUT, - name=case_id, - ticket=ticket_id, - ) - case.customer = customer - store.session.add(case) - store.session.commit() - assert store.get_case_by_name_and_customer(customer=customer, case_name=case_id) - - monkeypatch_process_lims(monkeypatch, order_data) - - # WHEN calling submit - # THEN an OrderError should be raised on duplicate case name - with pytest.raises(OrderError): - orders_api.submit( - project=order_type, - order_in=order_data, - user_name=user_name, - user_mail=user_mail, - ) - - -@pytest.mark.parametrize( - "order_type", - [OrderType.FLUFFY], -) -def test_submit_fluffy_duplicate_sample_case_name( - all_orders_to_submit: dict, - monkeypatch: pytest.MonkeyPatch, - order_type: OrderType, - orders_api: OrdersAPI, - user_mail: str, - user_name: str, - ticket_id: str, -): - with ( - patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" - ) as mock_create_ticket, - patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" - ) as mock_reply_to_ticket, - ): - mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id) - mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) - # GIVEN we have an order with a case that is already in the database - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - monkeypatch_process_lims(monkeypatch, order_data) - - orders_api.submit( - project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail - ) - - # WHEN calling submit - # THEN an OrderError should be raised on duplicate case name - with pytest.raises(OrderError): - orders_api.submit( - project=order_type, - order_in=order_data, - user_name=user_name, - user_mail=user_mail, - ) - - -def test_submit_unique_sample_case_name( - orders_api: OrdersAPI, - mip_order_to_submit: dict, - user_name: str, - user_mail: str, - monkeypatch: pytest.MonkeyPatch, - ticket_id: str, -): - with ( - patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" - ) as mock_create_ticket, - patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" - ) as mock_reply_to_ticket, - ): - mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id) - mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) - - # GIVEN we have an order with a case that is not existing in the database - order_data = OrderIn.parse_obj(obj=mip_order_to_submit, project=OrderType.MIP_DNA) - - store = orders_api.status - - sample: MipDnaSample - for sample in order_data.samples: - case_id = sample.family_name - customer: Customer = store.get_customer_by_internal_id( - customer_internal_id=order_data.customer - ) - assert not store.get_case_by_name_and_customer(customer=customer, case_name=case_id) - - monkeypatch_process_lims(monkeypatch, order_data) - - # WHEN calling submit - orders_api.submit( - project=OrderType.MIP_DNA, - order_in=order_data, - user_name=user_name, - user_mail=user_mail, - ) - - # Then no exception about duplicate names should be thrown - - -def test_validate_sex_inconsistent_sex( - orders_api: OrdersAPI, mip_order_to_submit: dict, helpers: StoreHelpers -): - # GIVEN we have an order with a sample that is already in the database but with different sex - order_data = OrderIn.parse_obj(mip_order_to_submit, project=OrderType.MIP_DNA) - store = orders_api.status - customer: Customer = store.get_customer_by_internal_id(customer_internal_id=order_data.customer) - - # add sample with different sex than in order - sample: MipDnaSample - for sample in order_data.samples: - sample_obj: Sample = helpers.add_sample( - store=store, - customer_id=customer.internal_id, - sex=Sex.MALE if sample.sex == Sex.FEMALE else Sex.FEMALE, - name=sample.name, - subject_id=sample.subject_id, - ) - store.session.add(sample_obj) - store.session.commit() - assert sample_obj.sex != sample.sex - - validator = ValidateCaseOrderService(status_db=orders_api.status) - - # WHEN calling _validate_sex - # THEN an OrderError should be raised on non-matching sex - with pytest.raises(OrderError): - validator._validate_subject_sex(samples=order_data.samples, customer_id=order_data.customer) - - -def test_validate_sex_consistent_sex( - orders_api: OrdersAPI, mip_order_to_submit: dict, helpers: StoreHelpers -): - # GIVEN we have an order with a sample that is already in the database and with same gender - order_data = OrderIn.parse_obj(mip_order_to_submit, project=OrderType.MIP_DNA) - store = orders_api.status - customer: Customer = store.get_customer_by_internal_id(customer_internal_id=order_data.customer) - - # add sample with different sex than in order - sample: MipDnaSample - for sample in order_data.samples: - sample_obj: Sample = helpers.add_sample( - store=store, - customer_id=customer.internal_id, - sex=sample.sex, - name=sample.name, - subject_id=sample.subject_id, - ) - store.session.add(sample_obj) - store.session.commit() - assert sample_obj.sex == sample.sex - - validator = ValidateCaseOrderService(status_db=orders_api.status) - - # WHEN calling _validate_sex - validator._validate_subject_sex(samples=order_data.samples, customer_id=order_data.customer) - - # THEN no OrderError should be raised on non-matching sex - - -def test_validate_sex_unknown_existing_sex( - orders_api: OrdersAPI, mip_order_to_submit: dict, helpers: StoreHelpers -): - # GIVEN we have an order with a sample that is already in the database and with different gender but the existing is - # of type "unknown" - order_data = OrderIn.parse_obj(mip_order_to_submit, project=OrderType.MIP_DNA) - store = orders_api.status - customer: Customer = store.get_customer_by_internal_id(customer_internal_id=order_data.customer) - - # add sample with different sex than in order - sample: MipDnaSample - for sample in order_data.samples: - sample_obj: Sample = helpers.add_sample( - store=store, - customer_id=customer.internal_id, - sex=Sex.UNKNOWN, - name=sample.name, - subject_id=sample.subject_id, - ) - store.session.add(sample_obj) - store.session.commit() - assert sample_obj.sex != sample.sex - - validator = ValidateCaseOrderService(status_db=orders_api.status) - - # WHEN calling _validate_sex - validator._validate_subject_sex(samples=order_data.samples, customer_id=order_data.customer) - - # THEN no OrderError should be raised on non-matching sex - - -def test_validate_sex_unknown_new_sex( - orders_api: OrdersAPI, mip_order_to_submit: dict, helpers: StoreHelpers -): - # GIVEN we have an order with a sample that is already in the database and with different gender but the new is of - # type "unknown" - order_data = OrderIn.parse_obj(mip_order_to_submit, project=OrderType.MIP_DNA) - store = orders_api.status - customer: Customer = store.get_customer_by_internal_id(customer_internal_id=order_data.customer) - - # add sample with different sex than in order - for sample in order_data.samples: - sample_obj: Sample = helpers.add_sample( - store=store, - customer_id=customer.internal_id, - sex=sample.sex, - name=sample.name, - subject_id=sample.subject_id, - ) - sample.sex = "unknown" - store.session.add(sample_obj) - store.session.commit() - - for sample in order_data.samples: - assert sample_obj.sex != sample.sex - - validator = ValidateCaseOrderService(status_db=orders_api.status) - - # WHEN calling _validate_sex - validator._validate_subject_sex(samples=order_data.samples, customer_id=order_data.customer) - - # THEN no OrderError should be raised on non-matching sex - - -@pytest.mark.parametrize( - "order_type", - [ - OrderType.BALSAMIC, - OrderType.FASTQ, - OrderType.FLUFFY, - OrderType.METAGENOME, - OrderType.MICROSALT, - OrderType.MIP_DNA, - OrderType.MIP_RNA, - OrderType.RML, - OrderType.SARS_COV_2, - ], -) -def test_submit_unique_sample_name( - all_orders_to_submit: dict, - monkeypatch: pytest.MonkeyPatch, - order_type: OrderType, - orders_api: OrdersAPI, - user_mail: str, - user_name: str, - ticket_id: str, -): - with ( - patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" - ) as mock_create_ticket, - patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" - ) as mock_reply_to_ticket, - ): - mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id) - mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) - # GIVEN we have an order with a sample that is not existing in the database - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - store = orders_api.status - assert not store._get_query(table=Sample).first() - - monkeypatch_process_lims(monkeypatch, order_data) - - # WHEN calling submit - orders_api.submit( - project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail - ) - - # Then no exception about duplicate names should be thrown - - -@pytest.mark.parametrize( - "order_type", - [OrderType.SARS_COV_2, OrderType.METAGENOME], -) -def test_sarscov2_submit_duplicate_sample_name( - all_orders_to_submit: dict, - helpers: StoreHelpers, - monkeypatch: pytest.MonkeyPatch, - order_type: OrderType, - orders_api: OrdersAPI, - user_mail: str, - user_name: str, -): - # GIVEN we have an order with samples that is already in the database - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - monkeypatch_process_lims(monkeypatch, order_data) - store_samples_with_names_from_order(orders_api.status, helpers, order_data) - - # WHEN calling submit - # THEN an OrderError should be raised on duplicate sample name - with pytest.raises(OrderError): - orders_api.submit( - project=order_type, - order_in=order_data, - user_name=user_name, - user_mail=user_mail, - ) - - -def store_samples_with_names_from_order(store: Store, helpers: StoreHelpers, order_data: OrderIn): - customer: Customer = store.get_customer_by_internal_id(customer_internal_id=order_data.customer) - for sample in order_data.samples: - sample_name = sample.name - if not store.get_sample_by_customer_and_name( - customer_entry_id=[customer.id], sample_name=sample_name - ): - sample_obj = helpers.add_sample( - store=store, customer_id=customer.internal_id, name=sample_name - ) - store.session.add(sample_obj) - store.session.commit() - - -@pytest.mark.parametrize( - "order_type", - [ - OrderType.BALSAMIC, - OrderType.FASTQ, - OrderType.MICROSALT, - OrderType.MIP_DNA, - OrderType.MIP_RNA, - OrderType.RML, - ], -) -def test_not_sarscov2_submit_duplicate_sample_name( - all_orders_to_submit: dict, - helpers: StoreHelpers, - monkeypatch: pytest.MonkeyPatch, - order_type: OrderType, - orders_api: OrdersAPI, - user_mail: str, - user_name: str, - ticket_id: str, -): - with ( - patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" - ) as mock_create_ticket, - patch( - "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" - ) as mock_reply_to_ticket, - ): - mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id) - mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) - # GIVEN we have an order with samples that is already in the database - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - monkeypatch_process_lims(monkeypatch, order_data) - store_samples_with_names_from_order(orders_api.status, helpers, order_data) - - # WHEN calling submit - orders_api.submit( - project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail - ) - - # THEN no OrderError should be raised on duplicate sample name diff --git a/tests/services/orders/lims_service/test_order_lims_service.py b/tests/services/orders/lims_service/test_order_lims_service.py new file mode 100644 index 0000000000..af30e418f4 --- /dev/null +++ b/tests/services/orders/lims_service/test_order_lims_service.py @@ -0,0 +1,223 @@ +import pytest + +from cg.constants import Workflow +from cg.models.lims.sample import LimsSample +from cg.services.orders.lims_service.service import OrderLimsService +from cg.services.orders.validation.workflows.balsamic.models.order import BalsamicOrder +from cg.services.orders.validation.workflows.fastq.models.order import FastqOrder +from cg.services.orders.validation.workflows.fluffy.models.order import FluffyOrder +from cg.services.orders.validation.workflows.microsalt.models.order import MicrosaltOrder +from cg.services.orders.validation.workflows.mip_dna.models.order import MipDnaOrder +from cg.services.orders.validation.workflows.mutant.models.order import MutantOrder +from cg.services.orders.validation.workflows.rml.models.order import RmlOrder + + +def test_to_lims_mip(mip_dna_order_to_submit): + # GIVEN a scout order for a trio + order_data = MipDnaOrder.model_validate(mip_dna_order_to_submit) + # WHEN parsing the order to format for LIMS import + new_samples = [sample for _, _, sample in order_data.enumerated_new_samples] + samples: list[LimsSample] = OrderLimsService._build_lims_sample( + customer="cust003", + samples=new_samples, + workflow=Workflow.MIP_DNA, + delivery_type=order_data.delivery_type, + skip_reception_control=order_data.skip_reception_control, + ) + + # THEN it should list all samples + assert len(samples) == 4 + + # THEN container should be 96 well plate for all samples + assert {sample.container for sample in samples} == {"96 well plate"} + + # THEN container names should be the same for all samples + container_names = {sample.container_name for sample in samples if sample.container_name} + assert container_names == {"MipPlate"} + + # ... and pick out relevant UDFs + first_sample: LimsSample = samples[0] + assert first_sample.well_position == "A:1" + assert first_sample.udfs.priority == "standard" + assert first_sample.udfs.application == "WGSPCFC030" + assert first_sample.udfs.source == "blood" + assert first_sample.udfs.customer == "cust003" + assert first_sample.udfs.volume == "54" + + # THEN assert that the comment of a sample is a string + assert isinstance(samples[1].udfs.comment, str) + + +def test_to_lims_fastq(fastq_order_to_submit): + # GIVEN a fastq order for two samples; normal vs. tumour + order_data = FastqOrder.model_validate(fastq_order_to_submit) + + # WHEN parsing the order to format for LIMS + samples: list[LimsSample] = OrderLimsService._build_lims_sample( + customer="dummyCust", + samples=order_data.samples, + workflow=Workflow.RAW_DATA, + delivery_type=order_data.delivery_type, + skip_reception_control=order_data.skip_reception_control, + ) + + # THEN should "work" + assert len(samples) == 2 + normal_sample = samples[0] + tumour_sample = samples[1] + # ... and pick out relevant UDF values + assert normal_sample.udfs.tumour is False + assert tumour_sample.udfs.tumour is True + assert normal_sample.udfs.volume == "54" + + +@pytest.mark.xfail(reason="RML sample container validation not working") +def test_to_lims_rml(rml_order_to_submit: dict): + # GIVEN a rml order for four samples + order_data = RmlOrder.model_validate(rml_order_to_submit) + + # WHEN parsing for LIMS + samples: list[LimsSample] = OrderLimsService._build_lims_sample( + customer="cust000", + samples=order_data.samples, + workflow=Workflow.RAW_DATA, + delivery_type=order_data.delivery_type, + ) + + # THEN it should have found the same number of samples + assert len(samples) == 4 + + # THEN the relevant UDFs are parsed + first_sample = samples[0] + assert first_sample.udfs.pool == "pool1" + assert first_sample.udfs.application.startswith("RML") + assert first_sample.udfs.index == "IDT DupSeq 10 bp Set B" + assert first_sample.udfs.index_number == "3" + assert first_sample.udfs.rml_plate_name == "plate1" + assert first_sample.udfs.well_position_rml == "A:1" + + +@pytest.mark.xfail(reason="Fluffy sample container validation not working") +def test_to_lims_fluffy(fluffy_order_to_submit: dict): + # GIVEN a Fluffy order for four samples + order_data = FluffyOrder.model_validate(fluffy_order_to_submit) + + # WHEN parsing for LIMS + samples: list[LimsSample] = OrderLimsService._build_lims_sample( + customer="cust000", + samples=order_data.samples, + workflow=Workflow.FLUFFY, + delivery_type=order_data.delivery_type, + ) + + # THEN it should have found the same number of samples + assert len(samples) == 4 + + # THEN the relevant UDFs are parsed + first_sample = samples[0] + assert first_sample.udfs.pool == "pool1" + assert first_sample.udfs.application.startswith("RML") + assert first_sample.udfs.index == "IDT DupSeq 10 bp Set B" + assert first_sample.udfs.index_number == "3" + assert first_sample.udfs.rml_plate_name == "plate1" + assert first_sample.udfs.well_position_rml == "A:1" + + +def test_to_lims_microbial(microbial_order_to_submit: dict): + # GIVEN a microbial order for three samples + order_data = MicrosaltOrder.model_validate(microbial_order_to_submit) + + # WHEN parsing for LIMS + samples: list[LimsSample] = OrderLimsService._build_lims_sample( + customer="cust000", + samples=order_data.samples, + workflow=Workflow.MICROSALT, + delivery_type=order_data.delivery_type, + skip_reception_control=order_data.skip_reception_control, + ) + # THEN it should "work" + + assert len(samples) == 5 + # ... and pick out relevant UDFs + first_sample = samples[0].dict() + assert first_sample["udfs"]["priority"] == "research" + assert first_sample["udfs"]["organism"] == "C. jejuni" + assert first_sample["udfs"]["reference_genome"] == "NC_000001" + assert first_sample["udfs"]["extraction_method"] == "MagNaPure 96" + assert first_sample["udfs"]["volume"] == "20" + + +def test_to_lims_sarscov2(mutant_order: MutantOrder): + # GIVEN a sarscov2 order for samples + + # WHEN parsing for LIMS + samples: list[LimsSample] = OrderLimsService._build_lims_sample( + customer="cust000", + samples=mutant_order.samples, + workflow=Workflow.MUTANT, + delivery_type=mutant_order.delivery_type, + skip_reception_control=mutant_order.skip_reception_control, + ) + + # THEN it should have found the same number of samples + assert len(samples) == 6 + # ... and pick out relevant UDFs + first_sample = samples[0].dict() + assert first_sample["udfs"]["collection_date"] == "2021-05-05" + assert first_sample["udfs"]["extraction_method"] == "MagNaPure 96" + assert first_sample["udfs"]["lab_code"] == "SE100 Karolinska" + assert first_sample["udfs"]["organism"] == "SARS-CoV-2" + assert first_sample["udfs"]["original_lab"] == "Karolinska University Hospital Solna" + assert first_sample["udfs"]["original_lab_address"] == "171 76 Stockholm" + assert first_sample["udfs"]["pre_processing_method"] == "COVIDSeq" + assert first_sample["udfs"]["priority"] == "research" + assert first_sample["udfs"]["reference_genome"] == "NC_111" + assert first_sample["udfs"]["region"] == "Stockholm" + assert first_sample["udfs"]["region_code"] == "01" + assert first_sample["udfs"]["selection_criteria"] == "Allmän övervakning" + assert first_sample["udfs"]["volume"] == "20" + + +def test_to_lims_balsamic(balsamic_order_to_submit: dict): + # GIVEN a cancer order for a sample + order_data = BalsamicOrder.model_validate(balsamic_order_to_submit) + + new_samples = [sample for _, _, sample in order_data.enumerated_new_samples] + # WHEN parsing the order to format for LIMS import + samples: list[LimsSample] = OrderLimsService._build_lims_sample( + customer="cust000", + samples=new_samples, + workflow=Workflow.BALSAMIC, + delivery_type=order_data.delivery_type, + skip_reception_control=order_data.skip_reception_control, + ) + # THEN it should list all samples + + assert len(samples) == 1 + # ... and determine the container, container name, and well position + + container_names = {sample.container_name for sample in samples if sample.container_name} + + # ... and pick out relevant UDFs + first_sample = samples[0].dict() + assert first_sample["name"] == "BalsamicSample" + assert {sample.container for sample in samples} == set(["96 well plate"]) + assert first_sample["udfs"]["data_analysis"] == Workflow.BALSAMIC + assert first_sample["udfs"]["application"] == "PANKTTR100" + assert first_sample["udfs"]["sex"] == "M" + assert first_sample["udfs"]["customer"] == "cust000" + assert first_sample["udfs"]["source"] == "cytology (FFPE)" + assert first_sample["udfs"]["volume"] == "42" + assert first_sample["udfs"]["priority"] == "standard" + + assert container_names == set(["BalsamicPlate"]) + assert first_sample["well_position"] == "A:1" + assert first_sample["udfs"]["tumour"] is True + assert first_sample["udfs"]["capture_kit"] == "GMCKsolid" + assert first_sample["udfs"]["tumour_purity"] == "13" + + assert first_sample["udfs"]["formalin_fixation_time"] == "15" + assert first_sample["udfs"]["post_formalin_fixation_time"] == "3" + assert first_sample["udfs"]["tissue_block_size"] == "large" + + assert first_sample["udfs"]["comment"] == "This is a sample comment" diff --git a/tests/services/orders/order_lims_service/test_order_lims_service.py b/tests/services/orders/order_lims_service/test_order_lims_service.py deleted file mode 100644 index c22499242b..0000000000 --- a/tests/services/orders/order_lims_service/test_order_lims_service.py +++ /dev/null @@ -1,179 +0,0 @@ -import pytest - -from cg.constants import Workflow -from cg.services.orders.order_lims_service.order_lims_service import OrderLimsService -from cg.models.lims.sample import LimsSample -from cg.models.orders.constants import OrderType -from cg.models.orders.order import OrderIn - - -def test_to_lims_mip(mip_order_to_submit): - # GIVEN a scout order for a trio - order_data = OrderIn.parse_obj(obj=mip_order_to_submit, project=OrderType.MIP_DNA) - # WHEN parsing the order to format for LIMS import - samples: list[LimsSample] = OrderLimsService._build_lims_sample( - customer="cust003", samples=order_data.samples - ) - - # THEN it should list all samples - assert len(samples) == 4 - - # THEN container should be 96 well plate for all samples - assert {sample.container for sample in samples} == {"96 well plate"} - - # THEN container names should be the same for all samples - container_names = {sample.container_name for sample in samples if sample.container_name} - assert container_names == {"CMMS"} - - # ... and pick out relevant UDFs - first_sample: LimsSample = samples[0] - assert first_sample.well_position == "A:1" - assert first_sample.udfs.family_name == "family1" - assert first_sample.udfs.priority == "standard" - assert first_sample.udfs.application == "WGSPCFC030" - assert first_sample.udfs.source == "tissue (fresh frozen)" - assert first_sample.udfs.quantity == "220" - assert first_sample.udfs.customer == "cust003" - assert first_sample.udfs.volume == "1.0" - - # THEN assert that the comment of a sample is a string - assert isinstance(samples[1].udfs.comment, str) - - -def test_to_lims_fastq(fastq_order_to_submit): - # GIVEN a fastq order for two samples; normal vs. tumour - order_data = OrderIn.parse_obj(obj=fastq_order_to_submit, project=OrderType.FASTQ) - - # WHEN parsing the order to format for LIMS - samples: list[LimsSample] = OrderLimsService._build_lims_sample( - customer="dummyCust", samples=order_data.samples - ) - - # THEN should "work" - assert len(samples) == 2 - normal_sample = samples[0] - tumor_sample = samples[1] - # ... and pick out relevant UDF values - assert normal_sample.udfs.tumour is False - assert tumor_sample.udfs.tumour is True - assert normal_sample.udfs.volume == "1" - - -def test_to_lims_rml(rml_order_to_submit): - # GIVEN a rml order for four samples - order_data = OrderIn.parse_obj(obj=rml_order_to_submit, project=OrderType.RML) - - # WHEN parsing for LIMS - samples: list[LimsSample] = OrderLimsService._build_lims_sample( - customer="dummyCust", samples=order_data.samples - ) - - # THEN it should have found the same number of samples - assert len(samples) == 4 - # ... and pick out relevant UDFs - first_sample = samples[0] - assert first_sample.udfs.pool == "pool-1" - assert first_sample.udfs.volume == "30" - assert first_sample.udfs.concentration == "5.0" - assert first_sample.udfs.index == "IDT DupSeq 10 bp Set B" - assert first_sample.udfs.index_number == "1" - - -def test_to_lims_microbial(microbial_order_to_submit): - # GIVEN a microbial order for three samples - order_data = OrderIn.parse_obj(obj=microbial_order_to_submit, project=OrderType.MICROSALT) - - # WHEN parsing for LIMS - samples: list[LimsSample] = OrderLimsService._build_lims_sample( - customer="cust000", samples=order_data.samples - ) - # THEN it should "work" - - assert len(samples) == 5 - # ... and pick out relevant UDFs - first_sample = samples[0].dict() - assert first_sample["udfs"]["priority"] == "research" - assert first_sample["udfs"]["organism"] == "M.upium" - assert first_sample["udfs"]["reference_genome"] == "NC_111" - assert ( - first_sample["udfs"]["extraction_method"] == "MagNaPure 96 (contact Clinical Genomics " - "before submission)" - ) - assert first_sample["udfs"]["volume"] == "1" - - -def test_to_lims_sarscov2(sarscov2_order_to_submit): - # GIVEN a sarscov2 order for samples - order_data = OrderIn.parse_obj(obj=sarscov2_order_to_submit, project=OrderType.SARS_COV_2) - - # WHEN parsing for LIMS - samples: list[LimsSample] = OrderLimsService._build_lims_sample( - customer="cust000", samples=order_data.samples - ) - - # THEN it should have found the same number of samples - assert len(samples) == 6 - # ... and pick out relevant UDFs - first_sample = samples[0].dict() - assert first_sample["udfs"]["collection_date"] == "2021-05-05" - assert first_sample["udfs"]["extraction_method"] == "MagNaPure 96" - assert first_sample["udfs"]["lab_code"] == "SE100 Karolinska" - assert first_sample["udfs"]["organism"] == "SARS CoV-2" - assert first_sample["udfs"]["original_lab"] == "Karolinska University Hospital Solna" - assert first_sample["udfs"]["original_lab_address"] == "171 76 Stockholm" - assert first_sample["udfs"]["pre_processing_method"] == "COVIDSeq" - assert first_sample["udfs"]["priority"] == "research" - assert first_sample["udfs"]["reference_genome"] == "NC_111" - assert first_sample["udfs"]["region"] == "Stockholm" - assert first_sample["udfs"]["region_code"] == "01" - assert first_sample["udfs"]["selection_criteria"] == "1. Allmän övervakning" - assert first_sample["udfs"]["volume"] == "1" - - -@pytest.mark.parametrize( - "project", [OrderType.BALSAMIC, OrderType.BALSAMIC_UMI, OrderType.BALSAMIC_QC] -) -def test_to_lims_balsamic(balsamic_order_to_submit, project): - # GIVEN a cancer order for a sample - order_data = OrderIn.parse_obj(obj=balsamic_order_to_submit, project=project) - - # WHEN parsing the order to format for LIMS import - samples: list[LimsSample] = OrderLimsService._build_lims_sample( - customer="cust000", samples=order_data.samples - ) - # THEN it should list all samples - - assert len(samples) == 1 - # ... and determine the container, container name, and well position - - container_names = {sample.container_name for sample in samples if sample.container_name} - - # ... and pick out relevant UDFs - first_sample = samples[0].dict() - assert first_sample["name"] == "s1" - assert {sample.container for sample in samples} == set(["96 well plate"]) - assert first_sample["udfs"]["data_analysis"] in [ - Workflow.BALSAMIC, - Workflow.BALSAMIC_QC, - Workflow.BALSAMIC_UMI, - ] - assert first_sample["udfs"]["application"] == "WGSPCFC030" - assert first_sample["udfs"]["sex"] == "M" - assert first_sample["udfs"]["family_name"] == "family1" - assert first_sample["udfs"]["customer"] == "cust000" - assert first_sample["udfs"]["source"] == "blood" - assert first_sample["udfs"]["volume"] == "1.0" - assert first_sample["udfs"]["priority"] == "standard" - - assert container_names == set(["p1"]) - assert first_sample["well_position"] == "A:1" - assert first_sample["udfs"]["tumour"] is True - assert first_sample["udfs"]["capture_kit"] == "other" - assert first_sample["udfs"]["tumour_purity"] == "75" - - assert first_sample["udfs"]["formalin_fixation_time"] == "1" - assert first_sample["udfs"]["post_formalin_fixation_time"] == "2" - assert first_sample["udfs"]["tissue_block_size"] == "small" - - assert first_sample["udfs"]["quantity"] == "2" - assert first_sample["udfs"]["comment"] == "other Elution buffer" diff --git a/tests/services/orders/order_store_service/test_fastq_order_service.py b/tests/services/orders/order_store_service/test_fastq_order_service.py deleted file mode 100644 index c26c217cea..0000000000 --- a/tests/services/orders/order_store_service/test_fastq_order_service.py +++ /dev/null @@ -1,202 +0,0 @@ -import datetime as dt - -import pytest - -from cg.constants import DataDelivery, Workflow -from cg.constants.sequencing import SeqLibraryPrepCategory -from cg.exc import OrderError -from cg.models.orders.order import OrderIn, OrderType -from cg.services.orders.store_order_services.constants import MAF_ORDER_ID -from cg.services.orders.store_order_services.store_fastq_order_service import StoreFastqOrderService -from cg.store.models import Application, Case, Sample, Order -from cg.store.store import Store - - -def test_samples_to_status( - fastq_order_to_submit: dict, store_fastq_order_service: StoreFastqOrderService -): - # GIVEN fastq order with two samples - order = OrderIn.parse_obj(fastq_order_to_submit, OrderType.FASTQ) - - # WHEN parsing for status - data: dict = store_fastq_order_service.order_to_status(order=order) - - # THEN it should pick out samples and relevant information - assert len(data["samples"]) == 2 - first_sample: dict = data["samples"][0] - assert first_sample["name"] == "prov1" - assert first_sample["application"] == "WGSPCFC060" - assert first_sample["priority"] == "priority" - assert first_sample["tumour"] is False - assert first_sample["volume"] == "1" - - # THEN the other sample is a tumour - assert data["samples"][1]["tumour"] is True - - -def test_store_samples( - base_store: Store, - fastq_status_data: dict, - ticket_id: str, - store_fastq_order_service: StoreFastqOrderService, -): - # GIVEN a basic store with no samples and a fastq order - assert not base_store._get_query(table=Sample).first() - assert base_store._get_query(table=Case).count() == 0 - - # WHEN storing the order - new_samples = store_fastq_order_service.store_items_in_status( - customer_id=fastq_status_data["customer"], - order=fastq_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=fastq_status_data["samples"], - ) - - # THEN it should store the samples and create a case for each sample - assert len(new_samples) == 2 - assert len(base_store._get_query(table=Sample).all()) == 2 - assert base_store._get_query(table=Case).count() == 2 - first_sample = new_samples[0] - assert len(first_sample.links) == 2 - family_link = first_sample.links[0] - assert family_link.case in base_store.get_cases() - assert family_link.case.data_analysis - assert family_link.case.data_delivery in [DataDelivery.FASTQ, DataDelivery.NO_DELIVERY] - - # THEN a MAF case should be added to the MAF orders - maf_order: Order = base_store.get_order_by_id(MAF_ORDER_ID) - assert len(maf_order.cases) == 1 - - -def test_store_samples_sex_stored( - base_store: Store, - fastq_status_data: dict, - ticket_id: str, - store_fastq_order_service: StoreFastqOrderService, -): - # GIVEN a basic store with no samples and a fastq order - assert not base_store._get_query(table=Sample).first() - assert base_store._get_query(table=Case).count() == 0 - - # WHEN storing the order - new_samples = store_fastq_order_service.store_items_in_status( - customer_id=fastq_status_data["customer"], - order=fastq_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=fastq_status_data["samples"], - ) - - # THEN the sample sex should be stored - assert new_samples[0].sex == "male" - - -def test_store_fastq_samples_non_tumour_wgs_to_mip( - base_store: Store, fastq_status_data: dict, store_fastq_order_service: StoreFastqOrderService -): - # GIVEN a basic store with no samples and a non-tumour fastq order as wgs - assert not base_store._get_query(table=Sample).first() - assert base_store._get_query(table=Case).count() == 0 - base_store.get_application_by_tag( - fastq_status_data["samples"][0]["application"] - ).prep_category = SeqLibraryPrepCategory.WHOLE_GENOME_SEQUENCING - fastq_status_data["samples"][0]["tumour"] = False - - # WHEN storing the order - new_samples = store_fastq_order_service.store_items_in_status( - customer_id=fastq_status_data["customer"], - order=fastq_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=1234348, - items=fastq_status_data["samples"], - ) - - # THEN the analysis for the case should be MAF - assert new_samples[0].links[0].case.data_analysis == Workflow.MIP_DNA - - -def test_store_fastq_samples_tumour_wgs_to_fastq( - base_store: Store, - fastq_status_data: dict, - ticket_id: str, - store_fastq_order_service: StoreFastqOrderService, -): - # GIVEN a basic store with no samples and a tumour fastq order as wgs - assert not base_store._get_query(table=Sample).first() - assert base_store._get_query(table=Case).count() == 0 - base_store.get_application_by_tag( - fastq_status_data["samples"][0]["application"] - ).prep_category = SeqLibraryPrepCategory.WHOLE_GENOME_SEQUENCING - fastq_status_data["samples"][0]["tumour"] = True - - # WHEN storing the order - new_samples = store_fastq_order_service.store_items_in_status( - customer_id=fastq_status_data["customer"], - order=fastq_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=fastq_status_data["samples"], - ) - - # THEN the analysis for the case should be RAW_DATA - assert new_samples[0].links[0].case.data_analysis == Workflow.RAW_DATA - - -def test_store_fastq_samples_non_wgs_as_fastq( - base_store: Store, - fastq_status_data: dict, - ticket_id: str, - store_fastq_order_service: StoreFastqOrderService, -): - # GIVEN a basic store with no samples and a fastq order as non wgs - assert not base_store._get_query(table=Sample).first() - assert base_store._get_query(table=Case).count() == 0 - non_wgs_prep_category = SeqLibraryPrepCategory.WHOLE_EXOME_SEQUENCING - - non_wgs_applications = base_store._get_query(table=Application).filter( - Application.prep_category == non_wgs_prep_category - ) - - assert non_wgs_applications - - for sample in fastq_status_data["samples"]: - sample["application"] = non_wgs_applications[0].tag - - # WHEN storing the order - new_samples = store_fastq_order_service.store_items_in_status( - customer_id=fastq_status_data["customer"], - order=fastq_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=fastq_status_data["samples"], - ) - - # THEN the analysis for the case should be RAW_DATA (none) - assert new_samples[0].links[0].case.data_analysis == Workflow.RAW_DATA - - -def test_store_samples_bad_apptag( - base_store: Store, - fastq_status_data: dict, - ticket_id: str, - store_fastq_order_service: StoreFastqOrderService, -): - # GIVEN a basic store with no samples and a fastq order - assert not base_store._get_query(table=Sample).first() - assert base_store._get_query(table=Case).count() == 0 - - # GIVEN a non-existing application tag - for sample in fastq_status_data["samples"]: - sample["application"] = "nonexistingtag" - - # THEN it should raise OrderError - with pytest.raises(OrderError): - # WHEN storing the order - store_fastq_order_service.store_items_in_status( - customer_id=fastq_status_data["customer"], - order=fastq_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=fastq_status_data["samples"], - ) diff --git a/tests/services/orders/order_store_service/test_generic_order_store_service.py b/tests/services/orders/order_store_service/test_generic_order_store_service.py deleted file mode 100644 index 56459bb7c8..0000000000 --- a/tests/services/orders/order_store_service/test_generic_order_store_service.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Module to test the StoreGenericOrderService class.""" - -import datetime as dt -import math -from copy import deepcopy - -from cg.constants import DataDelivery, Priority, Workflow -from cg.models.orders.order import OrderIn, OrderType -from cg.services.orders.store_order_services.store_case_order import ( - StoreCaseOrderService, -) -from cg.store.models import Sample -from cg.store.store import Store - - -def test_cases_to_status( - mip_order_to_submit: dict, store_generic_order_service: StoreCaseOrderService -): - # GIVEN a scout order with a trio case - project: OrderType = OrderType.MIP_DNA - order = OrderIn.parse_obj(mip_order_to_submit, project=project) - - # WHEN parsing for status - data = store_generic_order_service.order_to_status(order=order) - - # THEN it should pick out the case - assert len(data["families"]) == 2 - family = data["families"][0] - assert family["name"] == "family1" - assert family["data_analysis"] == Workflow.MIP_DNA - assert family["data_delivery"] == str(DataDelivery.SCOUT) - assert family["priority"] == Priority.standard.name - assert family["cohorts"] == ["Other"] - assert ( - family["synopsis"] - == "As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for." - ) - assert set(family["panels"]) == {"IEM"} - assert len(family["samples"]) == 3 - - first_sample = family["samples"][0] - assert math.isclose(first_sample["age_at_sampling"], 17.18192, rel_tol=1e-09, abs_tol=1e-09) - assert first_sample["name"] == "sample1" - assert first_sample["application"] == "WGSPCFC030" - assert first_sample["phenotype_groups"] == ["Phenotype-group"] - assert first_sample["phenotype_terms"] == ["HP:0012747", "HP:0025049"] - assert first_sample["sex"] == "female" - assert first_sample["status"] == "affected" - assert first_sample["subject_id"] == "subject1" - assert first_sample["mother"] == "sample2" - assert first_sample["father"] == "sample3" - - # ... second sample has a comment - assert isinstance(family["samples"][1]["comment"], str) - - -def test_cases_to_status_synopsis( - mip_order_to_submit: dict, store_generic_order_service: StoreCaseOrderService -): - # GIVEN a scout order with a trio case where synopsis is None - modified_order: dict = deepcopy(mip_order_to_submit) - for sample in modified_order["samples"]: - sample["synopsis"] = None - - project: OrderType = OrderType.MIP_DNA - order = OrderIn.parse_obj(mip_order_to_submit, project=project) - - # WHEN parsing for status - store_generic_order_service.order_to_status(order=order) - - # THEN No exception should have been raised on synopsis - - -def test_store_mip( - base_store: Store, - mip_status_data: dict, - ticket_id: str, - store_generic_order_service: StoreCaseOrderService, -): - # GIVEN a basic store with no samples or nothing in it + scout order - assert not base_store._get_query(table=Sample).first() - assert not base_store.get_cases() - - # WHEN storing the order - new_families = store_generic_order_service.store_items_in_status( - customer_id=mip_status_data["customer"], - order=mip_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=mip_status_data["families"], - ) - - # THEN it should create and link samples and the case - assert len(new_families) == 2 - new_case = new_families[0] - assert new_case.name == "family1" - assert set(new_case.panels) == {"IEM"} - assert new_case.priority_human == Priority.standard.name - - assert len(new_case.links) == 3 - new_link = new_case.links[0] - assert new_case.data_analysis == Workflow.MIP_DNA - assert new_case.data_delivery == str(DataDelivery.SCOUT) - assert set(new_case.cohorts) == {"Other"} - assert ( - new_case.synopsis - == "As for the synopsis it will be this overly complex sentence to prove that the synopsis field might in fact be a very long string, which we should be prepared for." - ) - assert new_link.status == "affected" - assert new_link.mother.name == "sample2" - assert new_link.father.name == "sample3" - assert new_link.sample.name == "sample1" - assert new_link.sample.sex == "female" - assert new_link.sample.application_version.application.tag == "WGSPCFC030" - assert new_link.sample.is_tumour - assert isinstance(new_case.links[1].sample.comment, str) - - assert set(new_link.sample.phenotype_groups) == {"Phenotype-group"} - assert set(new_link.sample.phenotype_terms) == {"HP:0012747", "HP:0025049"} - assert new_link.sample.subject_id == "subject1" - assert math.isclose(new_link.sample.age_at_sampling, 17.18192, rel_tol=1e-09, abs_tol=1e-09) - - -def test_store_mip_rna( - base_store: Store, - mip_rna_status_data, - ticket_id: str, - store_generic_order_service: StoreCaseOrderService, -): - # GIVEN a basic store with no samples or nothing in it + rna order - rna_application_tag = "RNAPOAR025" - assert not base_store._get_query(table=Sample).first() - assert not base_store.get_cases() - assert base_store.get_application_by_tag(tag=rna_application_tag) - - # WHEN storing the order - new_cases = store_generic_order_service.store_items_in_status( - customer_id=mip_rna_status_data["customer"], - order=mip_rna_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=mip_rna_status_data["families"], - ) - - # THEN it should create and link samples and the casing - assert len(new_cases) == 1 - new_casing = new_cases[0] - - assert len(new_casing.links) == 2 - new_link = new_casing.links[0] - assert new_casing.data_analysis == Workflow.MIP_RNA - assert new_casing.data_delivery == str(DataDelivery.SCOUT) - assert new_link.sample.name == "sample1-rna-t1" - assert new_link.sample.application_version.application.tag == rna_application_tag - - -def test_store_cancer_samples( - base_store: Store, - balsamic_status_data: dict, - ticket_id: str, - store_generic_order_service: StoreCaseOrderService, -): - - # GIVEN a basic store with no samples and a cancer order - assert not base_store._get_query(table=Sample).first() - assert not base_store.get_cases() - - # WHEN storing the order - new_families = store_generic_order_service.store_items_in_status( - customer_id=balsamic_status_data["customer"], - order=balsamic_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=balsamic_status_data["families"], - ) - - # THEN it should create and link samples and the case - assert len(new_families) == 1 - new_case = new_families[0] - assert new_case.name == "family1" - assert new_case.data_analysis in [ - Workflow.BALSAMIC, - Workflow.BALSAMIC_QC, - Workflow.BALSAMIC_UMI, - ] - assert new_case.data_delivery == str(DataDelivery.FASTQ_ANALYSIS_SCOUT) - assert not set(new_case.panels) - assert new_case.priority_human == Priority.standard.name - - assert len(new_case.links) == 1 - new_link = new_case.links[0] - assert new_link.sample.name == "s1" - assert new_link.sample.sex == "male" - assert new_link.sample.application_version.application.tag == "WGSPCFC030" - assert new_link.sample.comment == "other Elution buffer" - assert new_link.sample.is_tumour diff --git a/tests/services/orders/order_store_service/test_metagenome_store_service.py b/tests/services/orders/order_store_service/test_metagenome_store_service.py deleted file mode 100644 index 7d8b91573f..0000000000 --- a/tests/services/orders/order_store_service/test_metagenome_store_service.py +++ /dev/null @@ -1,51 +0,0 @@ -import datetime as dt -import pytest -from cg.exc import OrderError -from cg.models.orders.order import OrderIn, OrderType -from cg.services.orders.store_order_services.store_metagenome_order import ( - StoreMetagenomeOrderService, -) -from cg.store.models import Sample -from cg.store.store import Store - - -def test_metagenome_to_status( - metagenome_order_to_submit: dict, store_metagenome_order_service: StoreMetagenomeOrderService -): - # GIVEN metagenome order with two samples - order = OrderIn.parse_obj(metagenome_order_to_submit, OrderType.METAGENOME) - - # WHEN parsing for status - data = store_metagenome_order_service.order_to_status(order=order) - case = data["families"][0] - # THEN it should pick out samples and relevant information - assert len(case["samples"]) == 2 - first_sample = case["samples"][0] - assert first_sample["name"] == "Bristol" - assert first_sample["application"] == "METLIFR020" - assert first_sample["priority"] == "standard" - assert first_sample["volume"] == "1.0" - - -def test_store_metagenome_samples_bad_apptag( - base_store: Store, - metagenome_status_data: dict, - ticket_id: str, - store_metagenome_order_service: StoreMetagenomeOrderService, -): - # GIVEN a basic store with no samples and a metagenome order - assert not base_store._get_query(table=Sample).first() - - for sample in metagenome_status_data["families"][0]["samples"]: - sample["application"] = "nonexistingtag" - - # THEN it should raise OrderError - with pytest.raises(OrderError): - # WHEN storing the order - store_metagenome_order_service.store_items_in_status( - customer_id=metagenome_status_data["customer"], - order=metagenome_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=metagenome_status_data["families"], - ) diff --git a/tests/services/orders/order_store_service/test_microbial_fastq_order_store_service.py b/tests/services/orders/order_store_service/test_microbial_fastq_order_store_service.py deleted file mode 100644 index f0764ebac3..0000000000 --- a/tests/services/orders/order_store_service/test_microbial_fastq_order_store_service.py +++ /dev/null @@ -1,68 +0,0 @@ -from datetime import datetime - -from cg.constants import DataDelivery, Workflow -from cg.models.orders.constants import OrderType -from cg.models.orders.order import OrderIn -from cg.services.orders.store_order_services.store_microbial_fastq_order_service import ( - StoreMicrobialFastqOrderService, -) -from cg.store.models import Case, Sample - - -def test_microbial_samples_to_status( - microbial_fastq_order_to_submit: dict, - store_microbial_fastq_order_service: StoreMicrobialFastqOrderService, -): - # GIVEN microbial order with three samples - order = OrderIn.parse_obj(microbial_fastq_order_to_submit, OrderType.MICROBIAL_FASTQ) - - # WHEN parsing for status - data = store_microbial_fastq_order_service.order_to_status(order=order) - - # THEN it should pick out samples and relevant information - assert len(data["samples"]) == 2 - assert data["customer"] == "cust002" - assert data["order"] == "Microbial Fastq order" - assert data["comment"] == "" - - # THEN first sample should contain all the relevant data from the microbial order - sample_data = data["samples"][0] - assert sample_data["priority"] == "priority" - assert sample_data["name"] == "prov1" - assert sample_data.get("internal_id") is None - assert sample_data["application"] == "WGSPCFC060" - assert sample_data["comment"] == "sample comment" - assert sample_data["volume"] == "1" - assert sample_data["data_analysis"] == Workflow.MICROSALT - assert sample_data["data_delivery"] == str(DataDelivery.FASTQ) - - -def test_store_samples( - microbial_fastq_status_data: dict, - ticket_id: str, - store_microbial_fastq_order_service: StoreMicrobialFastqOrderService, -): - # GIVEN a basic store with no samples and a fastq order - - assert not store_microbial_fastq_order_service.status_db._get_query(table=Sample).first() - assert store_microbial_fastq_order_service.status_db._get_query(table=Case).count() == 0 - - # WHEN storing the order - new_samples = store_microbial_fastq_order_service.store_items_in_status( - customer_id=microbial_fastq_status_data["customer"], - order=microbial_fastq_status_data["order"], - ordered=datetime.now(), - ticket_id=ticket_id, - items=microbial_fastq_status_data["samples"], - ) - - # THEN it should store the samples and create a case for each sample - assert len(new_samples) == 2 - assert len(store_microbial_fastq_order_service.status_db._get_query(table=Sample).all()) == 2 - assert store_microbial_fastq_order_service.status_db._get_query(table=Case).count() == 2 - first_sample = new_samples[0] - assert len(first_sample.links) == 1 - case_link = first_sample.links[0] - assert case_link.case in store_microbial_fastq_order_service.status_db.get_cases() - assert case_link.case.data_analysis - assert case_link.case.data_delivery == DataDelivery.FASTQ diff --git a/tests/services/orders/order_store_service/test_microbial_store_order_service.py b/tests/services/orders/order_store_service/test_microbial_store_order_service.py deleted file mode 100644 index c638fd43dd..0000000000 --- a/tests/services/orders/order_store_service/test_microbial_store_order_service.py +++ /dev/null @@ -1,247 +0,0 @@ -from cg.services.orders.store_order_services.store_microbial_order import StoreMicrobialOrderService -from cg.store.models import Case -import datetime as dt -from cg.constants import DataDelivery -from cg.constants.constants import Workflow -from cg.models.orders.constants import OrderType -from cg.models.orders.order import OrderIn -from cg.models.orders.sample_base import ControlEnum -from cg.models.orders.samples import SarsCov2Sample -from cg.store.models import Customer, Sample -from cg.store.store import Store - - -def test_microbial_samples_to_status( - microbial_order_to_submit: dict, store_microbial_order_service: StoreMicrobialOrderService -): - # GIVEN microbial order with three samples - order = OrderIn.parse_obj(microbial_order_to_submit, OrderType.MICROSALT) - - # WHEN parsing for status - data = store_microbial_order_service.order_to_status(order=order) - - # THEN it should pick out samples and relevant information - assert len(data["samples"]) == 5 - assert data["customer"] == "cust002" - assert data["order"] == "Microbial samples" - assert data["comment"] == "Order comment" - assert data["data_analysis"] == Workflow.MICROSALT - assert data["data_delivery"] == str(DataDelivery.FASTQ) - - # THEN first sample should contain all the relevant data from the microbial order - sample_data = data["samples"][0] - assert sample_data["priority"] == "research" - assert sample_data["name"] == "all-fields" - assert sample_data.get("internal_id") is None - assert sample_data["organism_id"] == "M.upium" - assert sample_data["reference_genome"] == "NC_111" - assert sample_data["application"] == "MWRNXTR003" - assert sample_data["comment"] == "plate comment" - assert sample_data["volume"] == "1" - - -def test_sarscov2_samples_to_status( - sarscov2_order_to_submit: dict, store_microbial_order_service: StoreMicrobialOrderService -): - # GIVEN sarscov2 order with three samples - order = OrderIn.parse_obj(sarscov2_order_to_submit, OrderType.SARS_COV_2) - - # WHEN parsing for status - data = store_microbial_order_service.order_to_status(order=order) - - # THEN it should pick out samples and relevant information - assert len(data["samples"]) == 6 - assert data["customer"] == "cust002" - assert data["order"] == "Sars-CoV-2 samples" - assert data["comment"] == "Order comment" - assert data["data_analysis"] == Workflow.MUTANT - assert data["data_delivery"] == str(DataDelivery.FASTQ) - - # THEN first sample should contain all the relevant data from the microbial order - sample_data = data["samples"][0] - assert sample_data.get("internal_id") is None - assert sample_data["priority"] == "research" - assert sample_data["application"] == "VWGDPTR001" - assert sample_data["comment"] == "plate comment" - assert sample_data["name"] == "all-fields" - assert sample_data["organism_id"] == "SARS CoV-2" - assert sample_data["reference_genome"] == "NC_111" - assert sample_data["volume"] == "1" - - -def test_store_microbial_samples( - base_store: Store, - microbial_status_data: dict, - ticket_id: str, - store_microbial_order_service: StoreMicrobialOrderService, -): - # GIVEN a basic store with no samples and a microbial order and one Organism - assert not base_store._get_query(table=Sample).first() - assert base_store._get_query(table=Case).count() == 0 - assert base_store.get_all_organisms().count() == 1 - - # WHEN storing the order - new_samples = store_microbial_order_service.store_items_in_status( - customer_id=microbial_status_data["customer"], - order=microbial_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=microbial_status_data["samples"], - comment="", - data_analysis=Workflow.MICROSALT, - data_delivery=DataDelivery.FASTQ_QC, - ) - - # THEN it should store the samples under a case (case) and the used previously unknown - # organisms - assert new_samples - assert base_store._get_query(table=Case).count() == 1 - assert len(new_samples) == 5 - assert len(base_store._get_query(table=Sample).all()) == 5 - assert base_store.get_all_organisms().count() == 3 - - -def test_store_microbial_case_data_analysis_stored( - base_store: Store, - microbial_status_data: dict, - ticket_id: str, - store_microbial_order_service: StoreMicrobialOrderService, -): - # GIVEN a basic store with no samples and a microbial order and one Organism - assert not base_store._get_query(table=Sample).first() - assert base_store._get_query(table=Case).count() == 0 - - # WHEN storing the order - store_microbial_order_service.store_items_in_status( - customer_id=microbial_status_data["customer"], - order=microbial_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=microbial_status_data["samples"], - comment="", - data_analysis=Workflow.MICROSALT, - data_delivery=DataDelivery.FASTQ_QC, - ) - - # THEN store the samples under a case with the microbial data_analysis type on case level - assert len(base_store._get_query(table=Sample).all()) > 0 - assert base_store._get_query(table=Case).count() == 1 - - microbial_case = base_store.get_cases()[0] - assert microbial_case.data_analysis == Workflow.MICROSALT - assert microbial_case.data_delivery == str(DataDelivery.FASTQ_QC) - - -def test_store_microbial_sample_priority( - base_store: Store, - microbial_status_data: dict, - ticket_id: str, - store_microbial_order_service: StoreMicrobialOrderService, -): - # GIVEN a basic store with no samples - assert not base_store._get_query(table=Sample).first() - - # WHEN storing the order - store_microbial_order_service.store_items_in_status( - customer_id=microbial_status_data["customer"], - order=microbial_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=microbial_status_data["samples"], - comment="", - data_analysis=Workflow.MICROSALT, - data_delivery=DataDelivery.FASTQ_QC, - ) - - # THEN it should store the sample priority - assert len(base_store._get_query(table=Sample).all()) > 0 - microbial_sample = base_store._get_query(table=Sample).first() - - assert microbial_sample.priority_human == "research" - - -def test_order_to_status_control_exists( - sarscov2_order_to_submit: dict, - base_store: Store, - store_microbial_order_service: StoreMicrobialOrderService, -): - # GIVEN sarscov2 order with three samples - order: OrderIn = OrderIn.parse_obj(sarscov2_order_to_submit, OrderType.SARS_COV_2) - - # WHEN transforming order to status structure - result: dict = store_microbial_order_service.order_to_status(order=order) - - # THEN check that control is in the result - sample: dict - for sample in result.get("samples"): - assert "control" in sample - - -def test_order_to_status_control_has_input_value( - sarscov2_order_to_submit: dict, - base_store: Store, - store_microbial_order_service: StoreMicrobialOrderService, -): - # GIVEN sarscov2 order with three samples with control value set - control_value = ControlEnum.positive - order: OrderIn = OrderIn.parse_obj(sarscov2_order_to_submit, OrderType.SARS_COV_2) - sample: SarsCov2Sample - for sample in order.samples: - sample.control: ControlEnum = control_value - - # WHEN transforming order to status structure - result: dict = store_microbial_order_service.order_to_status(order=order) - - # THEN check that control is in the result - sample: dict - for sample in result.get("samples"): - assert control_value in sample.get("control") - - -def test_mutant_sample_generates_fields(sarscov2_order_to_submit: dict, base_store: Store): - """Tests that Mutant orders with region and original_lab set can generate region_code and original_lab_address.""" - # GIVEN sarscov2 order with six samples, one without region_code and original_lab_address - - # WHEN parsing the order - order: OrderIn = OrderIn.parse_obj(sarscov2_order_to_submit, OrderType.SARS_COV_2) - - # THEN all samples should have region_code and original_lab_address set - for sample in order.samples: - assert sample.region_code - assert sample.original_lab_address - - -def test_store_items_in_status_control_has_stored_value( - sarscov2_order_to_submit: dict, - base_store: Store, - store_microbial_order_service: StoreMicrobialOrderService, -): - # GIVEN sarscov2 order with three samples with control value - order: OrderIn = OrderIn.parse_obj(sarscov2_order_to_submit, OrderType.SARS_COV_2) - control_value = ControlEnum.positive - sample: SarsCov2Sample - for sample in order.samples: - sample.control: ControlEnum = control_value - - status_data = store_microbial_order_service.order_to_status(order=order) - - # WHEN storing the order - store_microbial_order_service.store_items_in_status( - comment="", - customer_id=order.customer, - data_analysis=Workflow.MUTANT, - data_delivery=DataDelivery.FASTQ, - order="", - ordered=dt.datetime.now(), - ticket_id=123456, - items=status_data.get("samples"), - ) - - # THEN control should exist on the sample in the store - customer: Customer = base_store.get_customer_by_internal_id(customer_internal_id=order.customer) - sample: SarsCov2Sample - for sample in order.samples: - stored_sample: Sample = base_store.get_sample_by_customer_and_name( - customer_entry_id=[customer.id], sample_name=sample.name - ) - assert stored_sample.control == control_value diff --git a/tests/services/orders/order_store_service/test_pacbio_order_service.py b/tests/services/orders/order_store_service/test_pacbio_order_service.py deleted file mode 100644 index a7336f54c9..0000000000 --- a/tests/services/orders/order_store_service/test_pacbio_order_service.py +++ /dev/null @@ -1,72 +0,0 @@ -from datetime import datetime - -from cg.constants import DataDelivery, Workflow -from cg.models.orders.constants import OrderType -from cg.models.orders.order import OrderIn -from cg.models.orders.sample_base import SexEnum -from cg.services.orders.store_order_services.store_pacbio_order_service import ( - StorePacBioOrderService, -) -from cg.store.models import Case, Sample -from cg.store.store import Store - - -def test_order_to_status( - pacbio_order_to_submit: dict, store_pacbio_order_service: StorePacBioOrderService -): - """Test that a PacBio order is parsed correctly.""" - # GIVEN a PacBio order with two samples - order = OrderIn.parse_obj(pacbio_order_to_submit, OrderType.PACBIO_LONG_READ) - - # WHEN parsing for status - data = store_pacbio_order_service.order_to_status(order=order) - - # THEN it should pick out samples and relevant information - assert len(data["samples"]) == 2 - first_sample = data["samples"][0] - assert first_sample["name"] == "prov1" - assert first_sample["application"] == "WGSPCFC060" - assert first_sample["priority"] == "priority" - assert first_sample["tumour"] is False - assert first_sample["volume"] == "25" - - # THEN the other sample is a tumour - assert data["samples"][1]["tumour"] is True - - -def test_store_order( - base_store: Store, - pacbio_status_data: dict, - ticket_id: str, - store_pacbio_order_service: StorePacBioOrderService, -): - """Test that a PacBio order is stored in the database.""" - # GIVEN a basic store with no samples and a PacBio order - assert not base_store._get_query(table=Sample).first() - assert base_store._get_query(table=Case).count() == 0 - - # WHEN storing the order - new_samples: list[Sample] = store_pacbio_order_service._store_samples_in_statusdb( - customer_id=pacbio_status_data["customer"], - order=pacbio_status_data["order"], - ordered=datetime.now(), - ticket_id=ticket_id, - samples=pacbio_status_data["samples"], - ) - - # THEN it should store the samples and create a case for each sample - assert len(new_samples) == 2 - assert len(base_store._get_query(table=Sample).all()) == 2 - assert base_store._get_query(table=Case).count() == 2 - for new_sample in new_samples: - assert len(new_sample.links) == 1 - case_link = new_sample.links[0] - assert case_link.case in base_store.get_cases() - assert case_link.case.data_analysis - assert case_link.case.data_delivery in [DataDelivery.BAM, DataDelivery.NO_DELIVERY] - - # THEN the sample sex should be stored - assert new_samples[0].sex == SexEnum.female - - # THEN the analysis for the case should be RAW_DATA - assert new_samples[0].links[0].case.data_analysis == Workflow.RAW_DATA diff --git a/tests/services/orders/order_store_service/test_pool_order_store_service.py b/tests/services/orders/order_store_service/test_pool_order_store_service.py deleted file mode 100644 index f0b6a278c0..0000000000 --- a/tests/services/orders/order_store_service/test_pool_order_store_service.py +++ /dev/null @@ -1,86 +0,0 @@ -import datetime as dt - -from cg.constants import DataDelivery, Workflow -from cg.models.orders.order import OrderIn, OrderType -from cg.services.orders.store_order_services.store_pool_order import StorePoolOrderService -from cg.store.models import Case, Pool, Sample -from cg.store.store import Store - - -def test_pools_to_status( - rml_order_to_submit: dict, store_pool_order_service: StorePoolOrderService -): - # GIVEN a rml order with three samples in one pool - order = OrderIn.parse_obj(rml_order_to_submit, OrderType.RML) - - # WHEN parsing for status - data = store_pool_order_service.order_to_status(order=order) - - # THEN it should pick out the general information - assert data["customer"] == "cust000" - assert data["order"] == "#123456" - assert data["comment"] == "order comment" - - # ... and information about the pool(s) - assert len(data["pools"]) == 2 - pool = data["pools"][0] - assert pool["name"] == "pool-1" - assert pool["application"] == "RMLP05R800" - assert pool["data_analysis"] == Workflow.RAW_DATA - assert pool["data_delivery"] == str(DataDelivery.FASTQ) - assert len(pool["samples"]) == 2 - sample = pool["samples"][0] - assert sample["name"] == "sample1" - assert sample["comment"] == "test comment" - assert pool["priority"] == "research" - assert sample["control"] == "negative" - - -def test_store_rml( - base_store: Store, - rml_status_data: dict, - ticket_id: str, - store_pool_order_service: StorePoolOrderService, -): - # GIVEN a basic store with no samples and a rml order - assert base_store._get_query(table=Pool).count() == 0 - assert base_store._get_query(table=Case).count() == 0 - assert not base_store._get_query(table=Sample).first() - - # WHEN storing the order - new_pools = store_pool_order_service.store_items_in_status( - customer_id=rml_status_data["customer"], - order=rml_status_data["order"], - ordered=dt.datetime.now(), - ticket_id=ticket_id, - items=rml_status_data["pools"], - ) - - # THEN it should update the database with new pools - assert len(new_pools) == 2 - - assert base_store._get_query(table=Pool).count() == base_store._get_query(table=Case).count() - assert len(base_store._get_query(table=Sample).all()) == 4 - - # ASSERT that there is one negative sample - negative_samples = 0 - for sample in base_store._get_query(table=Sample).all(): - if sample.control == "negative": - negative_samples += 1 - assert negative_samples == 1 - - new_pool = base_store._get_query(table=Pool).order_by(Pool.created_at.desc()).first() - assert new_pool == new_pools[1] - - assert new_pool.name == "pool-2" - assert new_pool.application_version.application.tag == "RMLP05R800" - assert not hasattr(new_pool, "data_analysis") - - new_case = base_store.get_cases()[0] - assert new_case.data_analysis == Workflow.RAW_DATA - assert new_case.data_delivery == str(DataDelivery.FASTQ) - - # and that the pool is set for invoicing but not the samples of the pool - assert not new_pool.no_invoice - for link in new_case.links: - assert link.sample.no_invoice diff --git a/tests/services/orders/store_service/test_fastq_order_service.py b/tests/services/orders/store_service/test_fastq_order_service.py new file mode 100644 index 0000000000..d42b1cd186 --- /dev/null +++ b/tests/services/orders/store_service/test_fastq_order_service.py @@ -0,0 +1,145 @@ +""" +Module to test the store_order_data_in_status_db method of the StoreFastqOrderService class. +The function store_order_data_in_status_db is never expected to fail, as its input order should +have always been validated before calling the function. +""" + +from cg.constants import DataDelivery, Workflow +from cg.constants.sequencing import SeqLibraryPrepCategory +from cg.services.orders.storing.constants import MAF_ORDER_ID +from cg.services.orders.storing.implementations.fastq_order_service import StoreFastqOrderService +from cg.services.orders.validation.workflows.fastq.models.order import FastqOrder +from cg.store.models import Application, Case, Order, Sample +from cg.store.store import Store +from tests.store_helpers import StoreHelpers + + +def test_store_order_data_in_status_db( + store_to_submit_and_validate_orders: Store, + store_fastq_order_service: StoreFastqOrderService, + fastq_order: FastqOrder, + ticket_id_as_int: int, +): + """Test that a Fastq order with two WGS samples, one being tumour, is stored in the database.""" + + # GIVEN a fastq order with two WGS samples, the first one being a tumour sample + + # GIVEN a basic store with no samples nor cases + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + assert store_to_submit_and_validate_orders._get_query(table=Case).count() == 0 + + # WHEN storing the order + new_samples: list[Sample] = store_fastq_order_service.store_order_data_in_status_db(fastq_order) + + # THEN it should store the order + assert store_to_submit_and_validate_orders.get_order_by_ticket_id(ticket_id_as_int) + + # THEN it should store the samples + db_samples: list[Sample] = store_to_submit_and_validate_orders._get_query(table=Sample).all() + assert set(new_samples) == set(db_samples) + + # THEN it should create one case for the analysis and one MAF case + cases: list[Case] = store_to_submit_and_validate_orders._get_query(table=Case).all() + assert len(cases) == 2 + assert len(db_samples[0].links) == 2 + assert cases[0].data_analysis == Workflow.MIP_DNA + assert cases[1].data_analysis == Workflow.RAW_DATA + + # THEN the analysis case has allowed data deliveries + assert cases[1].data_delivery in [DataDelivery.FASTQ, DataDelivery.NO_DELIVERY] + + # THEN the sample sex should be stored + assert db_samples[0].sex == "male" + + # THEN the MAF order should have one case linked to the tumour negative sample + maf_order: Order = store_to_submit_and_validate_orders.get_order_by_id(MAF_ORDER_ID) + maf_cases: list[Case] = maf_order.cases + assert len(maf_cases) == 1 + assert not maf_cases[0].samples[0].is_tumour + + +def test_store_fastq_samples_non_tumour_wgs_to_mip_maf_case( + store_to_submit_and_validate_orders: Store, + fastq_order: FastqOrder, + store_fastq_order_service: StoreFastqOrderService, +): + """Test that a non-tumour WGS sample creates a MAF case with MIP as data analysis.""" + # GIVEN a basic store with no samples nor cases + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + assert store_to_submit_and_validate_orders._get_query(table=Case).count() == 0 + + # GIVEN a fastq order with the first sample being a non-tumour WGS sample + store_to_submit_and_validate_orders.get_application_by_tag( + fastq_order.samples[0].application + ).prep_category = SeqLibraryPrepCategory.WHOLE_GENOME_SEQUENCING + fastq_order.samples[0].tumour = False + + # WHEN storing the order + new_samples = store_fastq_order_service.store_order_data_in_status_db(fastq_order) + + # THEN a MAF case was created for the first sample + assert new_samples[0].links[0].case.data_analysis == Workflow.MIP_DNA + + # THEN the case for the analysis is also created + assert new_samples[0].links[1].case.data_analysis == Workflow.RAW_DATA + + +def test_store_fastq_samples_tumour_wgs_to_fastq_no_maf_case( + store_to_submit_and_validate_orders: Store, + fastq_order: FastqOrder, + store_fastq_order_service: StoreFastqOrderService, +): + """Test that a tumour WGS sample does not create MAF cases.""" + # GIVEN a basic store with no samples + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + assert store_to_submit_and_validate_orders._get_query(table=Case).count() == 0 + + # GIVEN a fastq order with the second sample being a tumour WGS sample + store_to_submit_and_validate_orders.get_application_by_tag( + fastq_order.samples[0].application + ).prep_category = SeqLibraryPrepCategory.WHOLE_GENOME_SEQUENCING + fastq_order.samples[1].tumour = True + + # WHEN storing the order + new_samples = store_fastq_order_service.store_order_data_in_status_db(fastq_order) + + # THEN only one case is linked to the second sample + assert len(new_samples[1].links) == 1 + + # THEN the data analysis for the case should be RAW_DATA + assert new_samples[1].links[0].case.data_analysis == Workflow.RAW_DATA + + +def test_store_fastq_samples_non_wgs_no_maf_case( + store_to_submit_and_validate_orders: Store, + fastq_order: FastqOrder, + store_fastq_order_service: StoreFastqOrderService, + helpers: StoreHelpers, +): + """Test that an order with non-WGS samples creates no MAF cases.""" + # GIVEN a basic store with no samples + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + assert store_to_submit_and_validate_orders._get_query(table=Case).count() == 0 + + # GIVEN that the store has application versions for the non-WGS workflow + non_wgs_prep_category = SeqLibraryPrepCategory.WHOLE_EXOME_SEQUENCING + helpers.ensure_application_version( + store=store_to_submit_and_validate_orders, prep_category=non_wgs_prep_category + ) + + # GIVEN a fastq order with a non-WGS samples + non_wgs_applications: Application = store_to_submit_and_validate_orders._get_query( + table=Application + ).filter(Application.prep_category == non_wgs_prep_category) + assert non_wgs_applications + for sample in fastq_order.samples: + sample.application = non_wgs_applications[0].tag + + # WHEN storing the order + new_samples = store_fastq_order_service.store_order_data_in_status_db(fastq_order) + + # THEN only one case is linked to the sample + assert len(new_samples[0].links) == 1 + + # THEN the data analysis for the case should be RAW_DATA + assert new_samples[0].links[0].case.data_analysis == Workflow.RAW_DATA diff --git a/tests/services/orders/store_service/test_generic_order_store_service.py b/tests/services/orders/store_service/test_generic_order_store_service.py new file mode 100644 index 0000000000..efd9742ad4 --- /dev/null +++ b/tests/services/orders/store_service/test_generic_order_store_service.py @@ -0,0 +1,169 @@ +""" +Module to test the store_order_data_in_status_db method of the StoreGenericOrderService class. +The function store_order_data_in_status_db is never expected to fail, as its input order should +have always been validated before calling the function. +""" + +from cg.constants import DataDelivery, Priority, Workflow +from cg.services.orders.storing.implementations.case_order_service import StoreCaseOrderService +from cg.services.orders.validation.workflows.balsamic.models.order import BalsamicOrder +from cg.services.orders.validation.workflows.mip_dna.models.order import MipDnaOrder +from cg.services.orders.validation.workflows.mip_rna.models.order import MipRnaOrder +from cg.services.orders.validation.workflows.rna_fusion.models.order import RnaFusionOrder +from cg.services.orders.validation.workflows.tomte.models.order import TomteOrder +from cg.store.models import Case, Sample +from cg.store.store import Store + + +def test_store_mip_order( + store_to_submit_and_validate_orders: Store, + mip_dna_order: MipDnaOrder, + store_generic_order_service: StoreCaseOrderService, +): + # GIVEN a basic store with no samples nor cases + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + assert not store_to_submit_and_validate_orders.get_cases() + + # WHEN storing the order + new_cases: list[Case] = store_generic_order_service.store_order_data_in_status_db(mip_dna_order) + + # THEN it should create and link samples and the case + assert len(new_cases) == 2 + new_case = new_cases[0] + assert new_case.name == "MipCase1" + assert set(new_case.panels) == {"AID"} + assert new_case.priority_human == Priority.standard.name + + assert len(new_case.links) == 3 + new_link = new_case.links[2] + assert new_case.data_analysis == Workflow.MIP_DNA + assert new_case.data_delivery == str(DataDelivery.ANALYSIS_SCOUT) + assert ( + new_case.synopsis + == "This is a long string to test the buffer length because surely this is the best way to do this and there are no better ways of doing this." + ) + assert new_link.status == "affected" + assert new_link.father.name == "MipSample1" + assert new_link.mother.name == "MipSample2" + assert new_link.sample.name == "MipSample3" + assert new_link.sample.sex == "female" + assert new_link.sample.application_version.application.tag == "WGSPCFC030" + assert isinstance(new_case.links[1].sample.comment, str) + + assert set(new_link.sample.phenotype_groups) == {"Phenotype-group"} + assert set(new_link.sample.phenotype_terms) == {"HP:0012747", "HP:0025049"} + assert new_link.sample.subject_id == "Subject3" + + +def test_store_mip_rna_order( + store_to_submit_and_validate_orders: Store, + mip_rna_order: MipRnaOrder, + store_generic_order_service: StoreCaseOrderService, +): + # GIVEN a basic store with no samples nor cases + rna_application_tag = "RNAPOAR025" + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + assert not store_to_submit_and_validate_orders.get_cases() + assert store_to_submit_and_validate_orders.get_application_by_tag(tag=rna_application_tag) + + # WHEN storing a MIP-RNA order containing 1 case with 2 samples and 1 case with only 1 sample + new_cases: list[Case] = store_generic_order_service.store_order_data_in_status_db(mip_rna_order) + + # THEN it should create and link samples and the casing + assert len(new_cases) == 2 + first_case = new_cases[0] + + assert len(first_case.links) == 2 + new_link = first_case.links[0] + assert first_case.data_analysis == Workflow.MIP_RNA + assert first_case.data_delivery == str(DataDelivery.ANALYSIS_SCOUT) + assert new_link.sample.name == "MipRNASample1" + assert new_link.sample.application_version.application.tag == rna_application_tag + + +def test_store_balsamic_order( + store_to_submit_and_validate_orders: Store, + balsamic_order: BalsamicOrder, + store_generic_order_service: StoreCaseOrderService, +): + # GIVEN a Balsamic order + + # GIVEN a store with no samples nor cases + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + assert not store_to_submit_and_validate_orders.get_cases() + + # WHEN storing the order + new_cases: list[Case] = store_generic_order_service.store_order_data_in_status_db( + balsamic_order + ) + + # THEN it should create and link samples and the case + assert len(new_cases) == 1 + new_case = new_cases[0] + assert new_case.name == "BalsamicCase" + assert new_case.data_analysis in [ + Workflow.BALSAMIC, + Workflow.BALSAMIC_QC, + Workflow.BALSAMIC_UMI, + ] + assert new_case.data_delivery == str(DataDelivery.ANALYSIS_SCOUT) + assert not set(new_case.panels) + assert new_case.priority_human == Priority.standard.name + + assert len(new_case.links) == 1 + new_link = new_case.links[0] + assert new_link.sample.name == "BalsamicSample" + assert new_link.sample.sex == "male" + assert new_link.sample.application_version.application.tag == "PANKTTR100" + assert new_link.sample.comment == "This is a sample comment" + assert new_link.sample.is_tumour + + +def test_store_rna_fusion_order( + store_to_submit_and_validate_orders: Store, + rnafusion_order: RnaFusionOrder, + store_generic_order_service: StoreCaseOrderService, +): + # GIVEN a store with no samples nor cases + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + assert not store_to_submit_and_validate_orders.get_cases() + + # WHEN storing a RNA Fusion order + new_cases = store_generic_order_service.store_order_data_in_status_db(rnafusion_order) + + # THEN it should create and link samples and the casing + assert len(new_cases) == 2 + first_case = new_cases[0] + + assert len(first_case.links) == 1 + new_link = first_case.links[0] + assert first_case.data_analysis == Workflow.RNAFUSION + assert first_case.data_delivery == str(DataDelivery.FASTQ_ANALYSIS) + assert new_link.sample.name == "sample1-rna-t1" + assert new_link.sample.application_version.application.tag == "RNAPOAR025" + assert new_link + + +def test_store_tomte_order( + store_to_submit_and_validate_orders: Store, + tomte_order: TomteOrder, + store_generic_order_service: StoreCaseOrderService, +): + # GIVEN a store with no samples nor cases + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + assert not store_to_submit_and_validate_orders.get_cases() + + # WHEN storing a Tomte order + new_cases = store_generic_order_service.store_order_data_in_status_db(tomte_order) + + # THEN it should create and link samples and the casing + assert len(new_cases) == 1 + first_case = new_cases[0] + + assert len(first_case.links) == 4 + new_link = first_case.links[0] + assert first_case.data_analysis == Workflow.TOMTE + assert first_case.data_delivery == str(DataDelivery.FASTQ_ANALYSIS) + assert new_link.sample.name == "sample1" + assert new_link.sample.application_version.application.tag == "RNAPOAR025" + assert new_link diff --git a/tests/services/orders/store_service/test_metagenome_store_service.py b/tests/services/orders/store_service/test_metagenome_store_service.py new file mode 100644 index 0000000000..9da88631e4 --- /dev/null +++ b/tests/services/orders/store_service/test_metagenome_store_service.py @@ -0,0 +1,49 @@ +""" +Module to test the store_order_data_in_status_db method of the StoreMetagenomeOrderService class. +The function store_order_data_in_status_db is never expected to fail, as its input order should +have always been validated before calling the function. +""" + +import pytest + +from cg.services.orders.storing.implementations.metagenome_order_service import ( + StoreMetagenomeOrderService, +) +from cg.services.orders.validation.workflows.metagenome.models.order import MetagenomeOrder +from cg.services.orders.validation.workflows.taxprofiler.models.order import TaxprofilerOrder +from cg.store.models import Sample +from cg.store.store import Store + + +@pytest.mark.parametrize( + "order_fixture", + ["metagenome_order", "taxprofiler_order"], + ids=["Metagenome", "Taxprofiler"], +) +def test_store_metagenome_order_data_in_status_db( + order_fixture: str, + store_metagenome_order_service: StoreMetagenomeOrderService, + store_to_submit_and_validate_orders: Store, + ticket_id_as_int: int, + request: pytest.FixtureRequest, +): + # GIVEN an order + order: MetagenomeOrder | TaxprofilerOrder = request.getfixturevalue(order_fixture) + + # GIVEN a store with no samples nor cases + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + assert not store_to_submit_and_validate_orders.get_cases() + + # WHEN storing the order + new_samples: list[Sample] = store_metagenome_order_service.store_order_data_in_status_db(order) + + # THEN the samples should have been stored + db_samples: list[Sample] = store_to_submit_and_validate_orders._get_query(table=Sample).all() + assert set(new_samples) == set(db_samples) + + # THEN the samples should have the correct application tag + for sample in db_samples: + assert sample.application_version.application.tag in ["METWPFR030", "METPCFR030"] + + # THEN the order should be stored + assert store_to_submit_and_validate_orders.get_order_by_ticket_id(ticket_id_as_int) diff --git a/tests/services/orders/store_service/test_microbial_fastq_order_store_service.py b/tests/services/orders/store_service/test_microbial_fastq_order_store_service.py new file mode 100644 index 0000000000..a8d1ee403d --- /dev/null +++ b/tests/services/orders/store_service/test_microbial_fastq_order_store_service.py @@ -0,0 +1,35 @@ +from cg.constants import DataDelivery +from cg.services.orders.storing.implementations.microbial_fastq_order_service import ( + StoreMicrobialFastqOrderService, +) +from cg.services.orders.validation.workflows.microbial_fastq.models.order import MicrobialFastqOrder +from cg.store.models import Case, Sample +from cg.store.store import Store + + +def test_store_samples( + microbial_fastq_order: MicrobialFastqOrder, + store_microbial_fastq_order_service: StoreMicrobialFastqOrderService, +): + # GIVEN a microbial fastq order with microbial samples + + # GIVEN a basic store with no samples and a fastq order + store: Store = store_microbial_fastq_order_service.status_db + assert not store._get_query(table=Sample).first() + assert store._get_query(table=Case).count() == 0 + + # WHEN storing the order + new_samples = store_microbial_fastq_order_service.store_order_data_in_status_db( + order=microbial_fastq_order + ) + + # THEN it should store the samples and create a case for each sample + assert len(new_samples) == 2 + assert len(store._get_query(table=Sample).all()) == 2 + assert store._get_query(table=Case).count() == 2 + first_sample = new_samples[0] + assert len(first_sample.links) == 1 + case_link = first_sample.links[0] + assert case_link.case in store.get_cases() + assert case_link.case.data_analysis + assert case_link.case.data_delivery == DataDelivery.FASTQ diff --git a/tests/services/orders/store_service/test_microbial_store_order_service.py b/tests/services/orders/store_service/test_microbial_store_order_service.py new file mode 100644 index 0000000000..2052cf7165 --- /dev/null +++ b/tests/services/orders/store_service/test_microbial_store_order_service.py @@ -0,0 +1,105 @@ +""" +Module to test the store_order_data_in_status_db method of the StoreMicrobialOrderService class. +The function store_order_data_in_status_db is never expected to fail, as its input order should +have always been validated before calling the function. +""" + +from cg.constants import DataDelivery +from cg.constants.constants import Workflow +from cg.models.orders.sample_base import ControlEnum +from cg.services.orders.storing.implementations.microbial_order_service import ( + StoreMicrobialOrderService, +) +from cg.services.orders.validation.workflows.microsalt.models.order import MicrosaltOrder +from cg.services.orders.validation.workflows.mutant.models.order import MutantOrder +from cg.store.models import Case, Organism, Sample +from cg.store.store import Store + + +def test_store_microsalt_order_data_in_status_db( + store_to_submit_and_validate_orders: Store, + microsalt_order: MicrosaltOrder, + store_microbial_order_service: StoreMicrobialOrderService, +): + # GIVEN a store with no samples nor cases + assert store_to_submit_and_validate_orders._get_query(table=Sample).count() == 0 + assert not store_to_submit_and_validate_orders.get_cases() + + # GIVEN that the store has no organisms + assert store_to_submit_and_validate_orders.get_all_organisms().count() == 0 + + # WHEN storing the order + new_samples: list[Sample] = store_microbial_order_service.store_order_data_in_status_db( + microsalt_order + ) + + # THEN it should store the samples under a case + db_samples: list[Sample] = store_to_submit_and_validate_orders._get_query(table=Sample).all() + assert set(new_samples) == set(db_samples) + case_from_sample: Case = db_samples[0].links[0].case + db_case: Case = store_to_submit_and_validate_orders.get_cases()[0] + assert db_case == case_from_sample + + # THEN it should store the organisms + assert store_to_submit_and_validate_orders.get_all_organisms().count() > 0 + + # THEN the case should have the correct data analysis and data delivery + assert db_case.data_analysis == Workflow.MICROSALT + assert db_case.data_delivery == str(DataDelivery.FASTQ_QC_ANALYSIS) + + +def test_store_microbial_new_organism_in_status_db( + store_to_submit_and_validate_orders: Store, + microsalt_order: MicrosaltOrder, + store_microbial_order_service: StoreMicrobialOrderService, +): + """Test that a new organism in a Microsalt order is stored in the status db.""" + # GIVEN a store with no organisms + assert store_to_submit_and_validate_orders.get_all_organisms().count() == 0 + + # GIVEN a Microsalt order with a new organism + microsalt_order.samples[0].organism = "Canis lupus familiaris" + microsalt_order.samples[0].reference_genome = "UU_Cfam_GSD_1.0" + + # WHEN storing the order + store_microbial_order_service.store_order_data_in_status_db(microsalt_order) + + # THEN the organism should be stored in the status db + organisms: list[Organism] = store_to_submit_and_validate_orders.get_all_organisms().all() + dog: Organism = [ + organism for organism in organisms if organism.name == "Canis lupus familiaris" + ][0] + assert dog.reference_genome == "UU_Cfam_GSD_1.0" + + # THEN the organism should not be verified + assert not dog.verified + + +def test_store_mutant_order_data_control_has_stored_value( + mutant_order: MutantOrder, + store_to_submit_and_validate_orders: Store, + store_microbial_order_service: StoreMicrobialOrderService, +): + # GIVEN a Mutant order with one positive and one negative control + + # GIVEN a store with no samples nor cases + assert store_to_submit_and_validate_orders._get_query(table=Sample).count() == 0 + assert not store_to_submit_and_validate_orders.get_cases() + + # WHEN storing the order + new_samples: list[Sample] = store_microbial_order_service.store_order_data_in_status_db( + mutant_order + ) + + # THEN it should store the samples under a case + db_samples: list[Sample] = store_to_submit_and_validate_orders._get_query(table=Sample).all() + assert set(new_samples) == set(db_samples) + case_from_sample: Case = db_samples[0].links[0].case + db_case: Case = store_to_submit_and_validate_orders.get_cases()[0] + assert db_case == case_from_sample + + # THEN the control samples should have the correct control value + positive: Sample = store_to_submit_and_validate_orders.get_sample_by_name("control-positive") + assert positive.control == ControlEnum.positive + negative: Sample = store_to_submit_and_validate_orders.get_sample_by_name("control-negative") + assert negative.control == ControlEnum.negative diff --git a/tests/services/orders/store_service/test_pacbio_order_service.py b/tests/services/orders/store_service/test_pacbio_order_service.py new file mode 100644 index 0000000000..312d8ce3ee --- /dev/null +++ b/tests/services/orders/store_service/test_pacbio_order_service.py @@ -0,0 +1,44 @@ +from cg.constants import DataDelivery, Workflow +from cg.models.orders.sample_base import SexEnum +from cg.services.orders.storing.implementations.pacbio_order_service import StorePacBioOrderService +from cg.services.orders.validation.workflows.pacbio_long_read.models.order import PacbioOrder +from cg.store.models import Case, Order, Sample +from cg.store.store import Store + + +def test_store_pacbio_order_data_in_status_db( + store_to_submit_and_validate_orders: Store, + pacbio_order: PacbioOrder, + store_pacbio_order_service: StorePacBioOrderService, +): + """Test that a PacBio order is stored in the database.""" + # GIVEN a valid Pacbio order and a Pacbio store service + + # GIVEN a basic store with no samples, cases and only a MAF order + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + assert store_to_submit_and_validate_orders._get_query(table=Case).count() == 0 + assert store_to_submit_and_validate_orders._get_query(table=Order).count() == 1 + + # WHEN storing the order + new_samples: list[Sample] = store_pacbio_order_service.store_order_data_in_status_db( + order=pacbio_order + ) + + # THEN it should store the order + assert store_to_submit_and_validate_orders._get_query(table=Order).count() == 2 + + # THEN it should store the samples and create a case for each sample + assert len(new_samples) == 3 + assert len(store_to_submit_and_validate_orders._get_query(table=Sample).all()) == 3 + assert store_to_submit_and_validate_orders._get_query(table=Case).count() == 3 + for new_sample in new_samples: + # THEN the sample sex should be stored + assert new_sample.sex == SexEnum.male + # THEN the sample should have a relationship with a case + assert len(new_sample.links) == 1 + case_link = new_sample.links[0] + assert case_link.case in store_to_submit_and_validate_orders.get_cases() + # THEN the analysis for the case should be RAW_DATA + assert case_link.case.data_analysis == Workflow.RAW_DATA + # THEN the delivery type for the case should be BAM or NO_DELIVERY + assert case_link.case.data_delivery in [DataDelivery.BAM, DataDelivery.NO_DELIVERY] diff --git a/tests/services/orders/store_service/test_pool_order_store_service.py b/tests/services/orders/store_service/test_pool_order_store_service.py new file mode 100644 index 0000000000..42a8da0c74 --- /dev/null +++ b/tests/services/orders/store_service/test_pool_order_store_service.py @@ -0,0 +1,73 @@ +""" +Module to test the store_order_data_in_status_db method of the StorePoolOrderService class. +The function store_order_data_in_status_db is never expected to fail, as its input order should +have always been validated before calling the function. +""" + +import pytest + +from cg.constants import Workflow +from cg.services.orders.storing.implementations.pool_order_service import StorePoolOrderService +from cg.services.orders.validation.models.order_aliases import OrderWithIndexedSamples +from cg.store.models import Case, CaseSample, Pool, Sample +from cg.store.store import Store + + +@pytest.mark.parametrize( + "order_fixture, workflow", + [("rml_order", Workflow.RAW_DATA), ("fluffy_order", Workflow.FLUFFY)], + ids=["RML", "Fluffy"], +) +def test_store_pool_order_data_in_status_db( + store_to_submit_and_validate_orders: Store, + order_fixture: str, + ticket_id: str, + store_pool_order_service: StorePoolOrderService, + workflow: Workflow, + request: pytest.FixtureRequest, +): + """Test that a Fluffy or RML order is stored in the database.""" + # GIVEN a valid Fluffy or RML order + order: OrderWithIndexedSamples = request.getfixturevalue(order_fixture) + + # GIVEN a store with no samples, pools, nor cases + assert store_to_submit_and_validate_orders._get_query(table=Sample).count() == 0 + assert store_to_submit_and_validate_orders._get_query(table=Pool).count() == 0 + assert store_to_submit_and_validate_orders._get_query(table=Case).count() == 0 + assert store_to_submit_and_validate_orders._get_query(table=CaseSample).count() == 0 + + # WHEN storing the order + new_pools: list[Pool] = store_pool_order_service.store_order_data_in_status_db(order=order) + + # THEN it should return the pools + assert len(new_pools) == 4 + assert isinstance(new_pools[0], Pool) + + # THEN the pools should be stored in the database + db_pools: list[Pool] = store_to_submit_and_validate_orders._get_query(table=Pool).all() + assert len(db_pools) == 4 + assert set(new_pools) == set(db_pools) + + # THEN the database pools should be invoiced, have a RML application and the correct ticket id + for pool in db_pools: + assert not pool.no_invoice + assert pool.application_version.application.tag.startswith("RML") + assert pool.ticket == ticket_id + + # THEN the order should be stored, have the correct ticket id + assert store_to_submit_and_validate_orders.get_order_by_ticket_id(int(ticket_id)) + + # THEN it should store the samples and create a case for each sample + new_samples: list[Sample] = store_to_submit_and_validate_orders._get_query(table=Sample).all() + new_cases: list[Case] = store_to_submit_and_validate_orders._get_query(table=Case).all() + assert len(new_samples) == 4 + assert len(new_cases) == 4 + assert store_to_submit_and_validate_orders._get_query(table=CaseSample).count() == 4 + + # THEN the samples are not set for invoicing + for sample in new_samples: + assert sample.no_invoice + + # THEN the cases should have the correct data analysis + for case in new_cases: + assert case.data_analysis == workflow diff --git a/tests/services/orders/store_service/test_registry.py b/tests/services/orders/store_service/test_registry.py new file mode 100644 index 0000000000..a762a874de --- /dev/null +++ b/tests/services/orders/store_service/test_registry.py @@ -0,0 +1,70 @@ +import pytest + +from cg.models.orders.constants import OrderType +from cg.services.orders.storing.service import StoreOrderService +from cg.services.orders.storing.service_registry import StoringServiceRegistry + + +@pytest.mark.parametrize( + "order_type, storing_service_fixture", + [ + (OrderType.BALSAMIC, "store_generic_order_service"), + (OrderType.BALSAMIC_QC, "store_generic_order_service"), + (OrderType.FASTQ, "store_fastq_order_service"), + (OrderType.FLUFFY, "store_pool_order_service"), + (OrderType.METAGENOME, "store_metagenome_order_service"), + (OrderType.MICROBIAL_FASTQ, "store_microbial_fastq_order_service"), + (OrderType.MICROSALT, "store_microbial_order_service"), + (OrderType.MIP_DNA, "store_generic_order_service"), + (OrderType.MIP_RNA, "store_generic_order_service"), + (OrderType.PACBIO_LONG_READ, "store_pacbio_order_service"), + (OrderType.RML, "store_pool_order_service"), + (OrderType.RNAFUSION, "store_generic_order_service"), + (OrderType.SARS_COV_2, "store_microbial_order_service"), + (OrderType.TAXPROFILER, "store_metagenome_order_service"), + (OrderType.TOMTE, "store_generic_order_service"), + ], + ids=[ + "balsamic", + "balsamic_qc", + "fastq", + "fluffy", + "metagenome", + "microbial_fastq", + "microbial", + "mip_dna", + "mip_rna", + "pacbio_long_read", + "rml", + "rnafusion", + "sars_cov_2", + "taxprofiler", + "tomte", + ], +) +def test_get_storing_service( + storing_service_registry: StoringServiceRegistry, + order_type: OrderType, + storing_service_fixture: str, + request: pytest.FixtureRequest, +): + """Test that getting a storing service returns the correct service for any known order type.""" + # GIVEN a storing service registry + + # WHEN getting a storing service for a known order type + storing_service: StoreOrderService = storing_service_registry.get_storing_service(order_type) + + # THEN the correct storing service should be returned + expected_storing_service: StoreOrderService = request.getfixturevalue(storing_service_fixture) + assert isinstance(storing_service, type(expected_storing_service)) + + +def test_get_storing_registry_unknown_order_type(storing_service_registry: StoringServiceRegistry): + """Test that getting a storing service for an unknown order type raises a ValueError.""" + # GIVEN a storing service registry + + # WHEN getting a storing service for an unknown order type + + # THEN it should raise a ValueError + with pytest.raises(ValueError): + storing_service_registry.get_storing_service(order_type="non_existing_order_type") diff --git a/tests/services/orders/submitter/test_order_submitter.py b/tests/services/orders/submitter/test_order_submitter.py new file mode 100644 index 0000000000..57edb4458c --- /dev/null +++ b/tests/services/orders/submitter/test_order_submitter.py @@ -0,0 +1,182 @@ +import datetime as dt +from unittest.mock import patch + +import pytest + +from cg.clients.freshdesk.models import TicketResponse +from cg.exc import TicketCreationError +from cg.models.orders.constants import OrderType +from cg.services.orders.constants import ORDER_TYPE_WORKFLOW_MAP +from cg.services.orders.storing.constants import MAF_ORDER_ID +from cg.services.orders.submitter.service import OrderSubmitter +from cg.services.orders.validation.errors.validation_errors import ValidationErrors +from cg.services.orders.validation.models.order import Order +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.models.order_with_samples import OrderWithSamples +from cg.services.orders.validation.workflows.mip_dna.models.order import MipDnaOrder +from cg.store.models import Case +from cg.store.models import Order as DbOrder +from cg.store.models import Pool, Sample, User +from cg.store.store import Store + + +def monkeypatch_process_lims(monkeypatch: pytest.MonkeyPatch, order: Order) -> None: + lims_project_data = {"id": "ADM1234", "date": dt.datetime.now()} + if isinstance(order, OrderWithSamples): + lims_map = {sample.name: f"ELH123A{index}" for index, sample in enumerate(order.samples)} + elif isinstance(order, OrderWithCases): + lims_map = { + sample.name: f"ELH123A{case_index}-{sample_index}" + for case_index, sample_index, sample in order.enumerated_new_samples + } + monkeypatch.setattr( + "cg.services.orders.lims_service.service.OrderLimsService.process_lims", + lambda *args, **kwargs: (lims_project_data, lims_map), + ) + + +def mock_freshdesk_ticket_creation(mock_create_ticket: callable, ticket_id: str): + """Helper function to mock Freshdesk ticket creation.""" + mock_create_ticket.return_value = TicketResponse( + id=int(ticket_id), + description="This is a test description.", + subject="Support needed..", + status=2, + priority=1, + ) + + +def mock_freshdesk_reply_to_ticket(mock_reply_to_ticket: callable): + """Helper function to mock Freshdesk reply to ticket.""" + mock_reply_to_ticket.return_value = None + + +@pytest.mark.parametrize( + "order_type, order_fixture", + [ + (OrderType.BALSAMIC, "balsamic_order"), + (OrderType.FASTQ, "fastq_order"), + (OrderType.FLUFFY, "fluffy_order"), + (OrderType.METAGENOME, "metagenome_order"), + (OrderType.MICROBIAL_FASTQ, "microbial_fastq_order"), + (OrderType.MICROSALT, "microsalt_order"), + (OrderType.MIP_DNA, "mip_dna_order"), + (OrderType.MIP_RNA, "mip_rna_order"), + (OrderType.PACBIO_LONG_READ, "pacbio_order"), + (OrderType.RML, "rml_order"), + (OrderType.RNAFUSION, "rnafusion_order"), + (OrderType.SARS_COV_2, "mutant_order"), + (OrderType.TAXPROFILER, "taxprofiler_order"), + (OrderType.TOMTE, "tomte_order"), + ], +) +def test_submit_order( + store_to_submit_and_validate_orders: Store, + monkeypatch: pytest.MonkeyPatch, + order_type: OrderType, + order_fixture: str, + order_submitter: OrderSubmitter, + ticket_id: str, + customer_id: str, + request: pytest.FixtureRequest, +): + """Test submitting a valid order of each ordertype.""" + # GIVEN an order + order: Order = request.getfixturevalue(order_fixture) + + # GIVEN a store without samples, cases, or pools + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + assert not store_to_submit_and_validate_orders._get_query(table=Case).first() + assert not store_to_submit_and_validate_orders._get_query(table=Pool).first() + + # GIVEN that the only order in store is a MAF order + orders: list[DbOrder] = store_to_submit_and_validate_orders._get_query(table=DbOrder).all() + assert len(orders) == 1 + assert orders[0].id == MAF_ORDER_ID + + # GIVEN a ticketing system that returns a ticket number + with ( + patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" + ) as mock_create_ticket, + patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" + ) as mock_reply_to_ticket, + ): + mock_freshdesk_ticket_creation(mock_create_ticket=mock_create_ticket, ticket_id=ticket_id) + mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) + + # GIVEN a mock LIMS that returns project data and sample name mapping + monkeypatch_process_lims(monkeypatch=monkeypatch, order=order) + + # GIVEN a registered user + user: User = store_to_submit_and_validate_orders._get_query(table=User).first() + + # GIVEN the dict representation of the order and a store without samples + raw_order = order.model_dump(by_alias=True) + assert not store_to_submit_and_validate_orders._get_query(table=Sample).first() + + # WHEN submitting the order + result = order_submitter.submit(order_type=order_type, raw_order=raw_order, user=user) + + # THEN the result should contain the project data + assert result["project"]["id"] == "ADM1234" + + # THEN the records should contain the appropriate ticket id, customer id and data analysis + is_pool_order: bool = False + for record in result["records"]: + assert record.customer.internal_id == customer_id + if isinstance(record, Pool): + assert record.ticket == ticket_id + is_pool_order = True + elif isinstance(record, Sample): + assert record.original_ticket == ticket_id + elif isinstance(record, Case): + assert record.data_analysis == ORDER_TYPE_WORKFLOW_MAP[order_type] + for link_obj in record.links: + assert link_obj.sample.original_ticket == ticket_id + + # THEN the order should be stored in the database + assert store_to_submit_and_validate_orders.get_order_by_ticket_id(ticket_id=int(ticket_id)) + + # THEN the samples should be stored in the database + assert store_to_submit_and_validate_orders._get_query(table=Sample).first() + + # THEN the cases should be stored in the database + assert store_to_submit_and_validate_orders._get_query(table=Case).first() + + # THEN the pools should be stored in the database if applicable + if is_pool_order: + assert store_to_submit_and_validate_orders._get_query(table=Pool).first() + + +def test_submit_ticketexception( + order_submitter: OrderSubmitter, + mip_dna_order: MipDnaOrder, +): + + # GIVEN an order + raw_order = mip_dna_order.model_dump() + raw_order["project_type"] = mip_dna_order.order_type + + # GIVEN a registered user + user: User = order_submitter.validation_service.store._get_query(table=User).first() + + # GIVEN a mock Freshdesk ticket creation that raises TicketCreationError + with ( + patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket", + side_effect=TicketCreationError("ERROR"), + ), + patch( + "cg.services.orders.validation.service.OrderValidationService._get_rule_validation_errors", + return_value=ValidationErrors(), + ), + ): + + # WHEN the order is submitted and a TicketCreationError raised + # THEN the TicketCreationError is not excepted + with pytest.raises(TicketCreationError): + order_submitter.submit( + raw_order=raw_order, user=user, order_type=mip_dna_order.order_type + ) diff --git a/tests/meta/orders/test_ticket_handler.py b/tests/services/orders/submitter/test_ticket_handler.py similarity index 91% rename from tests/meta/orders/test_ticket_handler.py rename to tests/services/orders/submitter/test_ticket_handler.py index 6212b75d9d..0025e970b8 100644 --- a/tests/meta/orders/test_ticket_handler.py +++ b/tests/services/orders/submitter/test_ticket_handler.py @@ -1,4 +1,4 @@ -from cg.meta.orders.ticket_handler import TicketHandler +from cg.services.orders.submitter.ticket_handler import TicketHandler def test_add_user_name_message(ticket_handler: TicketHandler): diff --git a/tests/services/orders/order_status_service/conftest.py b/tests/services/orders/summary_service/conftest.py similarity index 100% rename from tests/services/orders/order_status_service/conftest.py rename to tests/services/orders/summary_service/conftest.py diff --git a/tests/services/orders/order_status_service/test_order_summary_service.py b/tests/services/orders/summary_service/test_order_summary_service.py similarity index 100% rename from tests/services/orders/order_status_service/test_order_summary_service.py rename to tests/services/orders/summary_service/test_order_summary_service.py diff --git a/tests/services/orders/test_validate_order_service/conftest.py b/tests/services/orders/test_validate_order_service/conftest.py deleted file mode 100644 index 27d2ae9d19..0000000000 --- a/tests/services/orders/test_validate_order_service/conftest.py +++ /dev/null @@ -1,41 +0,0 @@ -import pytest - -from cg.constants import DataDelivery, Workflow -from cg.models.orders.order import OrderIn -from cg.models.orders.sample_base import SexEnum -from cg.models.orders.samples import PacBioSample -from cg.services.orders.validate_order_services.validate_pacbio_order import ( - ValidatePacbioOrderService, -) -from cg.store.store import Store - - -@pytest.fixture -def pacbio_sample() -> PacBioSample: - return PacBioSample( - application="WGSPCFC060", - data_analysis=Workflow.RAW_DATA, - data_delivery=DataDelivery.NO_DELIVERY, - name="PacbioSample", - sex=SexEnum.unknown, - tumour=False, - volume="50", - buffer="buffer", - source="source", - subject_id="subject_id", - container="Tube", - ) - - -@pytest.fixture -def pacbio_order(pacbio_sample: PacBioSample) -> OrderIn: - return OrderIn( - customer="cust000", - name="PacbioOrder", - samples=[pacbio_sample], - ) - - -@pytest.fixture -def validate_pacbio_order_service(sample_store: Store) -> ValidatePacbioOrderService: - return ValidatePacbioOrderService(sample_store) diff --git a/tests/services/orders/test_validate_order_service/test_validate_generic_order.py b/tests/services/orders/test_validate_order_service/test_validate_generic_order.py deleted file mode 100644 index f74f5842d0..0000000000 --- a/tests/services/orders/test_validate_order_service/test_validate_generic_order.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest - -from cg.exc import OrderError -from cg.models.orders.constants import OrderType -from cg.models.orders.order import OrderIn -from cg.services.orders.validate_order_services.validate_case_order import ( - ValidateCaseOrderService, -) -from cg.store.store import Store - - -def test__validate_one_sample_per_case_multiple_samples( - base_store: Store, - rnafusion_order_to_submit: dict, -): - """Tests the validation of an RNAFUSION order where two samples have the same family_name.""" - ### GIVEN an RNAFUSION order where the first and last sample share the same case - order_data = OrderIn.parse_obj(obj=rnafusion_order_to_submit, project=OrderType.RNAFUSION) - order_data.samples[-1].family_name = order_data.samples[0].family_name - validator = ValidateCaseOrderService(base_store) - - ### WHEN validating that each case has only one sample - ### THEN an OrderError should be raised - - with pytest.raises(OrderError): - validator._validate_only_one_sample_per_case(order_data.samples) - - -def test__validate_one_sample_per_case_unique_samples( - base_store: Store, - rnafusion_order_to_submit: dict, -): - """Tests the validation of an RNAFUSION order where all samples have unique family_name.""" - ### GIVEN an RNAFUSION order with unique family names - order_data: OrderIn = OrderIn.parse_obj( - obj=rnafusion_order_to_submit, project=OrderType.RNAFUSION - ) - validator = ValidateCaseOrderService(base_store) - - ### WHEN validating that each case has only one sample - validator._validate_only_one_sample_per_case(order_data.samples) - - ### THEN no errors should be raised diff --git a/tests/services/orders/test_validate_order_service/test_validate_microbial_order_service.py b/tests/services/orders/test_validate_order_service/test_validate_microbial_order_service.py deleted file mode 100644 index ac1ee2d893..0000000000 --- a/tests/services/orders/test_validate_order_service/test_validate_microbial_order_service.py +++ /dev/null @@ -1,64 +0,0 @@ -import pytest - -from cg.exc import OrderError - -from cg.models.orders.constants import OrderType -from cg.models.orders.order import OrderIn -from cg.models.orders.sample_base import ControlEnum -from cg.models.orders.samples import SarsCov2Sample -from cg.services.orders.validate_order_services.validate_microbial_order import ( - ValidateMicrobialOrderService, -) -from cg.store.store import Store -from tests.store_helpers import StoreHelpers - - -def test_validate_normal_order(sarscov2_order_to_submit: dict, base_store: Store): - # GIVEN sarscov2 order with three samples, none in the database - order = OrderIn.parse_obj(sarscov2_order_to_submit, OrderType.SARS_COV_2) - - # WHEN validating the order - ValidateMicrobialOrderService(base_store).validate_order(order=order) - # THEN it should be regarded as valid - - -def test_validate_submitted_order( - sarscov2_order_to_submit: dict, base_store: Store, helpers: StoreHelpers -): - # GIVEN sarscov2 order with three samples, all in the database - order: OrderIn = OrderIn.parse_obj(sarscov2_order_to_submit, OrderType.SARS_COV_2) - - sample: SarsCov2Sample - for sample in order.samples: - helpers.add_sample(store=base_store, customer_id=order.customer, name=sample.name) - - # WHEN validating the order - # THEN it should be regarded as invalid - with pytest.raises(OrderError): - ValidateMicrobialOrderService(base_store).validate_order(order=order) - - -def test_validate_submitted_control_order( - sarscov2_order_to_submit: dict, base_store: Store, helpers: StoreHelpers -): - # GIVEN sarscov2 order with three control samples, all in the database - order: OrderIn = OrderIn.parse_obj(sarscov2_order_to_submit, OrderType.SARS_COV_2) - - sample: SarsCov2Sample - for sample in order.samples: - helpers.add_sample(store=base_store, customer_id=order.customer, name=sample.name) - sample.control = ControlEnum.positive - - # WHEN validating the order - # THEN it should be regarded as valid - ValidateMicrobialOrderService(base_store).validate_order(order=order) - - -def test_validate_microbial_fast_order(microbial_fastq_order_to_submit: dict, base_store: Store): - # GIVEN a microbial order with three samples, none in the database - - # WHEN validating the order - order = OrderIn.parse_obj(microbial_fastq_order_to_submit, OrderType.MICROBIAL_FASTQ) - - # THEN it should be regarded as valid - ValidateMicrobialOrderService(base_store).validate_order(order=order) diff --git a/tests/services/orders/test_validate_order_service/test_validate_pacbio_order_service.py b/tests/services/orders/test_validate_order_service/test_validate_pacbio_order_service.py deleted file mode 100644 index 872ccc2a64..0000000000 --- a/tests/services/orders/test_validate_order_service/test_validate_pacbio_order_service.py +++ /dev/null @@ -1,63 +0,0 @@ -import pytest - -from cg.exc import OrderError -from cg.models.orders.order import OrderIn -from cg.services.orders.validate_order_services.validate_pacbio_order import ( - ValidatePacbioOrderService, -) -from cg.store.store import Store - - -def test_validate_valid_pacbio_order( - validate_pacbio_order_service: ValidatePacbioOrderService, pacbio_order: OrderIn -): - # GIVEN a valid PacBio order - - # WHEN validating the order - validate_pacbio_order_service.validate_order(pacbio_order) - - # THEN no error is raised - - -def test_validate_pacbio_order_unknown_customer( - pacbio_order: OrderIn, validate_pacbio_order_service: ValidatePacbioOrderService -): - # GIVEN a PacBio order with an unknown customer - pacbio_order.customer = "unknown_customer" - - # WHEN validating the order - - # THEN an order error should be raised - with pytest.raises(OrderError): - validate_pacbio_order_service.validate_order(pacbio_order) - - -def test_validate_pacbio_order_invalid_application( - pacbio_order: OrderIn, validate_pacbio_order_service: ValidatePacbioOrderService -): - # GIVEN a PacBio order with an unknown application - pacbio_order.samples[0].application = "unknown_application" - - # WHEN validating the order - - # THEN an order error should be raised - with pytest.raises(OrderError): - validate_pacbio_order_service.validate_order(pacbio_order) - - -def test_validate_pacbio_order_reused_sample_name( - pacbio_order: OrderIn, validate_pacbio_order_service: ValidatePacbioOrderService -): - # GIVEN a PacBio order with a reused sample name - status_db: Store = validate_pacbio_order_service.status_db - customer = status_db.get_customer_by_internal_id(pacbio_order.customer) - old_sample_name: str = status_db.get_samples_by_customers_and_pattern(customers=[customer])[ - 0 - ].name - pacbio_order.samples[0].name = old_sample_name - - # WHEN validating the order - - # THEN an order error should be raised - with pytest.raises(OrderError): - validate_pacbio_order_service.validate_order(pacbio_order) diff --git a/tests/services/orders/test_validate_order_service/test_validate_pool_order_service.py b/tests/services/orders/test_validate_order_service/test_validate_pool_order_service.py deleted file mode 100644 index 98e138f0f6..0000000000 --- a/tests/services/orders/test_validate_order_service/test_validate_pool_order_service.py +++ /dev/null @@ -1,46 +0,0 @@ -import pytest - -from cg.constants import DataDelivery -from cg.constants.constants import Workflow -from cg.exc import OrderError -from cg.models.orders.constants import OrderType -from cg.models.orders.order import OrderIn -from cg.models.orders.samples import RmlSample -from cg.services.orders.validate_order_services.validate_pool_order import ValidatePoolOrderService -from cg.store.models import Customer -from cg.store.store import Store -from tests.store_helpers import StoreHelpers - - -def test_validate_normal_order(rml_order_to_submit: dict, base_store: Store): - # GIVEN pool order with three samples, none in the database - order = OrderIn.parse_obj(rml_order_to_submit, OrderType.RML) - - # WHEN validating the order - ValidatePoolOrderService(status_db=base_store).validate_order(order=order) - # THEN it should be regarded as valid - - -def test_validate_case_name(rml_order_to_submit: dict, base_store: Store, helpers: StoreHelpers): - # GIVEN pool order with a case already all in the database - order: OrderIn = OrderIn.parse_obj(rml_order_to_submit, OrderType.RML) - - sample: RmlSample - customer: Customer = helpers.ensure_customer(store=base_store, customer_id=order.customer) - for sample in order.samples: - case = helpers.ensure_case( - store=base_store, - case_name=ValidatePoolOrderService.create_case_name( - ticket=order.ticket, pool_name=sample.pool - ), - customer=customer, - data_analysis=Workflow.FLUFFY, - data_delivery=DataDelivery.STATINA, - ) - base_store.session.add(case) - base_store.session.commit() - - # WHEN validating the order - # THEN it should be regarded as invalid - with pytest.raises(OrderError): - ValidatePoolOrderService(status_db=base_store).validate_order(order=order) diff --git a/tests/services/orders/validation_service/conftest.py b/tests/services/orders/validation_service/conftest.py new file mode 100644 index 0000000000..abd05ead4e --- /dev/null +++ b/tests/services/orders/validation_service/conftest.py @@ -0,0 +1,297 @@ +import pytest + +from cg.constants.constants import GenomeVersion +from cg.constants.sequencing import SeqLibraryPrepCategory +from cg.models.orders.constants import OrderType +from cg.models.orders.sample_base import ContainerEnum, ControlEnum, SexEnum, StatusEnum +from cg.services.orders.validation.constants import MINIMUM_VOLUME +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.order_type_maps import ORDER_TYPE_RULE_SET_MAP, RuleSet +from cg.services.orders.validation.service import OrderValidationService +from cg.services.orders.validation.workflows.tomte.constants import TomteDeliveryType +from cg.services.orders.validation.workflows.tomte.models.case import TomteCase +from cg.services.orders.validation.workflows.tomte.models.order import TomteOrder +from cg.services.orders.validation.workflows.tomte.models.sample import TomteSample +from cg.store.models import Application, Customer, User +from cg.store.store import Store + + +def create_tomte_sample(id: int) -> TomteSample: + return TomteSample( + name=f"name{id}", + application="RNAPOAR100", + 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="subject1", + well_position=f"A:{id}", + volume=MINIMUM_VOLUME, + ) + + +def create_case(samples: list[TomteSample]) -> TomteCase: + return TomteCase( + name="name", + panels=[], + samples=samples, + ) + + +def create_tomte_order(cases: list[TomteCase]) -> TomteOrder: + order = TomteOrder( + delivery_type=TomteDeliveryType.FASTQ, + name="order_name", + project_type=OrderType.TOMTE, + customer="cust000", + cases=cases, + ) + order._user_id = 1 + order._generated_ticket_id = 123456 + return order + + +@pytest.fixture +def case_with_samples_in_same_well() -> TomteCase: + sample_1: TomteSample = create_tomte_sample(1) + sample_2: TomteSample = create_tomte_sample(1) + return create_case([sample_1, sample_2]) + + +@pytest.fixture +def sample_with_non_compatible_application() -> TomteSample: + sample: TomteSample = create_tomte_sample(1) + sample.application = "WGSPCFC030" + return sample + + +@pytest.fixture +def archived_application(base_store: Store) -> Application: + return base_store.add_application( + tag="archived_application", + prep_category="wts", + description="This is an archived_application", + percent_kth=100, + percent_reads_guaranteed=90, + is_archived=True, + ) + + +@pytest.fixture +def application_tag_required_buffer() -> str: + return "WGSWPFR400" + + +@pytest.fixture +def valid_order() -> TomteOrder: + child: TomteSample = create_tomte_sample(1) + father: TomteSample = create_tomte_sample(2) + mother: TomteSample = create_tomte_sample(3) + grandfather: TomteSample = create_tomte_sample(4) + grandmother: TomteSample = create_tomte_sample(5) + case = create_case([child, father, mother, grandfather, grandmother]) + return create_tomte_order([case]) + + +@pytest.fixture +def order_with_samples_in_same_well(case_with_samples_in_same_well: TomteCase) -> TomteOrder: + return create_tomte_order([case_with_samples_in_same_well]) + + +@pytest.fixture +def case_with_samples_with_repeated_names() -> TomteCase: + sample_1: TomteSample = create_tomte_sample(1) + sample_2: TomteSample = create_tomte_sample(1) + sample_1.name = sample_2.name + return create_case([sample_1, sample_2]) + + +@pytest.fixture +def order_with_repeated_sample_names( + case_with_samples_with_repeated_names: TomteCase, +) -> TomteOrder: + return create_tomte_order([case_with_samples_with_repeated_names]) + + +@pytest.fixture +def case() -> TomteCase: + sample_1: TomteSample = create_tomte_sample(1) + sample_2: TomteSample = create_tomte_sample(2) + return create_case([sample_1, sample_2]) + + +@pytest.fixture +def order_with_repeated_case_names(case: TomteCase) -> TomteOrder: + return create_tomte_order([case, case]) + + +@pytest.fixture +def order_with_invalid_father_sex(case: TomteCase): + child: TomteSample = case.samples[0] + father: TomteSample = case.samples[1] + child.father = father.name + father.sex = SexEnum.female + return create_tomte_order([case]) + + +@pytest.fixture +def order_with_father_in_wrong_case(case: TomteCase): + child: TomteSample = case.samples[0] + father: TomteSample = case.samples[1] + child.father = father.name + case.samples = [child] + return create_tomte_order([case]) + + +@pytest.fixture +def order_with_sample_cycle(): + child: TomteSample = create_tomte_sample(1) + father: TomteSample = create_tomte_sample(2) + mother: TomteSample = create_tomte_sample(3) + grandfather: TomteSample = create_tomte_sample(4) + grandmother: TomteSample = create_tomte_sample(5) + + child.mother = mother.name + child.father = father.name + + father.mother = grandmother.name + father.father = child.name # Cycle introduced here + + case = create_case([child, father, mother, grandfather, grandmother]) + return create_tomte_order([case]) + + +@pytest.fixture +def order_with_existing_sample_cycle(): + child: TomteSample = create_tomte_sample(1) + father = ExistingSample(internal_id="ExistingSampleInternalId", status=StatusEnum.unaffected) + mother: TomteSample = create_tomte_sample(3) + grandfather: TomteSample = create_tomte_sample(4) + grandmother: TomteSample = create_tomte_sample(5) + + child.mother = mother.name + child.father = "ExistingSampleName" + + father.mother = grandmother.name + father.father = child.name # Cycle introduced here + + case = create_case([child, father, mother, grandfather, grandmother]) + return create_tomte_order([case]) + + +@pytest.fixture +def order_with_siblings_as_parents(): + child: TomteSample = create_tomte_sample(1) + + father: TomteSample = create_tomte_sample(3) + mother: TomteSample = create_tomte_sample(4) + + grandfather: TomteSample = create_tomte_sample(5) + grandmother: TomteSample = create_tomte_sample(6) + + child.father = father.name + child.mother = mother.name + + father.mother = grandmother.name + father.father = grandfather.name + + mother.mother = grandmother.name + mother.father = grandfather.name + + case = create_case([child, father, mother, grandfather, grandmother]) + return create_tomte_order([case]) + + +@pytest.fixture +def sample_with_invalid_concentration(): + sample: TomteSample = create_tomte_sample(1) + sample.concentration_ng_ul = 1 + return sample + + +@pytest.fixture +def sample_with_missing_well_position(): + sample: TomteSample = create_tomte_sample(1) + sample.well_position = None + return sample + + +@pytest.fixture +def application_with_concentration_interval(base_store: Store) -> Application: + application: Application = base_store.add_application( + tag="RNAPOAR100", + prep_category="wts", + description="This is an application with concentration interval", + percent_kth=100, + percent_reads_guaranteed=90, + sample_concentration_minimum=50, + sample_concentration_maximum=250, + ) + application.order_types = [OrderType.TOMTE] + base_store.session.add(application) + base_store.commit_to_store() + return application + + +@pytest.fixture +def order_with_invalid_concentration(sample_with_invalid_concentration) -> TomteOrder: + case: TomteCase = create_case([sample_with_invalid_concentration]) + order: TomteOrder = create_tomte_order([case]) + order.skip_reception_control = True + return order + + +@pytest.fixture +def order_with_samples_having_same_names_as_cases() -> TomteOrder: + """Return an order with two cases, the first case having two samples named after the cases.""" + sample_1: TomteSample = create_tomte_sample(1) + sample_2: TomteSample = create_tomte_sample(2) + sample_3: TomteSample = create_tomte_sample(3) + case_1: TomteCase = create_case([sample_1, sample_2]) + case_1.name = sample_1.name + case_2: TomteCase = create_case([sample_3]) + case_2.name = sample_2.name + return create_tomte_order([case_1, case_2]) + + +@pytest.fixture +def sample_with_missing_container_name() -> TomteSample: + sample: TomteSample = create_tomte_sample(1) + sample.container_name = None + return sample + + +@pytest.fixture +def tomte_validation_service( + base_store: Store, + application_with_concentration_interval: Application, +) -> OrderValidationService: + customer: Customer = base_store.get_customer_by_internal_id("cust000") + user: User = base_store.add_user(customer=customer, email="mail@email.com", name="new user") + base_store.session.add(user) + base_store.session.add(application_with_concentration_interval) + base_store.session.commit() + return OrderValidationService(base_store) + + +@pytest.fixture +def application_tgs(base_store: Store) -> Application: + application: Application = base_store.add_application( + tag="PANKTTR020", + prep_category=SeqLibraryPrepCategory.TARGETED_GENOME_SEQUENCING, + description="Panel-based sequencing, 20 M read pairs.", + percent_kth=59, + percent_reads_guaranteed=75, + ) + base_store.session.add(application) + base_store.commit_to_store() + return application + + +@pytest.fixture +def tomte_rule_set() -> RuleSet: + return ORDER_TYPE_RULE_SET_MAP[OrderType.TOMTE] diff --git a/tests/services/orders/validation_service/sample_rules/__init__.py b/tests/services/orders/validation_service/sample_rules/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/orders/validation_service/sample_rules/conftest.py b/tests/services/orders/validation_service/sample_rules/conftest.py new file mode 100644 index 0000000000..60b91ebcc1 --- /dev/null +++ b/tests/services/orders/validation_service/sample_rules/conftest.py @@ -0,0 +1,74 @@ +import pytest + +from cg.models.orders.constants import OrderType +from cg.models.orders.sample_base import ContainerEnum, PriorityEnum +from cg.services.orders.validation.constants import MINIMUM_VOLUME, ElutionBuffer, ExtractionMethod +from cg.services.orders.validation.workflows.microsalt.constants import MicrosaltDeliveryType +from cg.services.orders.validation.workflows.microsalt.models.order import MicrosaltOrder +from cg.services.orders.validation.workflows.microsalt.models.sample import MicrosaltSample +from cg.store.models import Application +from cg.store.store import Store + + +def create_microsalt_sample(id: int) -> MicrosaltSample: + return MicrosaltSample( + name=f"name{id}", + application="MWRNXTR003", + container=ContainerEnum.plate, + container_name="ContainerName", + elution_buffer=ElutionBuffer.WATER, + extraction_method=ExtractionMethod.MAELSTROM, + organism="C. jejuni", + priority=PriorityEnum.standard, + require_qc_ok=True, + reference_genome="NC_00001", + well_position=f"A:{id}", + volume=MINIMUM_VOLUME, + ) + + +def create_microsalt_order(samples: list[MicrosaltSample]) -> MicrosaltOrder: + return MicrosaltOrder( + connect_to_ticket=True, + delivery_type=MicrosaltDeliveryType.FASTQ_QC, + name="order_name", + ticket_number="#12345", + project_type=OrderType.MICROSALT, + user_id=1, + customer="cust000", + samples=samples, + ) + + +@pytest.fixture +def valid_microsalt_order() -> MicrosaltOrder: + sample_1: MicrosaltSample = create_microsalt_sample(1) + sample_2: MicrosaltSample = create_microsalt_sample(2) + sample_3: MicrosaltSample = create_microsalt_sample(3) + return create_microsalt_order([sample_1, sample_2, sample_3]) + + +@pytest.fixture +def sample_with_non_compatible_application() -> MicrosaltSample: + sample: MicrosaltSample = create_microsalt_sample(1) + sample.application = "WGSPCFC030" + return sample + + +@pytest.fixture +def archived_application(base_store: Store) -> Application: + return base_store.add_application( + tag="archived_application", + prep_category="mic", + description="This is an archived_application", + percent_kth=100, + percent_reads_guaranteed=90, + is_archived=True, + ) + + +@pytest.fixture +def order_with_samples_in_same_well() -> MicrosaltOrder: + sample_1: MicrosaltSample = create_microsalt_sample(1) + sample_2: MicrosaltSample = create_microsalt_sample(1) + return create_microsalt_order([sample_1, sample_2]) diff --git a/tests/services/orders/validation_service/sample_rules/test_data_validators.py b/tests/services/orders/validation_service/sample_rules/test_data_validators.py new file mode 100644 index 0000000000..8eb2c5140c --- /dev/null +++ b/tests/services/orders/validation_service/sample_rules/test_data_validators.py @@ -0,0 +1,85 @@ +from cg.services.orders.validation.constants import MAXIMUM_VOLUME +from cg.services.orders.validation.errors.sample_errors import ( + ApplicationArchivedError, + ApplicationNotCompatibleError, + ApplicationNotValidError, + InvalidVolumeError, +) +from cg.services.orders.validation.rules.sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_applications_not_archived, + validate_volume_interval, +) +from cg.services.orders.validation.workflows.microsalt.models.order import MicrosaltOrder +from cg.services.orders.validation.workflows.microsalt.models.sample import MicrosaltSample +from cg.store.models import Application +from cg.store.store import Store + + +def test_applications_exist_sample_order(valid_microsalt_order: MicrosaltOrder, base_store: Store): + + # GIVEN an order with a sample with an application which is not found in the database + valid_microsalt_order.samples[0].application = "Non-existent app tag" + + # WHEN validating that the specified applications exist + errors = validate_application_exists(order=valid_microsalt_order, store=base_store) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the invalid application + assert isinstance(errors[0], ApplicationNotValidError) + + +def test_application_is_incompatible( + valid_microsalt_order: MicrosaltOrder, + sample_with_non_compatible_application: MicrosaltSample, + base_store: Store, +): + + # GIVEN an order that has a sample with an application which is incompatible with microsalt + valid_microsalt_order.samples.append(sample_with_non_compatible_application) + + # WHEN validating the order + errors = validate_application_compatibility(order=valid_microsalt_order, store=base_store) + + # THEN an error should be returned + assert errors + + # THEN the error should be about the application compatability + assert isinstance(errors[0], ApplicationNotCompatibleError) + + +def test_application_is_not_archived( + valid_microsalt_order: MicrosaltOrder, archived_application: Application, base_store: Store +): + + # GIVEN an order with a new sample with an archived application + valid_microsalt_order.samples[0].application = archived_application.tag + base_store.session.add(archived_application) + base_store.commit_to_store() + + # WHEN validating that the applications are not archived + errors = validate_applications_not_archived(order=valid_microsalt_order, store=base_store) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the archived application + assert isinstance(errors[0], ApplicationArchivedError) + + +def test_invalid_volume(valid_microsalt_order: MicrosaltOrder, base_store: Store): + + # GIVEN an order with a sample with an invalid volume + valid_microsalt_order.samples[0].volume = MAXIMUM_VOLUME + 10 + + # WHEN validating the volume interval + errors = validate_volume_interval(order=valid_microsalt_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the invalid volume + assert isinstance(errors[0], InvalidVolumeError) diff --git a/tests/services/orders/validation_service/sample_rules/test_inter_field_validators.py b/tests/services/orders/validation_service/sample_rules/test_inter_field_validators.py new file mode 100644 index 0000000000..0ffb7fd4ff --- /dev/null +++ b/tests/services/orders/validation_service/sample_rules/test_inter_field_validators.py @@ -0,0 +1,50 @@ +from cg.services.orders.validation.errors.sample_errors import ( + OccupiedWellError, + SampleNameRepeatedError, +) +from cg.services.orders.validation.rules.sample.rules import ( + validate_sample_names_unique, + validate_wells_contain_at_most_one_sample, +) +from cg.services.orders.validation.workflows.microsalt.models.order import MicrosaltOrder + + +def test_multiple_samples_in_well_not_allowed(order_with_samples_in_same_well: MicrosaltOrder): + + # GIVEN an order with multiple samples in the same well + + # WHEN validating the order + errors = validate_wells_contain_at_most_one_sample(order_with_samples_in_same_well) + + # THEN an error should be returned + assert errors + + # THEN the error should be about the well + assert isinstance(errors[0], OccupiedWellError) + + +def test_order_without_multiple_samples_in_well(valid_microsalt_order: MicrosaltOrder): + + # GIVEN a valid order with no samples in the same well + + # WHEN validating the order + errors = validate_wells_contain_at_most_one_sample(valid_microsalt_order) + + # THEN no errors should be returned + assert not errors + + +def test_sample_name_repeated(valid_microsalt_order: MicrosaltOrder): + + # GIVEN a valid order within sample names are repeated + sample_name_1 = valid_microsalt_order.samples[0].name + valid_microsalt_order.samples[1].name = sample_name_1 + + # WHEN validating that the sample names are unique + errors = validate_sample_names_unique(valid_microsalt_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the repeated sample name + assert isinstance(errors[0], SampleNameRepeatedError) diff --git a/tests/services/orders/validation_service/sample_rules/test_sample_rules.py b/tests/services/orders/validation_service/sample_rules/test_sample_rules.py new file mode 100644 index 0000000000..2d3bdf17ab --- /dev/null +++ b/tests/services/orders/validation_service/sample_rules/test_sample_rules.py @@ -0,0 +1,453 @@ +from cg.models.orders.sample_base import ContainerEnum, ControlEnum, PriorityEnum +from cg.services.orders.validation.constants import ElutionBuffer, IndexEnum +from cg.services.orders.validation.errors.sample_errors import ( + BufferInvalidError, + ConcentrationInvalidIfSkipRCError, + ConcentrationRequiredError, + ContainerNameMissingError, + ContainerNameRepeatedError, + IndexNumberMissingError, + IndexNumberOutOfRangeError, + IndexSequenceMismatchError, + IndexSequenceMissingError, + PoolApplicationError, + PoolPriorityError, + SampleNameNotAvailableControlError, + SampleNameNotAvailableError, + VolumeRequiredError, + WellFormatError, + WellFormatRmlError, +) +from cg.services.orders.validation.index_sequences import INDEX_SEQUENCES +from cg.services.orders.validation.rules.sample.rules import ( + validate_buffer_skip_rc_condition, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_index_number_in_range, + validate_index_number_required, + validate_index_sequence_mismatch, + validate_index_sequence_required, + validate_non_control_sample_names_available, + validate_pools_contain_one_application, + validate_pools_contain_one_priority, + validate_sample_names_available, + validate_tube_container_name_unique, + validate_volume_required, + validate_well_position_format, + validate_well_position_rml_format, +) +from cg.services.orders.validation.workflows.fastq.models.order import FastqOrder +from cg.services.orders.validation.workflows.fluffy.models.order import FluffyOrder +from cg.services.orders.validation.workflows.microsalt.models.order import MicrosaltOrder +from cg.services.orders.validation.workflows.mutant.models.order import MutantOrder +from cg.services.orders.validation.workflows.rml.models.order import RmlOrder +from cg.services.orders.validation.workflows.rml.models.sample import RmlSample +from cg.store.models import Sample +from cg.store.store import Store +from tests.store_helpers import StoreHelpers + + +def test_sample_names_available(valid_microsalt_order: MicrosaltOrder, sample_store: Store): + + # GIVEN an order with a sample name reused from a previous order + sample = sample_store.session.query(Sample).first() + valid_microsalt_order.customer = sample.customer.internal_id + valid_microsalt_order.samples[0].name = sample.name + + # WHEN validating that the sample names are available to the customer + errors = validate_sample_names_available(order=valid_microsalt_order, store=sample_store) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the reused sample name + assert isinstance(errors[0], SampleNameNotAvailableError) + + +def test_validate_tube_container_name_unique(valid_microsalt_order: MicrosaltOrder): + + # GIVEN an order with three samples in tubes with 2 reused container names + valid_microsalt_order.samples[0].container = ContainerEnum.tube + valid_microsalt_order.samples[1].container = ContainerEnum.tube + valid_microsalt_order.samples[2].container = ContainerEnum.tube + valid_microsalt_order.samples[0].container_name = "container_name" + valid_microsalt_order.samples[1].container_name = "container_name" + valid_microsalt_order.samples[2].container_name = "ContainerName" + + # WHEN validating the container names are unique + errors = validate_tube_container_name_unique(order=valid_microsalt_order) + + # THEN the error should concern the reused container name + assert isinstance(errors[0], ContainerNameRepeatedError) + assert errors[0].sample_index == 0 + assert errors[1].sample_index == 1 + + +def test_validate_sample_names_available( + fluffy_order: FluffyOrder, store: Store, helpers: StoreHelpers +): + """ + Test that an order without any control sample that has a sample name already existing in the + database returns an error. + """ + + # GIVEN an order without control with a sample name already in the database + sample_name: str = fluffy_order.samples[0].name + helpers.add_sample( + store=store, + name=sample_name, + customer_id=fluffy_order.customer, + ) + + # WHEN validating that the sample names are available to the customer + errors = validate_sample_names_available(order=fluffy_order, store=store) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the reused sample name + assert isinstance(errors[0], SampleNameNotAvailableError) + + +def test_validate_non_control_sample_names_available( + mutant_order: MutantOrder, store: Store, helpers: StoreHelpers +): + """ + Test that an order with a control sample name already existing in the database returns no error. + """ + + # GIVEN an order with a control sample + sample = mutant_order.samples[0] + assert sample.control == ControlEnum.positive + + # GIVEN that there is a sample in the database with the same name + helpers.add_sample( + store=store, + name=sample.name, + customer_id=mutant_order.customer, + ) + + # WHEN validating that the sample names are available to the customer + errors = validate_non_control_sample_names_available(order=mutant_order, store=store) + + # THEN no error should be returned because it is a control sample + assert not errors + + +def test_validate_non_control_sample_names_available_non_control_sample_name( + mutant_order: MutantOrder, store: Store, helpers: StoreHelpers +): + """ + Test that an order with a non-control sample name already existing in the database returns an + error. + """ + + # GIVEN an order with a non-control sample + sample = mutant_order.samples[2] + assert sample.control == ControlEnum.not_control + + # GIVEN that there is a sample in the database with the same name + helpers.add_sample( + store=store, + name=sample.name, + customer_id=mutant_order.customer, + ) + + # WHEN validating that the sample names are available to the customer + errors = validate_non_control_sample_names_available(order=mutant_order, store=store) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the reused sample name + assert isinstance(errors[0], SampleNameNotAvailableControlError) + + +def test_validate_well_position_format(valid_microsalt_order: MicrosaltOrder): + + # GIVEN an order with a sample with an invalid well position + valid_microsalt_order.samples[0].well_position = "J:4" + + # WHEN validating the well position format + errors = validate_well_position_format(order=valid_microsalt_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the invalid well position + assert isinstance(errors[0], WellFormatError) + assert errors[0].sample_index == 0 + + +def test_validate_well_position_rml_format(rml_order: RmlOrder): + + # GIVEN a RML order with a sample with an invalid well position + rml_order.samples[0].well_position_rml = "J:4" + + # WHEN validating the well position format + errors = validate_well_position_rml_format(order=rml_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the invalid well position + assert isinstance(errors[0], WellFormatRmlError) + assert errors[0].sample_index == 0 + + +def test_validate_missing_container_name(valid_microsalt_order: MicrosaltOrder): + + # GIVEN an order with a sample on a plate with no container name + valid_microsalt_order.samples[0].container = ContainerEnum.plate + valid_microsalt_order.samples[0].container_name = None + + # WHEN validating the container name + errors = validate_container_name_required(order=valid_microsalt_order) + + # THEN am error should be returned + assert errors + + # THEN the error should concern the missing container name + assert isinstance(errors[0], ContainerNameMissingError) + assert errors[0].sample_index == 0 + + +def test_validate_valid_container_name(valid_microsalt_order: MicrosaltOrder): + + # GIVEN an order with a sample on a plate with a valid container name + valid_microsalt_order.samples[0].container = ContainerEnum.plate + valid_microsalt_order.samples[0].container_name = "Plate_123" + + # WHEN validating the container name + errors = validate_container_name_required(order=valid_microsalt_order) + + # THEN no error should be returned + assert not errors + + +def test_validate_non_plate_container(valid_microsalt_order: MicrosaltOrder): + + # GIVEN an order with missing container names but the samples are not on plates + valid_microsalt_order.samples[0].container = ContainerEnum.tube + valid_microsalt_order.samples[0].container_name = None + + valid_microsalt_order.samples[1].container = ContainerEnum.no_container + valid_microsalt_order.samples[1].container_name = None + + # WHEN validating the container name + errors = validate_container_name_required(order=valid_microsalt_order) + + # THEN no error should be returned + assert not errors + + +def test_missing_required_sample_volume(valid_microsalt_order: MicrosaltOrder): + + # GIVEN an order with containerized samples missing volume + valid_microsalt_order.samples[0].container = ContainerEnum.tube + valid_microsalt_order.samples[0].volume = None + + valid_microsalt_order.samples[1].container = ContainerEnum.plate + valid_microsalt_order.samples[1].volume = None + + # WHEN validating the volume + errors = validate_volume_required(order=valid_microsalt_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the missing volume + assert isinstance(errors[0], VolumeRequiredError) + assert errors[0].sample_index == 0 + + assert isinstance(errors[1], VolumeRequiredError) + assert errors[1].sample_index == 1 + + +def test_non_required_sample_volume(valid_microsalt_order: MicrosaltOrder): + + # GIVEN an order with a sample not in a container and no volume set + valid_microsalt_order.samples[0].container = ContainerEnum.no_container + valid_microsalt_order.samples[0].volume = None + + # WHEN validating the volume + errors = validate_volume_required(order=valid_microsalt_order) + + # THEN no error should be returned + assert not errors + + +def test_validate_concentration_required_if_skip_rc(fastq_order: FastqOrder): + + # GIVEN a fastq order trying to skip reception control + fastq_order.skip_reception_control = True + + # GIVEN that one of its samples has no concentration set + fastq_order.samples[0].concentration_ng_ul = None + + # WHEN validating that the concentration is not missing + errors: list[ConcentrationRequiredError] = validate_concentration_required_if_skip_rc( + order=fastq_order + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the missing concentration + assert isinstance(errors[0], ConcentrationRequiredError) + + +def test_validate_concentration_interval_if_skip_rc(fastq_order: FastqOrder, base_store: Store): + + # GIVEN a Fastq order trying to skip reception control + fastq_order.skip_reception_control = True + + # GIVEN that one of the samples has a concentration outside the allowed interval for its + # application + sample = fastq_order.samples[0] + application = base_store.get_application_by_tag(sample.application) + application.sample_concentration_minimum = sample.concentration_ng_ul + 1 + base_store.session.add(application) + base_store.commit_to_store() + + # WHEN validating that the order's samples' concentrations are within allowed intervals + errors: list[ConcentrationInvalidIfSkipRCError] = validate_concentration_interval_if_skip_rc( + order=fastq_order, store=base_store + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the concentration level + assert isinstance(errors[0], ConcentrationInvalidIfSkipRCError) + + +def test_validate_buffer_skip_rc_condition(fastq_order: FastqOrder): + + # GIVEN a Fastq order trying to skip reception control + fastq_order.skip_reception_control = True + + # GIVEN that one of the samples has buffer specified as 'other' + fastq_order.samples[0].elution_buffer = ElutionBuffer.OTHER + + # WHEN validating that the buffers follow the 'skip reception control' requirements + errors: list[BufferInvalidError] = validate_buffer_skip_rc_condition(order=fastq_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the buffer + assert isinstance(errors[0], BufferInvalidError) + + +def test_validate_pools_contain_multiple_applications(rml_order: RmlOrder): + + # GIVEN a pooled order with the same pool containing multiple applications + rml_order.samples[0].pool = "pool" + rml_order.samples[1].pool = "pool" + _, samples = next(iter(rml_order.pools.items())) + samples[1].application = f"Not {samples[0].application}" + + # WHEN validating that the pools contain a single application + errors: list[PoolApplicationError] = validate_pools_contain_one_application(rml_order) + + # THEN errors should be returned + assert errors + + # THEN the errors should concern the pool with repeated applications + assert isinstance(errors[0], PoolApplicationError) + assert len(errors) == len(samples) + + +def test_validate_pools_contain_multiple_priorities(rml_order: RmlOrder): + + # GIVEN a pooled order with the same pool containing multiple priorities + rml_order.samples[0].pool = "pool" + rml_order.samples[1].pool = "pool" + _, samples = next(iter(rml_order.pools.items())) + samples[0].priority = PriorityEnum.research + samples[1].priority = PriorityEnum.priority + + # WHEN validating that the pools contain a single application + errors: list[PoolPriorityError] = validate_pools_contain_one_priority(rml_order) + + # THEN errors should be returned + assert errors + + # THEN the errors should concern the pool with repeated applications + assert isinstance(errors[0], PoolPriorityError) + assert len(errors) == len(samples) + + +def test_validate_missing_index_number(rml_order: RmlOrder): + + # GIVEN an indexed order with a missing index number + erroneous_sample: RmlSample = rml_order.samples[0] + erroneous_sample.index = IndexEnum.AVIDA_INDEX_STRIP + erroneous_sample.index_number = None + + # WHEN validating that no index numbers are missing + errors: list[IndexNumberMissingError] = validate_index_number_required(rml_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the sample's missing index number + assert isinstance(errors[0], IndexNumberMissingError) + assert errors[0].sample_index == 0 + + +def test_validate_index_number_out_of_range(rml_order: RmlOrder): + + # GIVEN an indexed order with an index number out of range + erroneous_sample: RmlSample = rml_order.samples[0] + erroneous_sample.index = IndexEnum.AVIDA_INDEX_STRIP + erroneous_sample.index_number = len(INDEX_SEQUENCES[erroneous_sample.index]) + 1 + + # WHEN validating that the index numbers are in range + errors: list[IndexNumberOutOfRangeError] = validate_index_number_in_range(rml_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the sample's index number being out of range + assert isinstance(errors[0], IndexNumberOutOfRangeError) + assert errors[0].sample_index == 0 + + +def test_validate_missing_index_sequence(rml_order: RmlOrder): + + # GIVEN an indexed order with a missing index sequence + erroneous_sample: RmlSample = rml_order.samples[0] + erroneous_sample.index = IndexEnum.AVIDA_INDEX_STRIP + erroneous_sample.index_sequence = None + + # WHEN validating that no index sequences are missing + errors: list[IndexSequenceMissingError] = validate_index_sequence_required(rml_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the sample's missing index sequence + assert isinstance(errors[0], IndexSequenceMissingError) + assert errors[0].sample_index == 0 + + +def test_validate_index_sequence_mismatch(rml_order: RmlOrder): + + # GIVEN an indexed order with a mismatched index sequence + erroneous_sample: RmlSample = rml_order.samples[0] + erroneous_sample.index = IndexEnum.AVIDA_INDEX_STRIP + erroneous_sample.index_number = 1 + erroneous_sample.index_sequence = INDEX_SEQUENCES[erroneous_sample.index][10] + + # WHEN validating that the index sequences match + errors: list[IndexSequenceMismatchError] = validate_index_sequence_mismatch(rml_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the sample's mismatched index sequence + assert isinstance(errors[0], IndexSequenceMismatchError) + assert errors[0].sample_index == 0 diff --git a/tests/services/orders/validation_service/test_case_rules.py b/tests/services/orders/validation_service/test_case_rules.py new file mode 100644 index 0000000000..b86a1d3d06 --- /dev/null +++ b/tests/services/orders/validation_service/test_case_rules.py @@ -0,0 +1,140 @@ +from cg.constants import GenePanelMasterList +from cg.models.orders.sample_base import ContainerEnum, SexEnum +from cg.services.orders.validation.errors.case_errors import ( + CaseDoesNotExistError, + CaseNameNotAvailableError, + CaseOutsideOfCollaborationError, + MultipleSamplesInCaseError, + RepeatedCaseNameError, +) +from cg.services.orders.validation.models.existing_case import ExistingCase +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.rules.case.rules import ( + validate_case_internal_ids_exist, + validate_case_names_available, + validate_case_names_not_repeated, + validate_existing_cases_belong_to_collaboration, + validate_one_sample_per_case, +) +from cg.services.orders.validation.workflows.mip_dna.models.order import MipDnaOrder +from cg.services.orders.validation.workflows.rna_fusion.models.order import RnaFusionOrder +from cg.services.orders.validation.workflows.rna_fusion.models.sample import RnaFusionSample +from cg.store.models import Case +from cg.store.store import Store + + +def test_case_name_not_available( + valid_order: OrderWithCases, store_with_multiple_cases_and_samples: Store +): + store = store_with_multiple_cases_and_samples + + # GIVEN an order with a new case that has the same name as an existing case + case: Case = store.get_cases()[0] + valid_order.cases[0].name = case.name + valid_order.customer = case.customer.internal_id + + # WHEN validating that the case name is available + errors: list[CaseNameNotAvailableError] = validate_case_names_available( + order=valid_order, store=store + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the case name + assert isinstance(errors[0], CaseNameNotAvailableError) + + +def test_case_internal_ids_does_not_exist( + valid_order: OrderWithCases, + store_with_multiple_cases_and_samples: Store, +): + + # GIVEN an order with a case marked as existing but which does not exist in the database + existing_case = ExistingCase(internal_id="Non-existent case", panels=[GenePanelMasterList.AID]) + valid_order.cases.append(existing_case) + + # WHEN validating that the internal ids match existing cases + errors: list[CaseDoesNotExistError] = validate_case_internal_ids_exist( + order=valid_order, + store=store_with_multiple_cases_and_samples, + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the non-existent case + assert isinstance(errors[0], CaseDoesNotExistError) + + +def test_repeated_case_names_not_allowed(order_with_repeated_case_names: OrderWithCases): + # GIVEN an order with cases with the same name + + # WHEN validating the order + errors: list[RepeatedCaseNameError] = validate_case_names_not_repeated( + order_with_repeated_case_names + ) + + # THEN errors are returned + assert errors + + # THEN the errors are about the case names + assert isinstance(errors[0], RepeatedCaseNameError) + + +def test_multiple_samples_in_case(rnafusion_order: RnaFusionOrder): + # GIVEN an RNAFusion order with multiple samples in the same case + rnafusion_sample = RnaFusionSample( + container=ContainerEnum.tube, + container_name="container_name", + application="DummyAppTag", + name="ExtraSample", + require_qc_ok=False, + sex=SexEnum.female, + source="blood", + subject_id="subject", + ) + rnafusion_order.cases[0].samples.append(rnafusion_sample) + + # WHEN validating that the order has at most one sample per case + errors: list[MultipleSamplesInCaseError] = validate_one_sample_per_case(rnafusion_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the multiple samples in the first case + assert isinstance(errors[0], MultipleSamplesInCaseError) + assert errors[0].case_index == 0 + + +def test_case_outside_of_collaboration( + mip_dna_order: MipDnaOrder, store_with_multiple_cases_and_samples: Store +): + + # GIVEN a customer from outside the order's customer's collaboration + new_customer = store_with_multiple_cases_and_samples.add_customer( + internal_id="NewCustomer", + name="New customer", + invoice_address="Test street", + invoice_reference="Invoice reference", + ) + store_with_multiple_cases_and_samples.add_item_to_store(new_customer) + store_with_multiple_cases_and_samples.commit_to_store() + + # GIVEN a case belonging to the customer is added to the order + existing_cases: list[Case] = store_with_multiple_cases_and_samples.get_cases() + case = existing_cases[0] + case.customer = new_customer + existing_case = ExistingCase(internal_id=case.internal_id, panels=case.panels) + mip_dna_order.cases.append(existing_case) + + # WHEN validating that the order does not contain cases from outside the customer's collaboration + errors: list[CaseOutsideOfCollaborationError] = validate_existing_cases_belong_to_collaboration( + order=mip_dna_order, store=store_with_multiple_cases_and_samples + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the added existing case + assert isinstance(errors[0], CaseOutsideOfCollaborationError) diff --git a/tests/services/orders/validation_service/test_case_sample_rules.py b/tests/services/orders/validation_service/test_case_sample_rules.py new file mode 100644 index 0000000000..692f5170f0 --- /dev/null +++ b/tests/services/orders/validation_service/test_case_sample_rules.py @@ -0,0 +1,602 @@ +import pytest + +from cg.models.orders.sample_base import ContainerEnum, SexEnum, StatusEnum +from cg.services.orders.validation.errors.case_sample_errors import ( + ApplicationArchivedError, + ApplicationNotCompatibleError, + ApplicationNotValidError, + BufferMissingError, + ConcentrationRequiredIfSkipRCError, + ContainerNameMissingError, + ContainerNameRepeatedError, + InvalidBufferError, + InvalidConcentrationIfSkipRCError, + InvalidVolumeError, + OccupiedWellError, + SampleDoesNotExistError, + SampleNameRepeatedError, + SampleNameSameAsCaseNameError, + SampleOutsideOfCollaborationError, + SexSubjectIdError, + StatusUnknownError, + SubjectIdSameAsCaseNameError, + SubjectIdSameAsSampleNameError, + VolumeRequiredError, + WellFormatError, + WellPositionMissingError, +) +from cg.services.orders.validation.models.existing_case import ExistingCase +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.models.order_with_cases import OrderWithCases +from cg.services.orders.validation.rules.case_sample.rules import ( + validate_application_compatibility, + validate_application_exists, + validate_application_not_archived, + validate_buffer_required, + validate_buffers_are_allowed, + validate_concentration_interval_if_skip_rc, + validate_concentration_required_if_skip_rc, + validate_container_name_required, + validate_existing_samples_belong_to_collaboration, + validate_not_all_samples_unknown_in_case, + validate_sample_names_different_from_case_names, + validate_sample_names_not_repeated, + validate_samples_exist, + validate_subject_ids_different_from_case_names, + validate_subject_ids_different_from_sample_names, + validate_subject_sex_consistency, + validate_tube_container_name_unique, + validate_volume_interval, + validate_volume_required, + validate_well_position_format, + validate_well_positions_required, + validate_wells_contain_at_most_one_sample, +) +from cg.services.orders.validation.workflows.mip_dna.models.order import MipDnaOrder +from cg.services.orders.validation.workflows.tomte.models.order import TomteOrder +from cg.services.orders.validation.workflows.tomte.models.sample import TomteSample +from cg.store.models import Application, Sample +from cg.store.store import Store + + +def test_validate_well_position_format(valid_order: OrderWithCases): + + # GIVEN an order with invalid well position format + valid_order.cases[0].samples[0].well_position = "D:0" + + # WHEN validating the well position format + errors: list[WellFormatError] = validate_well_position_format(order=valid_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the invalid well position format + assert isinstance(errors[0], WellFormatError) + assert errors[0].sample_index == 0 and errors[0].case_index == 0 + + +def test_validate_tube_container_name_unique(valid_order: OrderWithCases): + + # GIVEN an order with two samples with the same tube container name + valid_order.cases[0].samples[0].container = ContainerEnum.tube + valid_order.cases[0].samples[1].container = ContainerEnum.tube + valid_order.cases[0].samples[0].container_name = "tube_name" + valid_order.cases[0].samples[1].container_name = "tube_name" + + # WHEN validating the tube container name uniqueness + errors: list[ContainerNameRepeatedError] = validate_tube_container_name_unique( + order=valid_order + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the non-unique tube container name + assert isinstance(errors[0], ContainerNameRepeatedError) + assert errors[0].sample_index == 0 and errors[0].case_index == 0 + + +def test_applications_exist(valid_order: OrderWithCases, base_store: Store): + # GIVEN an order where one of the samples has an invalid application + for case in valid_order.cases: + case.samples[0].application = "Invalid application" + + # WHEN validating the order + errors: list[ApplicationNotValidError] = validate_application_exists( + order=valid_order, store=base_store + ) + + # THEN an error should be returned + assert errors + + # THEN the error should be about the ticket number + assert isinstance(errors[0], ApplicationNotValidError) + + +def test_applications_not_archived( + valid_order: OrderWithCases, base_store: Store, archived_application: Application +): + # GIVEN an order where one of the samples has an invalid application + base_store.session.add(archived_application) + base_store.commit_to_store() + for case in valid_order.cases: + case.samples[0].application = archived_application.tag + + # WHEN validating the order + errors: list[ApplicationArchivedError] = validate_application_not_archived( + order=valid_order, store=base_store + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the archived application + assert isinstance(errors[0], ApplicationArchivedError) + + +def test_missing_required_volume(valid_order: OrderWithCases): + + # GIVEN an orders with two samples with missing volumes + valid_order.cases[0].samples[0].container = ContainerEnum.tube + valid_order.cases[0].samples[0].volume = None + + valid_order.cases[0].samples[1].container = ContainerEnum.plate + valid_order.cases[0].samples[1].volume = None + + # WHEN validating that required volumes are set + errors: list[VolumeRequiredError] = validate_volume_required(order=valid_order) + + # THEN an error should be returned + assert errors + + # THEN the errors should concern the missing volumes + assert isinstance(errors[0], VolumeRequiredError) + assert errors[0].sample_index == 0 and errors[0].case_index == 0 + + assert isinstance(errors[1], VolumeRequiredError) + assert errors[1].sample_index == 1 and errors[1].case_index == 0 + + +def test_sample_internal_ids_does_not_exist( + valid_order: OrderWithCases, + base_store: Store, + store_with_multiple_cases_and_samples: Store, +): + + # GIVEN an order with a sample marked as existing but which does not exist in the database + existing_sample = ExistingSample(internal_id="Non-existent sample", status=StatusEnum.unknown) + valid_order.cases[0].samples.append(existing_sample) + + # WHEN validating that the samples exists + errors: list[SampleDoesNotExistError] = validate_samples_exist( + order=valid_order, store=store_with_multiple_cases_and_samples + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the non-existent sample + assert isinstance(errors[0], SampleDoesNotExistError) + + +def test_application_is_incompatible( + valid_order: TomteOrder, sample_with_non_compatible_application: TomteSample, base_store: Store +): + + # GIVEN an order that has a sample with an application which is incompatible with the workflow + valid_order.cases[0].samples.append(sample_with_non_compatible_application) + + # WHEN validating the order + errors: list[ApplicationNotCompatibleError] = validate_application_compatibility( + order=valid_order, store=base_store + ) + + # THEN an error should be returned + assert errors + + # THEN the error should be about the application compatability + assert isinstance(errors[0], ApplicationNotCompatibleError) + + +def test_subject_ids_same_as_case_names_not_allowed(valid_order: TomteOrder): + + # GIVEN an order with a sample having its subject_id same as the case's name + case_name = valid_order.cases[0].name + valid_order.cases[0].samples[0].subject_id = case_name + + # WHEN validating that no subject ids are the same as the case name + errors: list[SubjectIdSameAsCaseNameError] = validate_subject_ids_different_from_case_names( + valid_order + ) + + # THEN an error should be returned + assert errors + + # THEN the error should be concerning the subject id being the same as the case name + assert isinstance(errors[0], SubjectIdSameAsCaseNameError) + + +def test_well_position_missing( + valid_order: TomteOrder, sample_with_missing_well_position: TomteSample +): + # GIVEN an order with a sample with a missing well position + valid_order.cases[0].samples.append(sample_with_missing_well_position) + + # WHEN validating that no well positions are missing + errors: list[WellPositionMissingError] = validate_well_positions_required(valid_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the missing well position + assert isinstance(errors[0], WellPositionMissingError) + + +def test_container_name_missing( + valid_order: TomteOrder, sample_with_missing_container_name: TomteSample +): + + # GIVEN an order with a sample missing its container name + valid_order.cases[0].samples.append(sample_with_missing_container_name) + + # WHEN validating that it is not missing any container names + errors: list[ContainerNameMissingError] = validate_container_name_required(order=valid_order) + + # THEN an error should be raised + assert errors + + # THEN the error should concern the missing container name + assert isinstance(errors[0], ContainerNameMissingError) + + +@pytest.mark.parametrize("sample_volume", [1, 200], ids=["Too low", "Too high"]) +def test_volume_out_of_bounds(valid_order: TomteOrder, sample_volume: int): + + # GIVEN an order containing a sample with an invalid volume + valid_order.cases[0].samples[0].volume = sample_volume + + # WHEN validating that the volume is within bounds + errors: list[InvalidVolumeError] = validate_volume_interval(valid_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the invalid volume + assert isinstance(errors[0], InvalidVolumeError) + + +def test_multiple_samples_in_well_not_allowed(order_with_samples_in_same_well: OrderWithCases): + + # GIVEN an order with multiple samples in the same well + + # WHEN validating the order + errors: list[OccupiedWellError] = validate_wells_contain_at_most_one_sample( + order_with_samples_in_same_well + ) + + # THEN an error should be returned + assert errors + + # THEN the error should be about the well + assert isinstance(errors[0], OccupiedWellError) + + +def test_repeated_sample_names_not_allowed( + order_with_repeated_sample_names: OrderWithCases, base_store: Store +): + # GIVEN an order with samples in a case with the same name + + # WHEN validating the order + errors: list[SampleNameRepeatedError] = validate_sample_names_not_repeated( + order=order_with_repeated_sample_names, store=base_store + ) + + # THEN errors are returned + assert errors + + # THEN the errors are about the sample names + assert isinstance(errors[0], SampleNameRepeatedError) + + +def test_elution_buffer_is_not_allowed(valid_order: TomteOrder): + + # GIVEN an order with 'skip reception control' toggled but no buffers specfied + valid_order.skip_reception_control = True + + # WHEN validating that the buffers conform to the 'skip reception control' requirements + errors: list[InvalidBufferError] = validate_buffers_are_allowed(valid_order) + + # THEN an error should be returned + assert errors + + # THEN the error should be about the buffer compatability + assert isinstance(errors[0], InvalidBufferError) + + +def test_subject_id_same_as_sample_name_is_not_allowed(valid_order: TomteOrder): + + # GIVEN an order with a sample with same name and subject id + sample_name = valid_order.cases[0].samples[0].name + valid_order.cases[0].samples[0].subject_id = sample_name + + # WHEN validating that the subject ids are different from the sample names + errors: list[SubjectIdSameAsSampleNameError] = validate_subject_ids_different_from_sample_names( + valid_order + ) + + # THEN an error should be returned + assert errors + + # THEN the error should be about the subject id being the same as the sample name + assert isinstance(errors[0], SubjectIdSameAsSampleNameError) + + +def test_concentration_required_if_skip_rc(valid_order: OrderWithCases): + # GIVEN an order with missing concentration trying to skip reception control + valid_order.skip_reception_control = True + + # WHEN validating that concentration is provided + errors: list[ConcentrationRequiredIfSkipRCError] = validate_concentration_required_if_skip_rc( + valid_order + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the missing concentration + assert isinstance(errors[0], ConcentrationRequiredIfSkipRCError) + + +def test_concentration_not_within_interval_if_skip_rc( + order_with_invalid_concentration: TomteOrder, + sample_with_invalid_concentration: TomteSample, + base_store: Store, + application_with_concentration_interval: Application, +): + + # GIVEN an order skipping reception control + # GIVEN that the order has a sample with invalid concentration for its application + base_store.session.add(application_with_concentration_interval) + base_store.session.commit() + + # WHEN validating that the concentration is within the allowed interval + errors: list[InvalidConcentrationIfSkipRCError] = validate_concentration_interval_if_skip_rc( + order=order_with_invalid_concentration, store=base_store + ) + + # THEN an error is returned + assert errors + + # THEN the error should concern the application interval + assert isinstance(errors[0], InvalidConcentrationIfSkipRCError) + + +def test_missing_volume_no_container(valid_order: OrderWithCases): + + # GIVEN an order with a sample with missing volume, but which is in no container + valid_order.cases[0].samples[0].container = ContainerEnum.no_container + valid_order.cases[0].samples[0].volume = None + + # WHEN validating that the order has required volumes set + errors: list[VolumeRequiredError] = validate_volume_required(order=valid_order) + + # THEN no error should be returned + assert not errors + + +def test_validate_sex_subject_id_clash(valid_order: OrderWithCases, 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: list[SexSubjectIdError] = 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) + + +def test_validate_sex_subject_id_no_clash(valid_order: OrderWithCases, 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 that the order's sample has a matching sex to the one in StatusDB + valid_order.cases[0].samples[0].sex = SexEnum.female + sample.sex = SexEnum.female + + # WHEN validating the order + errors: list[SexSubjectIdError] = validate_subject_sex_consistency( + order=valid_order, + store=sample_store, + ) + + # THEN no error should be returned + assert not errors + + +def test_validate_sex_subject_id_existing_sex_unknown( + valid_order: OrderWithCases, 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 known sex and the existing sample's sex is unknown + valid_order.cases[0].samples[0].sex = SexEnum.female + sample.sex = SexEnum.unknown + + # WHEN validating the order + errors: list[SexSubjectIdError] = validate_subject_sex_consistency( + order=valid_order, + store=sample_store, + ) + + # THEN no error should be returned + assert not errors + + +def test_validate_sex_subject_id_new_sex_unknown(valid_order: OrderWithCases, 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 an unknown sex and the existing sample's sex is known + valid_order.cases[0].samples[0].sex = SexEnum.unknown + sample.sex = SexEnum.female + + # WHEN validating the order + errors: list[SexSubjectIdError] = validate_subject_sex_consistency( + order=valid_order, + store=sample_store, + ) + + # THEN no error should be returned + assert not errors + + +def test_validate_sample_names_different_from_case_names( + order_with_samples_having_same_names_as_cases: OrderWithCases, base_store: Store +): + # GIVEN an order with a case holding samples with the same name as cases in the order + + # WHEN validating that the sample names are different from the case names + errors: list[SampleNameSameAsCaseNameError] = validate_sample_names_different_from_case_names( + order=order_with_samples_having_same_names_as_cases, store=base_store + ) + + # THEN a list with two errors should be returned + assert len(errors) == 2 + + # THEN the errors should concern the same case and sample name and hold the correct indices + for error in errors: + assert isinstance(error, SampleNameSameAsCaseNameError) + assert error.case_index == 0 + + assert errors[0].sample_index == 0 + assert errors[1].sample_index == 1 + + +def test_validate_sample_names_different_from_existing_case_names( + valid_order: TomteOrder, store_with_multiple_cases_and_samples: Store +): + # GIVEN an order with a case holding samples with the same name as an existing case in the order + case = store_with_multiple_cases_and_samples.get_cases()[0] + existing_case = ExistingCase(internal_id=case.internal_id, panels=case.panels) + valid_order.cases.append(existing_case) + valid_order.cases[0].samples[0].name = case.name + + # WHEN validating that the sample names are different from the case names + errors: list[SampleNameSameAsCaseNameError] = validate_sample_names_different_from_case_names( + order=valid_order, store=store_with_multiple_cases_and_samples + ) + + # THEN a list with one error should be returned + assert len(errors) == 1 + + # THEN the errors should concern the same case and sample name and hold the correct indices + error = errors[0] + assert isinstance(error, SampleNameSameAsCaseNameError) + assert error.case_index == 0 + assert error.sample_index == 0 + + +def test_validate_not_all_samples_unknown_in_case(valid_order: OrderWithCases): + + # GIVEN an order with a case with all samples unknown + for sample in valid_order.cases[0].samples: + sample.status = StatusEnum.unknown + + # WHEN validating that not all samples are unknown in a case + errors: list[StatusUnknownError] = validate_not_all_samples_unknown_in_case(order=valid_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the case with all samples unknown + assert isinstance(errors[0], StatusUnknownError) + + +def test_validate_buffer_required(mip_dna_order: MipDnaOrder, application_tag_required_buffer: str): + + # GIVEN an order for which the buffer is only required for samples running certain applications + + # GIVEN that one of its samples has an app tag which makes the elution buffer mandatory + sample = mip_dna_order.cases[0].samples[0] + sample.application = application_tag_required_buffer + + # GIVEN that the sample has no buffer set + sample.elution_buffer = None + + # WHEN validating that required buffers are set + errors: list[BufferMissingError] = validate_buffer_required(mip_dna_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the missing buffer + error = errors[0] + assert isinstance(error, BufferMissingError) + assert error.sample_index == 0 and error.case_index == 0 + + +def test_existing_sample_from_outside_of_collaboration( + mip_dna_order: MipDnaOrder, + store_with_multiple_cases_and_samples: Store, + sample_id_in_single_case: str, +): + + # GIVEN a customer from outside the order's customer's collaboration + new_customer = store_with_multiple_cases_and_samples.add_customer( + internal_id="NewCustomer", + name="New customer", + invoice_address="Test street", + invoice_reference="Invoice reference", + ) + store_with_multiple_cases_and_samples.add_item_to_store(new_customer) + store_with_multiple_cases_and_samples.commit_to_store() + + # GIVEN a sample belonging to the customer is added to the order + sample: Sample = store_with_multiple_cases_and_samples.get_sample_by_internal_id( + sample_id_in_single_case + ) + sample.customer = new_customer + existing_sample = ExistingSample(internal_id=sample.internal_id) + mip_dna_order.cases[0].samples.append(existing_sample) + + # WHEN validating that the order does not contain samples from outside the customer's collaboration + errors: list[SampleOutsideOfCollaborationError] = ( + validate_existing_samples_belong_to_collaboration( + order=mip_dna_order, store=store_with_multiple_cases_and_samples + ) + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the added existing case + assert isinstance(errors[0], SampleOutsideOfCollaborationError) diff --git a/tests/services/orders/validation_service/test_model_validator.py b/tests/services/orders/validation_service/test_model_validator.py new file mode 100644 index 0000000000..c2ecb84068 --- /dev/null +++ b/tests/services/orders/validation_service/test_model_validator.py @@ -0,0 +1,132 @@ +import pytest + +from cg.services.orders.validation.model_validator.model_validator import ModelValidator +from cg.services.orders.validation.models.order import Order +from cg.services.orders.validation.workflows.fluffy.models.order import FluffyOrder +from cg.services.orders.validation.workflows.mutant.models.order import MutantOrder +from cg.services.orders.validation.workflows.rml.models.order import RmlOrder +from cg.services.orders.validation.workflows.tomte.models.order import TomteOrder + + +@pytest.mark.parametrize( + "order_fixture, expected_index_sequence, order_model", + [ + ("fluffy_order_to_submit", "C01 IDT_10nt_568 (TGTGAGCGAA-AACTCCGATC)", FluffyOrder), + ("rml_order_to_submit", "C01 IDT_10nt_568 (TGTGAGCGAA-AACTCCGATC)", RmlOrder), + ], + ids=["fluffy", "rml"], +) +def test_validate_pool_sample_default_index( + order_fixture: str, + expected_index_sequence: str, + order_model: type[Order], + model_validator: ModelValidator, + request: pytest.FixtureRequest, +): + """Test the default index sequence is set for a pool sample without index sequence.""" + # GIVEN a pool raw order with a sample without index sequence but correct index and index number + raw_order: dict = request.getfixturevalue(order_fixture) + assert not raw_order["samples"][0]["index_sequence"] + + # WHEN validating the order + order, _ = model_validator.validate(order=raw_order, model=order_model) + + # THEN the index sequence should be set to the default index sequence + assert order.samples[0].index_sequence == expected_index_sequence + + +def test_validate_mutant_sample_gets_lab_and_region( + sarscov2_order_to_submit: dict, model_validator: ModelValidator +): + """Test the lab address and region code are set for a mutant sample without these fields.""" + # GIVEN a Mutant order with a sample without lab address and region code + assert not sarscov2_order_to_submit["samples"][0]["original_lab_address"] + assert not sarscov2_order_to_submit["samples"][0]["region_code"] + + # WHEN validating the order + order, _ = model_validator.validate(order=sarscov2_order_to_submit, model=MutantOrder) + + # THEN the lab address and region code should be set + assert order.samples[0].original_lab_address == "171 76 Stockholm" + assert order.samples[0].region_code == "01" + + +def test_order_field_error(valid_order: TomteOrder, model_validator: ModelValidator): + # GIVEN a Tomte order with an order field error + valid_order.name = "" + raw_order: dict = valid_order.model_dump(by_alias=True) + + # WHEN validating the order + _, errors = model_validator.validate(order=raw_order, model=TomteOrder) + + # THEN there should be an order error + assert errors.order_errors + + # THEN the error should concern the missing name + assert errors.order_errors[0].field == "name" + + +def test_case_field_error(valid_order: TomteOrder, model_validator: ModelValidator): + # GIVEN a Tomte order with a case field error + valid_order.cases[0].priority = None + raw_order: dict = valid_order.model_dump() + + # WHEN validating the order + _, errors = model_validator.validate(order=raw_order, model=TomteOrder) + + # THEN there should be a case error + assert errors.case_errors + + # THEN the error should concern the missing name + assert errors.case_errors[0].field == "priority" + + +def test_case_sample_field_error(valid_order: TomteOrder, model_validator: ModelValidator): + + # GIVEN a Tomte order with a case sample error + valid_order.cases[0].samples[0].well_position = 1.8 + raw_order: dict = valid_order.model_dump() + + # WHEN validating the order + _, errors = model_validator.validate(order=raw_order, model=TomteOrder) + + # THEN a case sample error should be returned + assert errors.case_sample_errors + + # THEN the case sample error should concern the invalid data type + assert errors.case_sample_errors[0].field == "well_position" + + +def test_order_case_and_case_sample_field_error( + valid_order: TomteOrder, model_validator: ModelValidator +): + # GIVEN a Tomte order with an order, case and case sample error + valid_order.name = None + valid_order.cases[0].priority = None + valid_order.cases[0].samples[0].well_position = 1.8 + raw_order: dict = valid_order.model_dump(by_alias=True) + + # WHEN validating the order + _, errors = model_validator.validate(order=raw_order, model=TomteOrder) + + # THEN all errors should be returned + assert errors.order_errors + assert errors.case_errors + assert errors.case_sample_errors + + # THEN the errors should concern the relevant fields + assert errors.order_errors[0].field == "name" + assert errors.case_errors[0].field == "priority" + assert errors.case_sample_errors[0].field == "well_position" + + +def test_null_conversion(valid_order: TomteOrder, model_validator: ModelValidator): + # GIVEN a Tomte order with a sample with empty concentration + valid_order.cases[0].samples[0].concentration_ng_ul = "" + raw_order: dict = valid_order.model_dump(by_alias=True) + + # WHEN validating the order + order, _ = model_validator.validate(order=raw_order, model=TomteOrder) + + # THEN the empty concentration should be converted to None + assert order.cases[0].samples[0].concentration_ng_ul is None diff --git a/tests/services/orders/validation_service/test_order_rules.py b/tests/services/orders/validation_service/test_order_rules.py new file mode 100644 index 0000000000..cffac34469 --- /dev/null +++ b/tests/services/orders/validation_service/test_order_rules.py @@ -0,0 +1,64 @@ +from cg.services.orders.validation.errors.order_errors import ( + CustomerCannotSkipReceptionControlError, + CustomerDoesNotExistError, + UserNotAssociatedWithCustomerError, +) +from cg.services.orders.validation.rules.order.rules import ( + validate_customer_can_skip_reception_control, + validate_customer_exists, + validate_user_belongs_to_customer, +) +from cg.services.orders.validation.workflows.tomte.models.order import TomteOrder +from cg.store.models import Customer +from cg.store.store import Store + + +def test_validate_customer_can_skip_reception_control(base_store: Store, valid_order: TomteOrder): + # GIVEN an order attempting to skip reception control from a not trusted customer + customer: Customer = base_store.get_customer_by_internal_id(valid_order.customer) + customer.is_trusted = False + valid_order.skip_reception_control = True + + # WHEN validating that the customer can skip reception control + errors: list[CustomerCannotSkipReceptionControlError] = ( + validate_customer_can_skip_reception_control(order=valid_order, store=base_store) + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the customer not being allowed to skip reception control + assert isinstance(errors[0], CustomerCannotSkipReceptionControlError) + + +def test_validate_customer_does_not_exist(base_store: Store, valid_order: TomteOrder): + # GIVEN an order from an unknown customer + valid_order.customer = "Unknown customer" + + # WHEN validating that the customer exists + errors: list[CustomerDoesNotExistError] = validate_customer_exists( + order=valid_order, store=base_store + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the unknown customer + assert isinstance(errors[0], CustomerDoesNotExistError) + + +def test_validate_user_belongs_to_customer(base_store: Store, valid_order: TomteOrder): + # GIVEN an order for a customer which the logged-in user does not have access to + customer: Customer = base_store.get_customer_by_internal_id(valid_order.customer) + customer.users = [] + + # WHEN validating that the user belongs to the customer account + errors: list[UserNotAssociatedWithCustomerError] = validate_user_belongs_to_customer( + order=valid_order, store=base_store + ) + + # THEN an error should be raised + assert errors + + # THEN the error should concern the user not belonging to the customer + assert isinstance(errors[0], UserNotAssociatedWithCustomerError) diff --git a/tests/services/orders/validation_service/test_validation_service.py b/tests/services/orders/validation_service/test_validation_service.py new file mode 100644 index 0000000000..16add7d335 --- /dev/null +++ b/tests/services/orders/validation_service/test_validation_service.py @@ -0,0 +1,21 @@ +import pytest + +from cg.exc import OrderError +from cg.models.orders.constants import OrderType +from cg.services.orders.validation.service import OrderValidationService + + +def test_parse_and_validate_pydantic_error( + order_validation_service: OrderValidationService, invalid_balsamic_order_to_submit: dict +): + # GIVEN a raw order that will fail validation and a validation service + + # WHEN parsing and validating the order + + # THEN an OrderError should be raised + with pytest.raises(OrderError): + order_validation_service.parse_and_validate( + raw_order=invalid_balsamic_order_to_submit, + order_type=OrderType.BALSAMIC, + user_id=1, + ) diff --git a/tests/services/orders/validation_service/workflows/__init__.py b/tests/services/orders/validation_service/workflows/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/orders/validation_service/workflows/balsamic/__init__.py b/tests/services/orders/validation_service/workflows/balsamic/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/orders/validation_service/workflows/balsamic/conftest.py b/tests/services/orders/validation_service/workflows/balsamic/conftest.py new file mode 100644 index 0000000000..0db505a3d7 --- /dev/null +++ b/tests/services/orders/validation_service/workflows/balsamic/conftest.py @@ -0,0 +1,107 @@ +import pytest + +from cg.constants.constants import CAPTUREKIT_CANCER_OPTIONS, GenomeVersion +from cg.models.orders.constants import OrderType +from cg.models.orders.sample_base import ContainerEnum, ControlEnum, SexEnum, StatusEnum +from cg.services.orders.validation.constants import MINIMUM_VOLUME, ElutionBuffer +from cg.services.orders.validation.order_type_maps import ORDER_TYPE_RULE_SET_MAP, RuleSet +from cg.services.orders.validation.service import OrderValidationService +from cg.services.orders.validation.workflows.balsamic.constants import BalsamicDeliveryType +from cg.services.orders.validation.workflows.balsamic.models.case import BalsamicCase +from cg.services.orders.validation.workflows.balsamic.models.order import BalsamicOrder +from cg.services.orders.validation.workflows.balsamic.models.sample import BalsamicSample +from cg.store.models import Application, Customer, User +from cg.store.store import Store + + +def create_sample(id: int) -> BalsamicSample: + return BalsamicSample( + name=f"name{id}", + application="PANKTTR020", + capture_kit=CAPTUREKIT_CANCER_OPTIONS[0], + container=ContainerEnum.plate, + container_name="ContainerName", + control=ControlEnum.not_control, + elution_buffer=ElutionBuffer.WATER, + 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, + tumour=False, + ) + + +def create_case(samples: list[BalsamicSample]) -> BalsamicCase: + return BalsamicCase( + name="name", + samples=samples, + ) + + +def create_order(cases: list[BalsamicCase]) -> BalsamicOrder: + order = BalsamicOrder( + delivery_type=BalsamicDeliveryType.FASTQ_ANALYSIS, + name="order_name", + project_type=OrderType.BALSAMIC, + customer="cust000", + cases=cases, + ) + order._user_id = 1 + order._generated_ticket_id = 12345 + return order + + +@pytest.fixture +def valid_order() -> BalsamicOrder: + sample = create_sample(1) + case = create_case([sample]) + return create_order([case]) + + +@pytest.fixture +def balsamic_application(base_store: Store) -> Application: + application: Application = base_store.add_application( + tag="PANKTTR020", + prep_category="tgs", + description="This is an application which is compatible with balsamic", + percent_kth=100, + percent_reads_guaranteed=90, + sample_concentration_minimum=50, + sample_concentration_maximum=250, + ) + application.order_types = [OrderType.BALSAMIC] + base_store.session.add(application) + base_store.commit_to_store() + return application + + +@pytest.fixture +def balsamic_validation_service( + base_store: Store, + balsamic_application: Application, +) -> OrderValidationService: + customer: Customer = base_store.get_customer_by_internal_id("cust000") + user: User = base_store.add_user(customer=customer, email="mail@email.com", name="new user") + base_store.session.add(user) + base_store.session.add(balsamic_application) + base_store.session.commit() + return OrderValidationService(base_store) + + +@pytest.fixture +def balsamic_rule_set() -> RuleSet: + return ORDER_TYPE_RULE_SET_MAP[OrderType.BALSAMIC] + + +@pytest.fixture +def another_balsamic_sample() -> BalsamicSample: + return create_sample(2) + + +@pytest.fixture +def a_third_balsamic_sample() -> BalsamicSample: + return create_sample(3) diff --git a/tests/services/orders/validation_service/workflows/balsamic/test_rules.py b/tests/services/orders/validation_service/workflows/balsamic/test_rules.py new file mode 100644 index 0000000000..d98e8e18ff --- /dev/null +++ b/tests/services/orders/validation_service/workflows/balsamic/test_rules.py @@ -0,0 +1,105 @@ +from cg.services.orders.validation.errors.case_errors import ( + DoubleNormalError, + DoubleTumourError, + MoreThanTwoSamplesInCaseError, + NumberOfNormalSamplesError, +) +from cg.services.orders.validation.errors.case_sample_errors import CaptureKitMissingError +from cg.services.orders.validation.rules.case.rules import ( + validate_at_most_two_samples_per_case, + validate_number_of_normal_samples, +) +from cg.services.orders.validation.rules.case_sample.rules import ( + validate_capture_kit_panel_requirement, +) +from cg.services.orders.validation.workflows.balsamic.models.order import BalsamicOrder +from cg.services.orders.validation.workflows.balsamic.models.sample import BalsamicSample +from cg.store.models import Application +from cg.store.store import Store + + +def test_validate_capture_kit_required( + valid_order: BalsamicOrder, base_store: Store, application_tgs: Application +): + + # GIVEN an order with a TGS sample but missing capture kit + valid_order.cases[0].samples[0].application = application_tgs.tag + valid_order.cases[0].samples[0].capture_kit = None + + # WHEN validating that the order has required capture kits set + errors: list[CaptureKitMissingError] = validate_capture_kit_panel_requirement( + order=valid_order, store=base_store + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the missing capture kit + assert isinstance(errors[0], CaptureKitMissingError) + + +def test_more_than_two_samples_in_case( + valid_order: BalsamicOrder, + another_balsamic_sample: BalsamicSample, + a_third_balsamic_sample: BalsamicSample, +): + # GIVEN a Balsamic order with three samples in the same case + + valid_order.cases[0].samples.append(another_balsamic_sample) + valid_order.cases[0].samples.append(a_third_balsamic_sample) + + # WHEN validating that the order has at most one sample per case + errors: list[MoreThanTwoSamplesInCaseError] = validate_at_most_two_samples_per_case(valid_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the multiple samples in the first case + assert isinstance(errors[0], MoreThanTwoSamplesInCaseError) + assert errors[0].case_index == 0 + + +def test_double_tumour_samples_in_case( + valid_order: BalsamicOrder, another_balsamic_sample: BalsamicSample, base_store: Store +): + # GIVEN a Balsamic order with two samples in a case + valid_order.cases[0].samples.append(another_balsamic_sample) + + # GIVEN that both samples are tumours + valid_order.cases[0].samples[0].tumour = True + valid_order.cases[0].samples[1].tumour = True + + # WHEN validating that the order has at most one sample per case + errors: list[NumberOfNormalSamplesError] = validate_number_of_normal_samples( + order=valid_order, store=base_store + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the double tumours in the case + assert isinstance(errors[0], DoubleTumourError) + assert errors[0].case_index == 0 + + +def test_double_normal_samples_in_case( + valid_order: BalsamicOrder, another_balsamic_sample: BalsamicSample, base_store: Store +): + # GIVEN a Balsamic order with two samples in a case + valid_order.cases[0].samples.append(another_balsamic_sample) + + # GIVEN that both samples are tumours + valid_order.cases[0].samples[0].tumour = False + valid_order.cases[0].samples[1].tumour = False + + # WHEN validating that the order has at most one sample per case + errors: list[NumberOfNormalSamplesError] = validate_number_of_normal_samples( + order=valid_order, store=base_store + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern the double tumours in the case + assert isinstance(errors[0], DoubleNormalError) + assert errors[0].case_index == 0 diff --git a/tests/services/orders/validation_service/workflows/balsamic/test_validation_integration.py b/tests/services/orders/validation_service/workflows/balsamic/test_validation_integration.py new file mode 100644 index 0000000000..1273fec534 --- /dev/null +++ b/tests/services/orders/validation_service/workflows/balsamic/test_validation_integration.py @@ -0,0 +1,97 @@ +from cg.models.orders.constants import OrderType +from cg.services.orders.validation.order_type_maps import RuleSet +from cg.services.orders.validation.service import OrderValidationService +from cg.services.orders.validation.workflows.balsamic.models.order import BalsamicOrder + + +def test_valid_order( + valid_order: BalsamicOrder, + balsamic_validation_service: OrderValidationService, + balsamic_rule_set: RuleSet, +): + + # GIVEN a valid order + + # WHEN validating the order + errors = balsamic_validation_service._get_errors( + raw_order=valid_order.model_dump(by_alias=True), + model=BalsamicOrder, + rule_set=balsamic_rule_set, + user_id=valid_order._user_id, + ) + + # THEN no errors should be raised + assert not errors.order_errors + assert not errors.case_errors + assert not errors.case_sample_errors + + +def test_valid_order_conversion( + valid_order: BalsamicOrder, + balsamic_validation_service: OrderValidationService, +): + + # GIVEN a valid order + order: dict = valid_order.model_dump() + + # WHEN validating the order + response = balsamic_validation_service.get_validation_response( + raw_order=order, order_type=OrderType.BALSAMIC, user_id=valid_order._user_id + ) + + # THEN a response should be given + assert response + + +def test_order_error_conversion( + valid_order: BalsamicOrder, + balsamic_validation_service: OrderValidationService, +): + + # GIVEN an order with a missing field on order level + valid_order.name = "" + order: dict = valid_order.model_dump() + + # WHEN validating the order + response: dict = balsamic_validation_service.get_validation_response( + raw_order=order, order_type=OrderType.BALSAMIC, user_id=valid_order._user_id + ) + + # THEN there should be an error for the missing name + assert response["name"]["errors"] + + +def test_case_error_conversion( + valid_order: BalsamicOrder, + balsamic_validation_service: OrderValidationService, +): + + # GIVEN an order with a faulty case priority + valid_order.cases[0].priority = "Non-existent priority" + order = valid_order.model_dump() + + # WHEN validating the order + response: dict = balsamic_validation_service.get_validation_response( + raw_order=order, order_type=OrderType.BALSAMIC, user_id=valid_order._user_id + ) + + # THEN there should be an error for the faulty priority + assert response["cases"][0]["priority"]["errors"] + + +def test_sample_error_conversion( + valid_order: BalsamicOrder, + balsamic_validation_service: OrderValidationService, +): + + # GIVEN an order with a sample with an invalid field + valid_order.cases[0].samples[0].volume = 1 + invalid_order: dict = valid_order.model_dump(by_alias=True) + + # WHEN validating the order + response = balsamic_validation_service.get_validation_response( + raw_order=invalid_order, order_type=OrderType.BALSAMIC, user_id=valid_order._user_id + ) + + # THEN an error should be returned regarding the invalid volume + assert response["cases"][0]["samples"][0]["volume"]["errors"] diff --git a/tests/services/orders/validation_service/workflows/tomte/__init__.py b/tests/services/orders/validation_service/workflows/tomte/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/orders/validation_service/workflows/tomte/test_case_sample_rules.py b/tests/services/orders/validation_service/workflows/tomte/test_case_sample_rules.py new file mode 100644 index 0000000000..ed244d2330 --- /dev/null +++ b/tests/services/orders/validation_service/workflows/tomte/test_case_sample_rules.py @@ -0,0 +1,162 @@ +from cg.models.orders.sample_base import StatusEnum +from cg.services.orders.validation.errors.case_errors import ( + InvalidGenePanelsError, + RepeatedGenePanelsError, +) +from cg.services.orders.validation.errors.case_sample_errors import ( + DescendantAsFatherError, + FatherNotInCaseError, + InvalidFatherSexError, + PedigreeError, + SampleIsOwnFatherError, +) +from cg.services.orders.validation.models.existing_sample import ExistingSample +from cg.services.orders.validation.rules.case.rules import validate_gene_panels_unique +from cg.services.orders.validation.rules.case_sample.rules import ( + validate_fathers_are_male, + validate_fathers_in_same_case_as_children, + validate_gene_panels_exist, + validate_pedigree, +) +from cg.services.orders.validation.workflows.tomte.models.order import TomteOrder +from cg.store.store import Store +from tests.store_helpers import StoreHelpers + + +def test_invalid_gene_panels(valid_order: TomteOrder, base_store: Store): + # GIVEN an order with an invalid gene panel specified + invalid_panel = "Non-existent panel" + valid_order.cases[0].panels = [invalid_panel] + + # WHEN validating that the gene panels exist + errors: list[InvalidGenePanelsError] = validate_gene_panels_exist( + order=valid_order, store=base_store + ) + + # THEN an error should be returned + assert errors + + # THEN the error should concern invalid gene panels + assert isinstance(errors[0], InvalidGenePanelsError) + + +def test_repeated_gene_panels(valid_order: TomteOrder, store_with_panels: Store): + # GIVEN an order with repeated gene panels specified + panel: str = store_with_panels.get_panels()[0].abbrev + valid_order.cases[0].panels = [panel, panel] + + # WHEN validating that the gene panels are unique + errors: list[RepeatedGenePanelsError] = validate_gene_panels_unique(valid_order) + + # THEN an error should be returned + assert errors + + # THEN the error should concern repeated gene panels + assert isinstance(errors[0], RepeatedGenePanelsError) + + +def test_father_must_be_male(order_with_invalid_father_sex: TomteOrder): + # GIVEN an order with an incorrectly specified father + + # WHEN validating the order + errors: list[InvalidFatherSexError] = validate_fathers_are_male(order_with_invalid_father_sex) + + # THEN errors are returned + assert errors + + # THEN the errors are about the father sex + assert isinstance(errors[0], InvalidFatherSexError) + + +def test_father_in_wrong_case(order_with_father_in_wrong_case: TomteOrder): + + # GIVEN an order with the father sample in the wrong case + + # WHEN validating the order + errors: list[FatherNotInCaseError] = validate_fathers_in_same_case_as_children( + order_with_father_in_wrong_case + ) + + # THEN an error is returned + assert errors + + # THEN the error is about the father being in the wrong case + assert isinstance(errors[0], FatherNotInCaseError) + + +def test_sample_cannot_be_its_own_father(valid_order: TomteOrder, base_store: Store): + # GIVEN an order with a sample which has itself as a parent + sample = valid_order.cases[0].samples[0] + sample.father = sample.name + + # WHEN validating the order + errors: list[PedigreeError] = validate_pedigree(order=valid_order, store=base_store) + + # THEN an error is returned + assert errors + + # THEN the error is about the sample having itself as a parent + assert isinstance(errors[0], SampleIsOwnFatherError) + + +def test_sample_cycle_not_allowed(order_with_sample_cycle: TomteOrder, base_store: Store): + # GIVEN an order where a sample is a descendant of itself + + # WHEN validating the order + errors: list[PedigreeError] = validate_pedigree(order=order_with_sample_cycle, store=base_store) + + # THEN an error is returned + assert errors + + # THEN the error is about the sample being a descendant of itself + assert isinstance(errors[0], DescendantAsFatherError) + + +def test_incest_is_allowed(order_with_siblings_as_parents: TomteOrder, base_store: Store): + # GIVEN an order where parents are siblings + + # WHEN validating the order + errors: list[PedigreeError] = validate_pedigree( + order=order_with_siblings_as_parents, store=base_store + ) + + # THEN no error is returned + assert not errors + + +def test_existing_samples_in_tree( + valid_order: TomteOrder, base_store: Store, helpers: StoreHelpers +): + # GIVEN a valid order where an existing sample is added + sample = helpers.add_sample(store=base_store) + existing_sample = ExistingSample(internal_id=sample.internal_id, status=StatusEnum.affected) + valid_order.cases[0].samples.append(existing_sample) + + # WHEN validating the order + errors: list[PedigreeError] = validate_pedigree(order=valid_order, store=base_store) + + # THEN no error is returned + assert not errors + + +def test_existing_sample_cycle_not_allowed( + order_with_existing_sample_cycle: TomteOrder, base_store: Store, helpers: StoreHelpers +): + + # GIVEN an order containing an existing sample and a cycle + existing_sample = order_with_existing_sample_cycle.cases[0].samples[1] + assert not existing_sample.is_new + helpers.add_sample( + store=base_store, name="ExistingSampleName", internal_id=existing_sample.internal_id + ) + + # WHEN validating the order + errors: list[PedigreeError] = validate_pedigree( + order=order_with_existing_sample_cycle, store=base_store + ) + + # THEN an error is returned + assert errors + + # THEN the error is about the sample being a descendant of itself + assert isinstance(errors[0], DescendantAsFatherError) diff --git a/tests/services/orders/validation_service/workflows/tomte/test_validation_integration.py b/tests/services/orders/validation_service/workflows/tomte/test_validation_integration.py new file mode 100644 index 0000000000..b6c0e4e954 --- /dev/null +++ b/tests/services/orders/validation_service/workflows/tomte/test_validation_integration.py @@ -0,0 +1,94 @@ +from cg.models.orders.constants import OrderType +from cg.services.orders.validation.order_type_maps import RuleSet +from cg.services.orders.validation.service import OrderValidationService +from cg.services.orders.validation.workflows.tomte.models.order import TomteOrder + + +def test_valid_order( + valid_order: TomteOrder, + tomte_validation_service: OrderValidationService, + tomte_rule_set: RuleSet, +): + + # GIVEN a valid order + + # WHEN validating the order + errors = tomte_validation_service._get_errors( + raw_order=valid_order.model_dump(by_alias=True), + model=TomteOrder, + rule_set=tomte_rule_set, + user_id=valid_order._user_id, + ) + + # THEN no errors should be raised + assert not errors.order_errors + assert not errors.case_errors + assert not errors.case_sample_errors + + +def test_valid_order_conversion( + valid_order: TomteOrder, + tomte_validation_service: OrderValidationService, +): + + # GIVEN a valid order + order: dict = valid_order.model_dump(by_alias=True) + + # WHEN validating the order + response = tomte_validation_service.get_validation_response( + raw_order=order, order_type=OrderType.TOMTE, user_id=valid_order._user_id + ) + + # THEN a response should be given + assert response + + +def test_order_error_conversion( + valid_order: TomteOrder, + tomte_validation_service: OrderValidationService, +): + + # GIVEN an order with a missing field on order level + valid_order.name = "" + order: dict = valid_order.model_dump(by_alias=True) + + # WHEN validating the order + response: dict = tomte_validation_service.get_validation_response( + raw_order=order, order_type=OrderType.TOMTE, user_id=valid_order._user_id + ) + + # THEN there should be an error for the missing name + assert response["name"]["errors"] + + +def test_case_error_conversion(valid_order, tomte_validation_service: OrderValidationService): + + # GIVEN an order with a faulty case priority + valid_order.cases[0].priority = "Non-existent priority" + order = valid_order.model_dump(by_alias=True) + + # WHEN validating the order + response: dict = tomte_validation_service.get_validation_response( + raw_order=order, order_type=OrderType.TOMTE, user_id=valid_order._user_id + ) + + # THEN there should be an error for the faulty priority + assert response["cases"][0]["priority"]["errors"] + + +def test_sample_error_conversion( + valid_order: TomteOrder, + tomte_validation_service: OrderValidationService, +): + + # GIVEN an order with a sample with an invalid field + valid_order.cases[0].samples[0].volume = 1 + invalid_order: dict = valid_order.model_dump(by_alias=True) + + # WHEN validating the order + response = tomte_validation_service.get_validation_response( + raw_order=invalid_order, order_type=OrderType.TOMTE, user_id=valid_order._user_id + ) + + # THEN an error should be returned regarding the invalid volume + assert response["cases"][0]["samples"][0]["volume"]["errors"] diff --git a/tests/store/conftest.py b/tests/store/conftest.py index 0c6c679dc1..49abb76f82 100644 --- a/tests/store/conftest.py +++ b/tests/store/conftest.py @@ -10,19 +10,10 @@ from cg.constants import Workflow from cg.constants.devices import DeviceType from cg.constants.priority import PriorityTerms -from cg.constants.subject import PhenotypeStatus, Sex +from cg.constants.subject import PhenotypeStatus from cg.services.illumina.data_transfer.models import IlluminaFlowCellDTO -from cg.services.orders.store_order_services.store_pool_order import StorePoolOrderService -from cg.store.models import ( - Analysis, - Application, - Case, - CaseSample, - Customer, - IlluminaFlowCell, - Organism, - Sample, -) +from cg.services.orders.storing.implementations.pool_order_service import StorePoolOrderService +from cg.store.models import Analysis, Application, Case, CaseSample, IlluminaFlowCell, Sample from cg.store.store import Store from tests.store_helpers import StoreHelpers diff --git a/tests/store/filters/test_status_user_filters.py b/tests/store/filters/test_status_user_filters.py index 397a66cb72..a6eeb8c5a7 100644 --- a/tests/store/filters/test_status_user_filters.py +++ b/tests/store/filters/test_status_user_filters.py @@ -1,5 +1,5 @@ from cg.store.filters.status_user_filters import filter_user_by_email -from cg.store.models import User +from cg.store.models import Customer, User from cg.store.store import Store @@ -51,3 +51,36 @@ def test_filter_user_by_email_none_returns_none(store_with_users: Store): # THEN no user should be returned assert filtered_user is None + + +def test_filter_user_by_customer(store_with_users: Store): + + # GIVEN a store with a user belonging to a customer + user: User = store_with_users._get_query(table=User).first() + customer: Customer = user.customers[0] + + # WHEN filtering the user by customer + user_is_associated: bool = store_with_users.is_user_associated_with_customer( + user_id=user.id, + customer_internal_id=customer.internal_id, + ) + + # THEN the user should be associated with the customer + assert user_is_associated + + +def test_filter_user_not_associated_with_customer( + store_with_users: Store, customer_without_users: Customer +): + + # GIVEN a store with a user not belonging to a specific customer + user: User = store_with_users._get_query(table=User).first() + + # WHEN filtering the user by customer + user_is_associated: bool = store_with_users.is_user_associated_with_customer( + user_id=user.id, + customer_internal_id=customer_without_users.internal_id, + ) + + # THEN the user should not be associated with the customer + assert not user_is_associated