From cfa2e0a134c6dbd155b90b473739fb6665c2c239 Mon Sep 17 00:00:00 2001 From: EliottBo <112384714+eliottBo@users.noreply.github.com> Date: Wed, 18 Dec 2024 13:02:15 +0100 Subject: [PATCH] feature(streamline mutant delivery and upload) (#3916) (major) # Description Add mutant upload api refactor delivery refactor concatenation --- cg/apps/lims/api.py | 8 + cg/cli/deliver/base.py | 11 +- cg/cli/deliver/utils.py | 5 +- cg/cli/upload/base.py | 3 + cg/cli/upload/fohm.py | 30 +- cg/constants/delivery.py | 1 - cg/constants/orderforms.py | 2 +- cg/meta/upload/fohm/fohm.py | 48 +-- cg/meta/upload/mutant/mutant.py | 27 ++ cg/meta/upload/upload_api.py | 5 +- cg/models/cg_config.py | 3 +- cg/services/deliver_files/constants.py | 23 ++ .../deliver_files_service.py | 90 +++-- .../deliver_files_service_factory.py | 175 ---------- cg/services/deliver_files/factory.py | 323 ++++++++++++++++++ .../deliver_files/file_fetcher/abstract.py | 2 +- .../file_fetcher/analysis_raw_data_service.py | 26 +- .../file_fetcher/analysis_service.py | 67 +++- .../deliver_files/file_fetcher/models.py | 2 +- .../file_fetcher/raw_data_service.py | 25 +- .../deliver_files/file_filter/abstract.py | 10 - .../file_filter/sample_service.py | 13 - .../{utils => destination}/__init__.py | 0 .../{ => destination}/abstract.py | 5 +- .../base_service.py} | 49 +-- .../{ => destination}/models.py | 0 .../file_formatter/files/__init__.py | 0 .../file_formatter/files/abstract.py | 15 + .../file_formatter/files/case_service.py | 83 +++++ .../files/concatenation_service.py | 307 +++++++++++++++++ .../file_formatter/files/models.py | 12 + .../file_formatter/files/mutant_service.py | 147 ++++++++ .../file_formatter/files/sample_service.py | 71 ++++ .../file_formatter/path_name/__init__.py | 0 .../file_formatter/path_name/abstract.py | 13 + .../path_name/flat_structure.py | 24 ++ .../path_name/nested_structure.py | 26 ++ .../file_formatter/utils/case_service.py | 48 --- .../utils/sample_concatenation_service.py | 128 ------- .../file_formatter/utils/sample_service.py | 84 ----- .../deliver_files/file_mover/abstract.py | 11 + .../deliver_files/file_mover/base_service.py | 30 ++ .../file_mover/customer_inbox_service.py | 61 ++++ .../deliver_files/file_mover/service.py | 111 ------ .../deliver_files/tag_fetcher/bam_service.py | 7 +- .../tag_fetcher/fohm_upload_service.py | 47 +++ .../deliver_files/tag_fetcher/models.py | 6 + .../tag_fetcher/sample_and_case_service.py | 5 +- cg/services/deliver_files/utils.py | 123 +++++++ .../fastq_concatenation_service.py | 30 +- .../fastq_concatenation_service/utils.py | 75 ++-- tests/conftest.py | 1 - .../delivery_fixtures/bundle_fixtures.py | 56 +++ .../delivery_fixtures/context_fixtures.py | 92 ++++- .../delivery_files_models_fixtures.py | 206 ++++++++++- .../delivery_formatted_files_fixtures.py | 48 ++- .../delivery_services_fixtures.py | 49 ++- .../delivery_fixtures/path_fixtures.py | 54 ++- tests/fixture_plugins/fohm/fohm_fixtures.py | 2 +- .../fixtures/orderforms/2184.10.sarscov2.xlsx | Bin 0 -> 214456 bytes .../fixtures/orderforms/2184.9.sarscov2.xlsx | Bin 223184 -> 0 bytes tests/services/__init__.py | 0 tests/services/fastq_file_service/conftest.py | 60 +++- .../test_fastq_file_service.py | 75 +++- .../delivery_file_service/test_service.py | 2 - .../test_service_builder.py | 96 +++++- .../test_file_fetching_service.py | 16 +- .../file_filter/test_sample_filter_service.py | 22 -- .../file_delivery/file_formatter/__init__.py | 0 .../file_formatter/destination/__init__.py | 0 .../test_formatting_service.py | 12 +- .../file_formatter/files/__init__.py | 0 .../files/test_formatter_utils.py | 139 ++++++++ .../path_name_formatters/__init__.py | 0 .../test_path_name_formatters.py | 54 +++ .../utils/test_formatter_utils.py | 80 ----- .../file_mover/test_file_mover_service.py | 27 +- .../tag_fetcher/test_tag_service.py | 13 + tests/store/crud/conftest.py | 1 - tests/store_helpers.py | 1 - 80 files changed, 2582 insertions(+), 941 deletions(-) create mode 100644 cg/meta/upload/mutant/mutant.py create mode 100644 cg/services/deliver_files/constants.py delete mode 100644 cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py create mode 100644 cg/services/deliver_files/factory.py delete mode 100644 cg/services/deliver_files/file_filter/abstract.py delete mode 100644 cg/services/deliver_files/file_filter/sample_service.py rename cg/services/deliver_files/file_formatter/{utils => destination}/__init__.py (100%) rename cg/services/deliver_files/file_formatter/{ => destination}/abstract.py (70%) rename cg/services/deliver_files/file_formatter/{service.py => destination/base_service.py} (56%) rename cg/services/deliver_files/file_formatter/{ => destination}/models.py (100%) create mode 100644 cg/services/deliver_files/file_formatter/files/__init__.py create mode 100644 cg/services/deliver_files/file_formatter/files/abstract.py create mode 100644 cg/services/deliver_files/file_formatter/files/case_service.py create mode 100644 cg/services/deliver_files/file_formatter/files/concatenation_service.py create mode 100644 cg/services/deliver_files/file_formatter/files/models.py create mode 100644 cg/services/deliver_files/file_formatter/files/mutant_service.py create mode 100644 cg/services/deliver_files/file_formatter/files/sample_service.py create mode 100644 cg/services/deliver_files/file_formatter/path_name/__init__.py create mode 100644 cg/services/deliver_files/file_formatter/path_name/abstract.py create mode 100644 cg/services/deliver_files/file_formatter/path_name/flat_structure.py create mode 100644 cg/services/deliver_files/file_formatter/path_name/nested_structure.py delete mode 100644 cg/services/deliver_files/file_formatter/utils/case_service.py delete mode 100644 cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py delete mode 100644 cg/services/deliver_files/file_formatter/utils/sample_service.py create mode 100644 cg/services/deliver_files/file_mover/abstract.py create mode 100644 cg/services/deliver_files/file_mover/base_service.py create mode 100644 cg/services/deliver_files/file_mover/customer_inbox_service.py delete mode 100644 cg/services/deliver_files/file_mover/service.py create mode 100644 cg/services/deliver_files/tag_fetcher/fohm_upload_service.py create mode 100644 cg/services/deliver_files/utils.py create mode 100644 tests/fixtures/orderforms/2184.10.sarscov2.xlsx delete mode 100644 tests/fixtures/orderforms/2184.9.sarscov2.xlsx create mode 100644 tests/services/__init__.py delete mode 100644 tests/services/file_delivery/file_filter/test_sample_filter_service.py create mode 100644 tests/services/file_delivery/file_formatter/__init__.py create mode 100644 tests/services/file_delivery/file_formatter/destination/__init__.py rename tests/services/file_delivery/file_formatter/{ => destination}/test_formatting_service.py (87%) create mode 100644 tests/services/file_delivery/file_formatter/files/__init__.py create mode 100644 tests/services/file_delivery/file_formatter/files/test_formatter_utils.py create mode 100644 tests/services/file_delivery/file_formatter/path_name_formatters/__init__.py create mode 100644 tests/services/file_delivery/file_formatter/path_name_formatters/test_path_name_formatters.py delete mode 100644 tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py diff --git a/cg/apps/lims/api.py b/cg/apps/lims/api.py index 71a66b799e..33b6f3130d 100644 --- a/cg/apps/lims/api.py +++ b/cg/apps/lims/api.py @@ -555,3 +555,11 @@ def _get_negative_controls_from_list(samples: list[Sample]) -> list[Sample]: ): negative_controls.append(sample) return negative_controls + + def get_sample_region_and_lab_code(self, sample_id: str) -> str: + """Return the region code and lab code for a sample formatted as a prefix string.""" + region_code: str = self.get_sample_attribute(lims_id=sample_id, key="region_code").split( + " " + )[0] + lab_code: str = self.get_sample_attribute(lims_id=sample_id, key="lab_code").split(" ")[0] + return f"{region_code}_{lab_code}_" diff --git a/cg/cli/deliver/base.py b/cg/cli/deliver/base.py index 265fba2f8f..8762c8c555 100644 --- a/cg/cli/deliver/base.py +++ b/cg/cli/deliver/base.py @@ -15,7 +15,7 @@ from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) -from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( +from cg.services.deliver_files.factory import ( DeliveryServiceFactory, ) from cg.services.deliver_files.rsync.service import DeliveryRsyncService @@ -88,8 +88,7 @@ def deliver_case( LOG.error(f"Could not find case with id {case_id}") return delivery_service: DeliverFilesService = service_builder.build_delivery_service( - case=case, - delivery_type=delivery_type, + case=case, delivery_type=delivery_type ) delivery_service.deliver_files_for_case( case=case, delivery_base_path=Path(inbox), dry_run=dry_run @@ -124,8 +123,7 @@ def deliver_ticket( LOG.error(f"Could not find case connected to ticket {ticket}") return delivery_service: DeliverFilesService = service_builder.build_delivery_service( - case=cases[0], - delivery_type=delivery_type, + case=cases[0], delivery_type=delivery_type ) delivery_service.deliver_files_for_ticket( ticket_id=ticket, delivery_base_path=Path(inbox), dry_run=dry_run @@ -172,8 +170,7 @@ def deliver_sample_raw_data( LOG.error(f"Could not find case with id {case_id}") return delivery_service: DeliverFilesService = service_builder.build_delivery_service( - case=case, - delivery_type=delivery_type, + case=case, delivery_type=delivery_type ) delivery_service.deliver_files_for_sample( case=case, sample_id=sample_id, delivery_base_path=Path(inbox), dry_run=dry_run diff --git a/cg/cli/deliver/utils.py b/cg/cli/deliver/utils.py index 14e8255c51..f4b0040112 100644 --- a/cg/cli/deliver/utils.py +++ b/cg/cli/deliver/utils.py @@ -5,7 +5,7 @@ from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) -from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( +from cg.services.deliver_files.factory import ( DeliveryServiceFactory, ) from cg.store.models import Analysis, Case @@ -26,8 +26,7 @@ def deliver_raw_data_for_analyses( try: case: Case = analysis.case delivery_service: DeliverFilesService = service_builder.build_delivery_service( - case=case, - delivery_type=case.data_delivery, + case=case, delivery_type=case.data_delivery ) delivery_service.deliver_files_for_case( diff --git a/cg/cli/upload/base.py b/cg/cli/upload/base.py index 4bf46d6303..46ffa1ac0d 100644 --- a/cg/cli/upload/base.py +++ b/cg/cli/upload/base.py @@ -39,6 +39,7 @@ from cg.meta.upload.microsalt.microsalt_upload_api import MicrosaltUploadAPI from cg.meta.upload.mip.mip_dna import MipDNAUploadAPI from cg.meta.upload.mip.mip_rna import MipRNAUploadAPI +from cg.meta.upload.mutant.mutant import MutantUploadAPI from cg.meta.upload.nf_analysis import NfAnalysisUploadAPI from cg.meta.upload.tomte.tomte import TomteUploadAPI from cg.meta.upload.raredisease.raredisease import RarediseaseUploadAPI @@ -94,6 +95,8 @@ def upload(context: click.Context, case_id: str | None, restart: bool): Workflow.TAXPROFILER, }: upload_api = NfAnalysisUploadAPI(config_object, case.data_analysis) + elif case.data_analysis == Workflow.MUTANT: + upload_api = MutantUploadAPI(config_object) context.obj.meta_apis["upload_api"] = upload_api upload_api.upload(ctx=context, case=case, restart=restart) diff --git a/cg/cli/upload/fohm.py b/cg/cli/upload/fohm.py index 6571e0be8c..34caf6ba9f 100644 --- a/cg/cli/upload/fohm.py +++ b/cg/cli/upload/fohm.py @@ -41,7 +41,11 @@ def aggregate_delivery( context: CGConfig, cases: list, dry_run: bool = False, datestr: str | None = None ): """Re-aggregates delivery files for FOHM and saves them to default working directory.""" - fohm_api = FOHMUploadAPI(config=context, dry_run=dry_run, datestr=datestr) + fohm_api = FOHMUploadAPI( + config=context, + dry_run=dry_run, + datestr=datestr, + ) try: fohm_api.aggregate_delivery(cases) except (ValidationError, TypeError) as error: @@ -57,7 +61,11 @@ def create_komplettering( context: CGConfig, cases: list, dry_run: bool = False, datestr: str | None = None ): """Re-aggregates komplettering files for FOHM and saves them to default working directory.""" - fohm_api = FOHMUploadAPI(config=context, dry_run=dry_run, datestr=datestr) + fohm_api = FOHMUploadAPI( + config=context, + dry_run=dry_run, + datestr=datestr, + ) try: fohm_api.create_and_write_complementary_report(cases) except ValidationError as error: @@ -73,7 +81,11 @@ def preprocess_all( context: CGConfig, cases: list, dry_run: bool = False, datestr: str | None = None ): """Create all FOHM upload files, upload to GISAID, sync SFTP and mail reports for all provided cases.""" - fohm_api = FOHMUploadAPI(config=context, dry_run=dry_run, datestr=datestr) + fohm_api = FOHMUploadAPI( + config=context, + dry_run=dry_run, + datestr=datestr, + ) gisaid_api = GisaidAPI(config=context) cases = list(cases) upload_cases = [] @@ -105,7 +117,11 @@ def preprocess_all( @click.pass_obj def upload_rawdata(context: CGConfig, dry_run: bool = False, datestr: str | None = None): """Deliver files in daily upload directory via sftp.""" - fohm_api = FOHMUploadAPI(config=context, dry_run=dry_run, datestr=datestr) + fohm_api = FOHMUploadAPI( + config=context, + dry_run=dry_run, + datestr=datestr, + ) fohm_api.sync_files_sftp() @@ -115,5 +131,9 @@ def upload_rawdata(context: CGConfig, dry_run: bool = False, datestr: str | None @click.pass_obj def send_reports(context: CGConfig, dry_run: bool = False, datestr: str | None = None): """Send all komplettering reports found in the current daily directory to target recipients.""" - fohm_api = FOHMUploadAPI(config=context, dry_run=dry_run, datestr=datestr) + fohm_api = FOHMUploadAPI( + config=context, + dry_run=dry_run, + datestr=datestr, + ) fohm_api.send_mail_reports() diff --git a/cg/constants/delivery.py b/cg/constants/delivery.py index ec492f28f0..f914305e3e 100644 --- a/cg/constants/delivery.py +++ b/cg/constants/delivery.py @@ -144,7 +144,6 @@ ] MUTANT_ANALYSIS_SAMPLE_TAGS: list[set[str]] = [ - {"fastq"}, {"vcf", "vcf-report", "fohm-delivery"}, ] diff --git a/cg/constants/orderforms.py b/cg/constants/orderforms.py index b071904789..b02d088c3b 100644 --- a/cg/constants/orderforms.py +++ b/cg/constants/orderforms.py @@ -39,7 +39,7 @@ def get_current_orderform_version(order_form: str) -> str: Orderform.MIP_DNA: "33", Orderform.RML: "19", Orderform.MICROSALT: "11", - Orderform.SARS_COV_2: "9", + Orderform.SARS_COV_2: "10", Orderform.MICROBIAL_FASTQ: "1", Orderform.PACBIO_LONG_READ: "1", } diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index 8026debda8..f416777a48 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -3,22 +3,22 @@ import logging import os import re -import shutil from pathlib import Path - import paramiko -from housekeeper.store.models import Version - from cg.apps.housekeeper.hk import HousekeeperAPI from cg.apps.lims import LimsAPI from cg.constants import FileExtensions -from cg.constants.constants import SARS_COV_REGEX +from cg.constants.constants import SARS_COV_REGEX, DataDelivery from cg.constants.housekeeper_tags import FohmTag from cg.exc import CgError from cg.io.csv import read_csv, write_csv_from_dict from cg.models.cg_config import CGConfig from cg.models.email import EmailInfo from cg.models.fohm.reports import FohmComplementaryReport, FohmPangolinReport +from cg.services.deliver_files.constants import DeliveryDestination, DeliveryStructure +from cg.services.deliver_files.factory import ( + DeliveryServiceFactory, +) from cg.store.models import Case, Sample from cg.store.store import Store from cg.utils.dict import remove_duplicate_dicts @@ -28,7 +28,12 @@ class FOHMUploadAPI: - def __init__(self, config: CGConfig, dry_run: bool = False, datestr: str | None = None): + def __init__( + self, + config: CGConfig, + dry_run: bool = False, + datestr: str | None = None, + ): self.config: CGConfig = config self.housekeeper_api: HousekeeperAPI = config.housekeeper_api self.lims_api: LimsAPI = config.lims_api @@ -44,6 +49,7 @@ def __init__(self, config: CGConfig, dry_run: bool = False, datestr: str | None self._reports_dataframe = None self._pangolin_dataframe = None self._aggregation_dataframe = None + self._delivery_factory: DeliveryServiceFactory = config.delivery_service_factory @property def current_datestr(self) -> str: @@ -196,16 +202,16 @@ def link_sample_raw_data_files( sample: Sample = self.status_db.get_sample_by_internal_id( internal_id=report.internal_id ) - bundle_name: str = sample.links[0].case.internal_id - version: Version = self.housekeeper_api.last_version(bundle=bundle_name) - files = self.housekeeper_api.files(version=version.id, tags={report.internal_id}).all() - for file in files: - if self._dry_run: - LOG.info( - f"Would have copied {file.full_path} to {Path(self.daily_rawdata_path)}" - ) - continue - shutil.copy(file.full_path, Path(self.daily_rawdata_path)) + case: Case = sample.links[0].case + delivery_service = self._delivery_factory.build_delivery_service( + case=case, + delivery_type=DataDelivery.FASTQ_ANALYSIS, + delivery_destination=DeliveryDestination.FOHM, + delivery_structure=DeliveryStructure.FLAT, + ) + delivery_service.deliver_files_for_sample_no_rsync( + case=case, sample_id=sample.internal_id, delivery_base_path=self.daily_rawdata_path + ) def create_pangolin_report(self, reports: list[FohmPangolinReport]) -> None: LOG.info("Creating aggregate Pangolin report") @@ -362,9 +368,13 @@ def parse_and_write_pangolin_report(self) -> list[FohmPangolinReport]: self.create_pangolin_report(sars_cov_pangolin_reports) return sars_cov_pangolin_reports - def aggregate_delivery(self, cases: list[str]) -> None: - """Aggregate and hardlink reports.""" - self.set_cases_to_aggregate(cases) + def aggregate_delivery(self, case_ids: list[str]) -> None: + """ + Aggregate and hardlink reports. + args: + case_ids: The internal ids for cases to aggregate. + """ + self.set_cases_to_aggregate(case_ids) self.create_daily_delivery_folders() sars_cov_complementary_reports: list[FohmComplementaryReport] = ( self.parse_and_write_complementary_report() diff --git a/cg/meta/upload/mutant/mutant.py b/cg/meta/upload/mutant/mutant.py new file mode 100644 index 0000000000..a61398ebe1 --- /dev/null +++ b/cg/meta/upload/mutant/mutant.py @@ -0,0 +1,27 @@ +from click import Context + +from cg.meta.upload.fohm.fohm import FOHMUploadAPI +from cg.meta.upload.gisaid import GisaidAPI +from cg.meta.upload.upload_api import UploadAPI +from cg.meta.workflow.mutant import MutantAnalysisAPI +from cg.models.cg_config import CGConfig +from cg.store.models import Analysis, Case + + +class MutantUploadAPI(UploadAPI): + + def __init__(self, config: CGConfig): + self.analysis_api: MutantAnalysisAPI = MutantAnalysisAPI(config) + self.fohm_api = FOHMUploadAPI(config) + self.gsaid_api = GisaidAPI(config) + + super().__init__(config=config, analysis_api=self.analysis_api) + + def upload(self, ctx: Context, case: Case, restart: bool) -> None: + latest_analysis: Analysis = case.analyses[0] + self.update_upload_started_at(latest_analysis) + self.upload_files_to_customer_inbox(case) + self.gsaid_api.upload(case.internal_id) + self.fohm_api.aggregate_delivery(case_ids=[case.internal_id]) + self.fohm_api.sync_files_sftp() + self.update_uploaded_at(latest_analysis) diff --git a/cg/meta/upload/upload_api.py b/cg/meta/upload/upload_api.py index d455079f92..ce52c9d913 100644 --- a/cg/meta/upload/upload_api.py +++ b/cg/meta/upload/upload_api.py @@ -15,7 +15,7 @@ from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) -from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( +from cg.services.deliver_files.factory import ( DeliveryServiceFactory, ) from cg.store.models import Analysis, Case @@ -97,8 +97,7 @@ def upload_files_to_customer_inbox(self, case: Case) -> None: """Uploads the analysis files to the customer inbox.""" factory_service: DeliveryServiceFactory = self.config.delivery_service_factory delivery_service: DeliverFilesService = factory_service.build_delivery_service( - case=case, - delivery_type=case.data_delivery, + case=case, delivery_type=case.data_delivery ) delivery_service.deliver_files_for_case( case=case, delivery_base_path=Path(self.config.delivery_path) diff --git a/cg/models/cg_config.py b/cg/models/cg_config.py index 66673796a8..bab7e52a26 100644 --- a/cg/models/cg_config.py +++ b/cg/models/cg_config.py @@ -27,7 +27,7 @@ from cg.meta.delivery.delivery import DeliveryAPI from cg.services.analysis_service.analysis_service import AnalysisService from cg.services.decompression_service.decompressor import Decompressor -from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( +from cg.services.deliver_files.factory import ( DeliveryServiceFactory, ) from cg.services.deliver_files.rsync.models import RsyncDeliveryConfig @@ -748,6 +748,7 @@ def delivery_service_factory(self) -> DeliveryServiceFactory: LOG.debug("Instantiating delivery service factory") factory = DeliveryServiceFactory( store=self.status_db, + lims_api=self.lims_api, hk_api=self.housekeeper_api, tb_service=self.trailblazer_api, rsync_service=self.delivery_rsync_service, diff --git a/cg/services/deliver_files/constants.py b/cg/services/deliver_files/constants.py new file mode 100644 index 0000000000..b126b7cf09 --- /dev/null +++ b/cg/services/deliver_files/constants.py @@ -0,0 +1,23 @@ +from enum import Enum + + +class DeliveryDestination(Enum): + """Enum for the DeliveryDestination + BASE: Deliver to the base folder provided in the call + CUSTOMER: Deliver to the customer folder on hasta + FOHM: Deliver to the FOHM folder on hasta + """ + + BASE = "base" + CUSTOMER = "customer" + FOHM = "fohm" + + +class DeliveryStructure(Enum): + """Enum for the DeliveryStructure + FLAT: Deliver the files in a flat structure, i.e. all files in the same folder + NESTED: Deliver the files in a nested structure, i.e. files in folders for each sample/case + """ + + FLAT: str = "flat" + NESTED: str = "nested" diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index bc2f8a7ddd..46cfe38a65 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -12,10 +12,11 @@ ) from cg.services.deliver_files.file_fetcher.abstract import FetchDeliveryFilesService from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_filter.abstract import FilterDeliveryFilesService -from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService -from cg.services.deliver_files.file_formatter.models import FormattedFiles -from cg.services.deliver_files.file_mover.service import DeliveryFilesMover +from cg.services.deliver_files.file_formatter.destination.abstract import ( + DeliveryDestinationFormatter, +) +from cg.services.deliver_files.file_formatter.destination.models import FormattedFiles +from cg.services.deliver_files.file_mover.abstract import DestinationFilesMover from cg.services.deliver_files.rsync.service import DeliveryRsyncService from cg.store.exc import EntryNotFoundError from cg.store.models import Case @@ -26,27 +27,28 @@ class DeliverFilesService: """ - Deliver files to the customer inbox on the HPC and Rsync them to the inbox folder on the delivery server. - 1. Get the files to deliver from Housekeeper based on workflow and data delivery - 2. Create a delivery folder structure in the customer folder on Hasta and move the files there - 3. Reformatting of output / renaming of files - 4. Rsync the files to the customer inbox on the delivery server - 5. Add the rsync job to Trailblazer + Deliver files for a case, cases in a ticket or a sample to a specified destination or upload location. + Requires: + - FetchDeliveryFilesService: Service to fetch the files to deliver from housekeeper + - DestinationFilesMover: Service to move the files to the destination of delivery or upload + - DeliveryDestinationFormatter: Service to format the files to the destination format + - DeliveryRsyncService: Service to run rsync for the delivery + - TrailblazerAPI: Service to interact with Trailblazer + - AnalysisService: Service to interact with the analysis + - Store: Store to interact with the database """ def __init__( self, delivery_file_manager_service: FetchDeliveryFilesService, - file_filter: FilterDeliveryFilesService, - move_file_service: DeliveryFilesMover, - file_formatter_service: DeliveryFileFormattingService, + move_file_service: DestinationFilesMover, + file_formatter_service: DeliveryDestinationFormatter, rsync_service: DeliveryRsyncService, tb_service: TrailblazerAPI, analysis_service: AnalysisService, status_db: Store, ): self.file_manager = delivery_file_manager_service - self.file_filter = file_filter self.file_mover = move_file_service self.file_formatter = file_formatter_service self.status_db = status_db @@ -58,14 +60,22 @@ def __init__( def deliver_files_for_case( self, case: Case, delivery_base_path: Path, dry_run: bool = False ) -> None: - """Deliver the files for a case to the customer folder.""" + """Deliver the files for a case to the customer folder. + args: + case: The case to deliver files for + delivery_base_path: The base path to deliver the files to + dry_run: Whether to perform a dry run or not + """ delivery_files: DeliveryFiles = self.file_manager.get_files_to_deliver( case_id=case.internal_id ) moved_files: DeliveryFiles = self.file_mover.move_files( delivery_files=delivery_files, delivery_base_path=delivery_base_path ) - formatted_files: FormattedFiles = self.file_formatter.format_files(moved_files) + formatted_files: FormattedFiles = self.file_formatter.format_files( + delivery_files=moved_files + ) + folders_to_deliver: set[Path] = set( [formatted_file.formatted_path.parent for formatted_file in formatted_files.files] ) @@ -77,7 +87,12 @@ def deliver_files_for_case( def deliver_files_for_ticket( self, ticket_id: str, delivery_base_path: Path, dry_run: bool = False ) -> None: - """Deliver the files for all cases in a ticket to the customer folder.""" + """Deliver the files for all cases in a ticket to the customer folder. + args: + ticket_id: The ticket id to deliver files for + delivery_base_path: The base path to deliver the files to + dry_run: Whether to perform a dry run or not + """ cases: list[Case] = self.status_db.get_cases_by_ticket_id(ticket_id) if not cases: raise EntryNotFoundError(f"No cases found for ticket {ticket_id}") @@ -91,15 +106,14 @@ def deliver_files_for_sample( ): """Deliver the files for a sample to the customer folder.""" delivery_files: DeliveryFiles = self.file_manager.get_files_to_deliver( - case_id=case.internal_id - ) - filtered_files: DeliveryFiles = self.file_filter.filter_delivery_files( - delivery_files=delivery_files, sample_id=sample_id + case_id=case.internal_id, sample_id=sample_id ) moved_files: DeliveryFiles = self.file_mover.move_files( - delivery_files=filtered_files, delivery_base_path=delivery_base_path + delivery_files=delivery_files, delivery_base_path=delivery_base_path + ) + formatted_files: FormattedFiles = self.file_formatter.format_files( + delivery_files=moved_files ) - formatted_files: FormattedFiles = self.file_formatter.format_files(moved_files) folders_to_deliver: set[Path] = set( [formatted_file.formatted_path.parent for formatted_file in formatted_files.files] ) @@ -108,7 +122,31 @@ def deliver_files_for_sample( ) self._add_trailblazer_tracking(case=case, job_id=job_id, dry_run=dry_run) + def deliver_files_for_sample_no_rsync( + self, case: Case, sample_id: str, delivery_base_path: Path + ): + """ + Deliver the files for a sample to the delivery base path. Does not perform rsync. + args: + case: The case to deliver files for + sample_id: The sample to deliver files for + delivery_base_path: The base path to deliver the files to + """ + delivery_files: DeliveryFiles = self.file_manager.get_files_to_deliver( + case_id=case.internal_id, sample_id=sample_id + ) + moved_files: DeliveryFiles = self.file_mover.move_files( + delivery_files=delivery_files, delivery_base_path=delivery_base_path + ) + self.file_formatter.format_files(delivery_files=moved_files) + def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Path]) -> int: + """Start a rsync job for the case. + args: + case: The case to start the rsync job for + dry_run: Whether to perform a dry run or not + folders_to_deliver: The folders to deliver + """ LOG.debug(f"[RSYNC] Starting rsync job for case {case.internal_id}") job_id: int = self.rsync_service.run_rsync_for_case( case=case, @@ -123,6 +161,12 @@ def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Pa return job_id def _add_trailblazer_tracking(self, case: Case, job_id: int, dry_run: bool) -> None: + """Add the rsync job to Trailblazer for tracking. + args: + case: The case to add the job for + job_id: The job id to add for trailblazer tracking + dry_run: Whether to perform a dry run or not + """ if dry_run: LOG.info(f"Would have added the analysis for case {case.internal_id} to Trailblazer") else: diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py deleted file mode 100644 index 45854fce29..0000000000 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Module for the factory of the deliver files service.""" - -from typing import Type - -from cg.apps.housekeeper.hk import HousekeeperAPI -from cg.apps.tb import TrailblazerAPI -from cg.constants import DataDelivery, Workflow -from cg.constants.sequencing import SeqLibraryPrepCategory -from cg.services.analysis_service.analysis_service import AnalysisService -from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( - DeliverFilesService, -) -from cg.services.deliver_files.deliver_files_service.exc import DeliveryTypeNotSupported -from cg.services.deliver_files.file_fetcher.abstract import FetchDeliveryFilesService -from cg.services.deliver_files.file_fetcher.analysis_raw_data_service import ( - RawDataAndAnalysisDeliveryFileFetcher, -) -from cg.services.deliver_files.file_fetcher.analysis_service import AnalysisDeliveryFileFetcher -from cg.services.deliver_files.file_fetcher.raw_data_service import RawDataDeliveryFileFetcher -from cg.services.deliver_files.file_filter.sample_service import SampleFileFilter -from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService -from cg.services.deliver_files.file_formatter.service import DeliveryFileFormatter -from cg.services.deliver_files.file_formatter.utils.case_service import CaseFileFormatter -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( - SampleFileConcatenationFormatter, -) -from cg.services.deliver_files.file_formatter.utils.sample_service import ( - SampleFileFormatter, - FileManagingService, - SampleFileNameFormatter, -) -from cg.services.deliver_files.file_mover.service import DeliveryFilesMover -from cg.services.deliver_files.rsync.service import DeliveryRsyncService -from cg.services.deliver_files.tag_fetcher.abstract import FetchDeliveryFileTagsService -from cg.services.deliver_files.tag_fetcher.bam_service import BamDeliveryTagsFetcher -from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( - SampleAndCaseDeliveryTagsFetcher, -) -from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( - FastqConcatenationService, -) -from cg.store.models import Case -from cg.store.store import Store - - -class DeliveryServiceFactory: - """Class to build the delivery services based on workflow and delivery type.""" - - def __init__( - self, - store: Store, - hk_api: HousekeeperAPI, - rsync_service: DeliveryRsyncService, - tb_service: TrailblazerAPI, - analysis_service: AnalysisService, - ): - self.store = store - self.hk_api = hk_api - self.rsync_service = rsync_service - self.tb_service = tb_service - self.analysis_service = analysis_service - - @staticmethod - def _sanitise_delivery_type(delivery_type: DataDelivery) -> DataDelivery: - """Sanitise the delivery type.""" - if delivery_type in [DataDelivery.FASTQ_QC, DataDelivery.FASTQ_SCOUT]: - return DataDelivery.FASTQ - if delivery_type in [DataDelivery.ANALYSIS_SCOUT]: - return DataDelivery.ANALYSIS_FILES - if delivery_type in [ - DataDelivery.FASTQ_ANALYSIS_SCOUT, - DataDelivery.FASTQ_QC_ANALYSIS, - ]: - return DataDelivery.FASTQ_ANALYSIS - return delivery_type - - @staticmethod - def _validate_delivery_type(delivery_type: DataDelivery): - """Check if the delivery type is supported. Raises DeliveryTypeNotSupported error.""" - if delivery_type in [ - DataDelivery.FASTQ, - DataDelivery.ANALYSIS_FILES, - DataDelivery.FASTQ_ANALYSIS, - DataDelivery.BAM, - ]: - return - raise DeliveryTypeNotSupported( - f"Delivery type {delivery_type} is not supported. Supported delivery types are" - f" {DataDelivery.FASTQ}, {DataDelivery.ANALYSIS_FILES}," - f" {DataDelivery.FASTQ_ANALYSIS}, {DataDelivery.BAM}." - ) - - @staticmethod - def _get_file_tag_fetcher(delivery_type: DataDelivery) -> FetchDeliveryFileTagsService: - """Get the file tag fetcher based on the delivery type.""" - service_map: dict[DataDelivery, Type[FetchDeliveryFileTagsService]] = { - DataDelivery.FASTQ: SampleAndCaseDeliveryTagsFetcher, - DataDelivery.ANALYSIS_FILES: SampleAndCaseDeliveryTagsFetcher, - DataDelivery.FASTQ_ANALYSIS: SampleAndCaseDeliveryTagsFetcher, - DataDelivery.BAM: BamDeliveryTagsFetcher, - } - return service_map[delivery_type]() - - def _get_file_fetcher(self, delivery_type: DataDelivery) -> FetchDeliveryFilesService: - """Get the file fetcher based on the delivery type.""" - service_map: dict[DataDelivery, Type[FetchDeliveryFilesService]] = { - DataDelivery.FASTQ: RawDataDeliveryFileFetcher, - DataDelivery.ANALYSIS_FILES: AnalysisDeliveryFileFetcher, - DataDelivery.FASTQ_ANALYSIS: RawDataAndAnalysisDeliveryFileFetcher, - DataDelivery.BAM: RawDataDeliveryFileFetcher, - } - file_tag_fetcher: FetchDeliveryFileTagsService = self._get_file_tag_fetcher(delivery_type) - return service_map[delivery_type]( - status_db=self.store, - hk_api=self.hk_api, - tags_fetcher=file_tag_fetcher, - ) - - def _convert_workflow(self, case: Case) -> Workflow: - """Converts a workflow with the introduction of the microbial-fastq delivery type an - unsupported combination of delivery type and workflow setup is required. This function - makes sure that a raw data workflow with microbial fastq delivery type is treated as a - microsalt workflow so that the microbial-fastq sample files can be concatenated.""" - tag: str = case.samples[0].application_version.application.tag - microbial_tags: list[str] = [ - application.tag - for application in self.store.get_active_applications_by_prep_category( - prep_category=SeqLibraryPrepCategory.MICROBIAL - ) - ] - if case.data_analysis == Workflow.RAW_DATA and tag in microbial_tags: - return Workflow.MICROSALT - return case.data_analysis - - def _get_sample_file_formatter( - self, - case: Case, - ) -> SampleFileFormatter | SampleFileConcatenationFormatter: - """Get the file formatter service based on the workflow.""" - converted_workflow: Workflow = self._convert_workflow(case) - if converted_workflow in [Workflow.MICROSALT]: - return SampleFileConcatenationFormatter( - file_manager=FileManagingService(), - file_formatter=SampleFileNameFormatter(), - concatenation_service=FastqConcatenationService(), - ) - return SampleFileFormatter( - file_manager=FileManagingService(), file_name_formatter=SampleFileNameFormatter() - ) - - def build_delivery_service( - self, case: Case, delivery_type: DataDelivery | None = None - ) -> DeliverFilesService: - """Build a delivery service based on a case.""" - delivery_type: DataDelivery = self._sanitise_delivery_type( - delivery_type if delivery_type else case.data_delivery - ) - self._validate_delivery_type(delivery_type) - file_fetcher: FetchDeliveryFilesService = self._get_file_fetcher(delivery_type) - sample_file_formatter: SampleFileFormatter | SampleFileConcatenationFormatter = ( - self._get_sample_file_formatter(case) - ) - file_formatter: DeliveryFileFormattingService = DeliveryFileFormatter( - case_file_formatter=CaseFileFormatter(), sample_file_formatter=sample_file_formatter - ) - return DeliverFilesService( - delivery_file_manager_service=file_fetcher, - move_file_service=DeliveryFilesMover(), - file_filter=SampleFileFilter(), - file_formatter_service=file_formatter, - status_db=self.store, - rsync_service=self.rsync_service, - tb_service=self.tb_service, - analysis_service=self.analysis_service, - ) diff --git a/cg/services/deliver_files/factory.py b/cg/services/deliver_files/factory.py new file mode 100644 index 0000000000..1701cba5fb --- /dev/null +++ b/cg/services/deliver_files/factory.py @@ -0,0 +1,323 @@ +"""Module for the factory of the deliver files service.""" + +from typing import Type + +from cg.apps.housekeeper.hk import HousekeeperAPI +from cg.apps.lims import LimsAPI +from cg.apps.tb import TrailblazerAPI +from cg.constants import DataDelivery, Workflow +from cg.constants.sequencing import SeqLibraryPrepCategory +from cg.services.analysis_service.analysis_service import AnalysisService +from cg.services.deliver_files.constants import DeliveryDestination, DeliveryStructure +from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( + DeliverFilesService, +) +from cg.services.deliver_files.deliver_files_service.exc import DeliveryTypeNotSupported +from cg.services.deliver_files.file_fetcher.abstract import FetchDeliveryFilesService +from cg.services.deliver_files.file_fetcher.analysis_raw_data_service import ( + RawDataAndAnalysisDeliveryFileFetcher, +) +from cg.services.deliver_files.file_fetcher.analysis_service import AnalysisDeliveryFileFetcher +from cg.services.deliver_files.file_fetcher.raw_data_service import RawDataDeliveryFileFetcher +from cg.services.deliver_files.file_formatter.destination.abstract import ( + DeliveryDestinationFormatter, +) +from cg.services.deliver_files.file_formatter.destination.base_service import ( + BaseDeliveryFormatter, +) +from cg.services.deliver_files.file_formatter.files.case_service import CaseFileFormatter +from cg.services.deliver_files.file_formatter.files.mutant_service import ( + MutantFileFormatter, +) +from cg.services.deliver_files.file_formatter.files.concatenation_service import ( + SampleFileConcatenationFormatter, +) +from cg.services.deliver_files.file_formatter.files.sample_service import ( + SampleFileFormatter, + FileManager, +) +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter +from cg.services.deliver_files.file_formatter.path_name.flat_structure import ( + FlatStructurePathFormatter, +) +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, +) +from cg.services.deliver_files.file_mover.abstract import DestinationFilesMover +from cg.services.deliver_files.file_mover.customer_inbox_service import ( + CustomerInboxDestinationFilesMover, +) +from cg.services.deliver_files.file_mover.base_service import BaseDestinationFilesMover +from cg.services.deliver_files.rsync.service import DeliveryRsyncService +from cg.services.deliver_files.tag_fetcher.abstract import FetchDeliveryFileTagsService +from cg.services.deliver_files.tag_fetcher.bam_service import BamDeliveryTagsFetcher +from cg.services.deliver_files.tag_fetcher.fohm_upload_service import FOHMUploadTagsFetcher +from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( + SampleAndCaseDeliveryTagsFetcher, +) +from cg.services.deliver_files.utils import FileMover +from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( + FastqConcatenationService, +) +from cg.store.models import Case +from cg.store.store import Store + + +class DeliveryServiceFactory: + """ + Class to build the delivery services based on case, workflow, delivery type, delivery destination and delivery structure. + The delivery destination is used to specify delivery to the customer or for external upload. + Workflow is used to specify the workflow of the case and is required for the tag fetcher. + Delivery type is used to specify the type of delivery to perform. + Delivery structure is used to specify the structure of the delivery. + """ + + def __init__( + self, + store: Store, + lims_api: LimsAPI, + hk_api: HousekeeperAPI, + rsync_service: DeliveryRsyncService, + tb_service: TrailblazerAPI, + analysis_service: AnalysisService, + ): + self.store = store + self.lims_api = lims_api + self.hk_api = hk_api + self.rsync_service = rsync_service + self.tb_service = tb_service + self.analysis_service = analysis_service + + @staticmethod + def _sanitise_delivery_type(delivery_type: DataDelivery) -> DataDelivery: + """Sanitise the delivery type. + We have multiple delivery types that are a combination of other delivery types or uploads. + Here we make sure to convert unsupported delivery types to supported ones. + args: + delivery_type: The type of delivery to perform. + """ + if delivery_type in [DataDelivery.FASTQ_QC, DataDelivery.FASTQ_SCOUT]: + return DataDelivery.FASTQ + if delivery_type in [DataDelivery.ANALYSIS_SCOUT]: + return DataDelivery.ANALYSIS_FILES + if delivery_type in [ + DataDelivery.FASTQ_ANALYSIS_SCOUT, + DataDelivery.FASTQ_QC_ANALYSIS, + ]: + return DataDelivery.FASTQ_ANALYSIS + return delivery_type + + @staticmethod + def _validate_delivery_type(delivery_type: DataDelivery): + """ + Check if the delivery type is supported. Raises DeliveryTypeNotSupported error. + args: + delivery_type: The type of delivery to perform. + """ + if delivery_type in [ + DataDelivery.FASTQ, + DataDelivery.ANALYSIS_FILES, + DataDelivery.FASTQ_ANALYSIS, + DataDelivery.BAM, + ]: + return + raise DeliveryTypeNotSupported( + f"Delivery type {delivery_type} is not supported. Supported delivery types are" + f" {DataDelivery.FASTQ}, {DataDelivery.ANALYSIS_FILES}," + f" {DataDelivery.FASTQ_ANALYSIS}, {DataDelivery.BAM}." + ) + + @staticmethod + def _get_file_tag_fetcher( + delivery_type: DataDelivery, delivery_destination: DeliveryDestination + ) -> FetchDeliveryFileTagsService: + """ + Get the file tag fetcher based on the delivery type or delivery destination. + NOTE: added complexity to handle the FOHM delivery type as it requires a special set of tags as compared to customer delivery. + It overrides the default behaviour of the delivery type given by the case. + args: + delivery_type: The type of delivery to perform. + delivery_destination: The destination of the delivery defaults to customer. + + """ + if delivery_destination == DeliveryDestination.FOHM: + return FOHMUploadTagsFetcher() + service_map: dict[DataDelivery, Type[FetchDeliveryFileTagsService]] = { + DataDelivery.FASTQ: SampleAndCaseDeliveryTagsFetcher, + DataDelivery.ANALYSIS_FILES: SampleAndCaseDeliveryTagsFetcher, + DataDelivery.FASTQ_ANALYSIS: SampleAndCaseDeliveryTagsFetcher, + DataDelivery.BAM: BamDeliveryTagsFetcher, + } + return service_map[delivery_type]() + + def _get_file_fetcher( + self, delivery_type: DataDelivery, delivery_destination: DeliveryDestination + ) -> FetchDeliveryFilesService: + """Get the file fetcher based on the delivery type. + args: + delivery_type: The type of delivery to perform. + delivery_destination: The destination of the delivery defaults to customer. See DeliveryDestination enum for explanation. + + """ + service_map: dict[DataDelivery, Type[FetchDeliveryFilesService]] = { + DataDelivery.FASTQ: RawDataDeliveryFileFetcher, + DataDelivery.ANALYSIS_FILES: AnalysisDeliveryFileFetcher, + DataDelivery.FASTQ_ANALYSIS: RawDataAndAnalysisDeliveryFileFetcher, + DataDelivery.BAM: RawDataDeliveryFileFetcher, + } + file_tag_fetcher: FetchDeliveryFileTagsService = self._get_file_tag_fetcher( + delivery_type=delivery_type, delivery_destination=delivery_destination + ) + return service_map[delivery_type]( + status_db=self.store, + hk_api=self.hk_api, + tags_fetcher=file_tag_fetcher, + ) + + def _convert_workflow(self, case: Case) -> Workflow: + """Change the workflow of a Microbial Fastq case to Microsalt to allow the concatenation of fastq files. + With the introduction of the microbial-fastq delivery type, an unsupported combination of delivery type and + workflow setup is required. This function makes sure that a raw data workflow with microbial fastq delivery + type is treated as a microsalt workflow so that the microbial-fastq sample files can be concatenated. + args: + case: The case to convert the workflow for + """ + tag: str = case.samples[0].application_version.application.tag + microbial_tags: list[str] = [ + application.tag + for application in self.store.get_active_applications_by_prep_category( + prep_category=SeqLibraryPrepCategory.MICROBIAL + ) + ] + if case.data_analysis == Workflow.RAW_DATA and tag in microbial_tags: + return Workflow.MICROSALT + return case.data_analysis + + def _get_sample_file_formatter( + self, + case: Case, + delivery_structure: DeliveryStructure = DeliveryStructure.NESTED, + ) -> SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter: + """Get the file formatter service based on the workflow. + Depending on the delivery structure the path name formatter will be different. + Args: + case: The case to deliver files for. + delivery_structure: The structure of the delivery. See DeliveryStructure enum for explanation. Defaults to nested. + """ + + converted_workflow: Workflow = self._convert_workflow(case) + if converted_workflow in [Workflow.MICROSALT]: + return SampleFileConcatenationFormatter( + file_manager=FileManager(), + path_name_formatter=self._get_path_name_formatter(delivery_structure), + concatenation_service=FastqConcatenationService(), + ) + if converted_workflow == Workflow.MUTANT: + return MutantFileFormatter( + lims_api=self.lims_api, + file_manager=FileManager(), + file_formatter=SampleFileConcatenationFormatter( + file_manager=FileManager(), + path_name_formatter=self._get_path_name_formatter(delivery_structure), + concatenation_service=FastqConcatenationService(), + ), + ) + return SampleFileFormatter( + file_manager=FileManager(), + path_name_formatter=self._get_path_name_formatter(delivery_structure), + ) + + def _get_case_file_formatter(self, delivery_structure: DeliveryStructure) -> CaseFileFormatter: + """ + Get the case file formatter based on the delivery structure. + args: + delivery_structure: The structure of the delivery. See DeliveryStructure enum for explanation. + """ + return CaseFileFormatter( + file_manager=FileManager(), + path_name_formatter=self._get_path_name_formatter(delivery_structure), + ) + + @staticmethod + def _get_path_name_formatter( + delivery_structure: DeliveryStructure, + ) -> PathNameFormatter: + """ + Get the path name formatter based on the delivery destination + args: + delivery_structure: The structure of the delivery. See DeliveryStructure enum for explanation. + """ + if delivery_structure == DeliveryStructure.FLAT: + return FlatStructurePathFormatter() + return NestedStructurePathFormatter() + + @staticmethod + def _get_file_mover( + delivery_destination: DeliveryDestination, + ) -> CustomerInboxDestinationFilesMover | BaseDestinationFilesMover: + """Get the file mover based on the delivery type. + args: + delivery_destination: The destination of the delivery. See DeliveryDestination enum for explanation. + """ + if delivery_destination in [DeliveryDestination.BASE, DeliveryDestination.FOHM]: + return BaseDestinationFilesMover(FileMover(FileManager())) + return CustomerInboxDestinationFilesMover(FileMover(FileManager())) + + def _get_file_formatter( + self, + delivery_structure: DeliveryStructure, + case: Case, + ) -> DeliveryDestinationFormatter: + """ + Get the file formatter service based on the delivery destination. + args: + delivery_structure: The structure of the delivery. See DeliveryStructure enum for explanation. + case: The case to deliver files for. + """ + sample_file_formatter: ( + SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter + ) = self._get_sample_file_formatter(case=case, delivery_structure=delivery_structure) + case_file_formatter: CaseFileFormatter = self._get_case_file_formatter( + delivery_structure=delivery_structure + ) + return BaseDeliveryFormatter( + case_file_formatter=case_file_formatter, + sample_file_formatter=sample_file_formatter, + ) + + def build_delivery_service( + self, + case: Case, + delivery_type: DataDelivery | None = None, + delivery_destination: DeliveryDestination = DeliveryDestination.CUSTOMER, + delivery_structure: DeliveryStructure = DeliveryStructure.NESTED, + ) -> DeliverFilesService: + """Build a delivery service based on a case. + args: + case: The case to deliver files for. + delivery_type: The type of data delivery to perform. See DataDelivery enum for explanation. + delivery_destination: The destination of the delivery defaults to customer. See DeliveryDestination enum for explanation. + delivery_structure: The structure of the delivery defaults to nested. See DeliveryStructure enum for explanation. + """ + delivery_type: DataDelivery = self._sanitise_delivery_type( + delivery_type if delivery_type else case.data_delivery + ) + self._validate_delivery_type(delivery_type) + file_fetcher: FetchDeliveryFilesService = self._get_file_fetcher( + delivery_type=delivery_type, delivery_destination=delivery_destination + ) + file_move_service: DestinationFilesMover = self._get_file_mover( + delivery_destination=delivery_destination + ) + file_formatter: DeliveryDestinationFormatter = self._get_file_formatter( + case=case, delivery_structure=delivery_structure + ) + return DeliverFilesService( + delivery_file_manager_service=file_fetcher, + move_file_service=file_move_service, + file_formatter_service=file_formatter, + status_db=self.store, + rsync_service=self.rsync_service, + tb_service=self.tb_service, + analysis_service=self.analysis_service, + ) diff --git a/cg/services/deliver_files/file_fetcher/abstract.py b/cg/services/deliver_files/file_fetcher/abstract.py index 95c5d78a98..65fe83e6d5 100644 --- a/cg/services/deliver_files/file_fetcher/abstract.py +++ b/cg/services/deliver_files/file_fetcher/abstract.py @@ -29,6 +29,6 @@ def __init__( self.tags_fetcher = tags_fetcher @abstractmethod - def get_files_to_deliver(self, case_id: str) -> DeliveryFiles: + def get_files_to_deliver(self, case_id: str, sample_id: str | None) -> DeliveryFiles: """Get the files to deliver.""" pass diff --git a/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py b/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py index 4eafa0210d..daceca0fb4 100644 --- a/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py +++ b/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py @@ -28,13 +28,19 @@ def __init__( self.hk_api = hk_api self.tags_fetcher = tags_fetcher - def get_files_to_deliver(self, case_id: str) -> DeliveryFiles: + def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> DeliveryFiles: + """ + Get files to deliver for a case or sample for both analysis and raw data. + args: + case_id: The case id to deliver files for + sample_id: The sample id to deliver files for + """ case: Case = self.status_db.get_case_by_internal_id(internal_id=case_id) fastq_files: DeliveryFiles = self._fetch_files( - service_class=RawDataDeliveryFileFetcher, case_id=case_id + service_class=RawDataDeliveryFileFetcher, case_id=case_id, sample_id=sample_id ) analysis_files: DeliveryFiles = self._fetch_files( - service_class=AnalysisDeliveryFileFetcher, case_id=case_id + service_class=AnalysisDeliveryFileFetcher, case_id=case_id, sample_id=sample_id ) delivery_data = DeliveryMetaData( case_id=case.internal_id, @@ -48,7 +54,15 @@ def get_files_to_deliver(self, case_id: str) -> DeliveryFiles: sample_files=analysis_files.sample_files + fastq_files.sample_files, ) - def _fetch_files(self, service_class: type, case_id: str) -> DeliveryFiles: - """Fetch files using the provided service class.""" + def _fetch_files( + self, service_class: type, case_id: str, sample_id: str | None + ) -> DeliveryFiles: + """Fetch files using the provided service class. + Wrapper to fetch files using the provided service class. This is either the RawDataDeliveryFileFetcher or the AnalysisDeliveryFileFetcher. + args: + service_class: The service class to use to fetch the files + case_id: The case id to fetch files for + sample_id: The sample id to fetch files for + """ service = service_class(self.status_db, self.hk_api, tags_fetcher=self.tags_fetcher) - return service.get_files_to_deliver(case_id) + return service.get_files_to_deliver(case_id=case_id, sample_id=sample_id) diff --git a/cg/services/deliver_files/file_fetcher/analysis_service.py b/cg/services/deliver_files/file_fetcher/analysis_service.py index ed0072cb2b..43e0b2f920 100644 --- a/cg/services/deliver_files/file_fetcher/analysis_service.py +++ b/cg/services/deliver_files/file_fetcher/analysis_service.py @@ -38,12 +38,23 @@ def __init__( self.hk_api = hk_api self.tags_fetcher = tags_fetcher - def get_files_to_deliver(self, case_id: str) -> DeliveryFiles: - """Return a list of analysis files to be delivered for a case.""" - LOG.debug(f"[FETCH SERVICE] Fetching analysis files for case: {case_id}") + def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> DeliveryFiles: + """Return a list of analysis files to be delivered for a case. + args: + case_id: The case id to deliver files for + sample_id: The sample id to deliver files for + """ + LOG.debug( + f"[FETCH SERVICE] Fetching analysis files for case: {case_id}, sample: {sample_id}" + ) case: Case = self.status_db.get_case_by_internal_id(internal_id=case_id) - analysis_case_files: list[CaseFile] = self._get_analysis_case_delivery_files(case) - analysis_sample_files: list[SampleFile] = self._get_analysis_sample_delivery_files(case) + analysis_case_files: list[CaseFile] = self._get_analysis_case_delivery_files( + case=case, sample_id=sample_id + ) + + analysis_sample_files: list[SampleFile] = self._get_analysis_sample_delivery_files( + case=case, sample_id=sample_id + ) delivery_data = DeliveryMetaData( case_id=case.internal_id, customer_internal_id=case.customer.internal_id, @@ -60,7 +71,13 @@ def get_files_to_deliver(self, case_id: str) -> DeliveryFiles: @staticmethod def _validate_delivery_has_content(delivery_files: DeliveryFiles) -> DeliveryFiles: - """Check if the delivery files has files to deliver.""" + """ + Check if the delivery files has files to deliver. + raises: + NoDeliveryFilesError if no files to deliver. + args: + delivery_files: The delivery files to check + """ if delivery_files.case_files or delivery_files.sample_files: return delivery_files LOG.info( @@ -71,9 +88,17 @@ def _validate_delivery_has_content(delivery_files: DeliveryFiles) -> DeliveryFil @handle_missing_bundle_errors def _get_sample_files_from_case_bundle( self, workflow: Workflow, sample_id: str, case_id: str - ) -> list[SampleFile]: - """Return a list of files from a case bundle with a sample id as tag.""" + ) -> list[SampleFile] | None: + """Return a list of files from a case bundle with a sample id as tag. + This is to fetch sample specific analysis files that are stored on the case level. + args: + workflow: The workflow to fetch files for + sample_id: The sample id to fetch files for + case_id: The case id to fetch files for + """ sample_tags: list[set[str]] = self.tags_fetcher.fetch_tags(workflow).sample_tags + if not sample_tags: + return [] sample_tags_with_sample_id: list[set[str]] = [tag | {sample_id} for tag in sample_tags] sample_files: list[File] = self.hk_api.get_files_from_latest_version_containing_tags( bundle_name=case_id, tags=sample_tags_with_sample_id @@ -89,9 +114,16 @@ def _get_sample_files_from_case_bundle( for sample_file in sample_files ] - def _get_analysis_sample_delivery_files(self, case: Case) -> list[SampleFile] | None: - """Return a all sample files to deliver for a case.""" - sample_ids: list[str] = case.sample_ids + def _get_analysis_sample_delivery_files( + self, case: Case, sample_id: str | None + ) -> list[SampleFile]: + """Return all sample files to deliver for a case. + Write a list of sample files to deliver for a case. + args: + case: The case to deliver files for + sample_id: The sample id to deliver files for + """ + sample_ids: list[str] = [sample_id] if sample_id else case.sample_ids delivery_files: list[SampleFile] = [] for sample_id in sample_ids: sample_files: list[SampleFile] = self._get_sample_files_from_case_bundle( @@ -101,13 +133,20 @@ def _get_analysis_sample_delivery_files(self, case: Case) -> list[SampleFile] | return delivery_files @handle_missing_bundle_errors - def _get_analysis_case_delivery_files(self, case: Case) -> list[CaseFile]: + def _get_analysis_case_delivery_files( + self, case: Case, sample_id: str | None + ) -> list[CaseFile]: """ Return a complete list of analysis case files to be delivered and ignore analysis sample - files. + files. This is to ensure that only case level analysis files are delivered. + args: + case: The case to deliver files for + sample_id: The sample id to deliver files for """ case_tags: list[set[str]] = self.tags_fetcher.fetch_tags(case.data_analysis).case_tags - sample_id_tags: list[str] = case.sample_ids + if not case_tags: + return [] + sample_id_tags: list[str] = [sample_id] if sample_id else case.sample_ids case_files: list[File] = self.hk_api.get_files_from_latest_version_containing_tags( bundle_name=case.internal_id, tags=case_tags, excluded_tags=sample_id_tags ) diff --git a/cg/services/deliver_files/file_fetcher/models.py b/cg/services/deliver_files/file_fetcher/models.py index ef38780862..f22a491d43 100644 --- a/cg/services/deliver_files/file_fetcher/models.py +++ b/cg/services/deliver_files/file_fetcher/models.py @@ -7,7 +7,7 @@ class DeliveryMetaData(BaseModel): case_id: str customer_internal_id: str ticket_id: str - customer_ticket_inbox: Path | None = None + delivery_path: Path | None = None class CaseFile(BaseModel): diff --git a/cg/services/deliver_files/file_fetcher/raw_data_service.py b/cg/services/deliver_files/file_fetcher/raw_data_service.py index bdc99cf1ca..a38dedd8d0 100644 --- a/cg/services/deliver_files/file_fetcher/raw_data_service.py +++ b/cg/services/deliver_files/file_fetcher/raw_data_service.py @@ -43,11 +43,16 @@ def __init__( self.hk_api = hk_api self.tags_fetcher = tags_fetcher - def get_files_to_deliver(self, case_id: str) -> DeliveryFiles: - """Return a list of raw data files to be delivered for a case and its samples.""" + def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> DeliveryFiles: + """ + Return a list of raw data files to be delivered for a case and its samples. + args: + case_id: The case id to deliver files for + sample_id: The sample id to deliver files for + """ LOG.debug(f"[FETCH SERVICE] Fetching raw data files for case: {case_id}") case: Case = self.status_db.get_case_by_internal_id(internal_id=case_id) - sample_ids: list[str] = case.sample_ids + sample_ids: list[str] = [sample_id] if sample_id else case.sample_ids raw_data_files: list[SampleFile] = [] for sample_id in sample_ids: raw_data_files.extend( @@ -68,7 +73,12 @@ def get_files_to_deliver(self, case_id: str) -> DeliveryFiles: @staticmethod def _validate_delivery_has_content(delivery_files: DeliveryFiles) -> DeliveryFiles: - """Check if the delivery files has files to deliver.""" + """Check if the delivery files has files to deliver. + raises: + NoDeliveryFilesError if no files to deliver. + args: + delivery_files: The delivery files to check + """ for sample_file in delivery_files.sample_files: LOG.debug( f"Found file to deliver: {sample_file.file_path} for sample: {sample_file.sample_id}" @@ -82,7 +92,12 @@ def _validate_delivery_has_content(delivery_files: DeliveryFiles) -> DeliveryFil @handle_missing_bundle_errors def _get_raw_data_files_for_sample(self, case_id: str, sample_id: str) -> list[SampleFile]: - """Get the RawData files for a sample.""" + """ + Get the RawData files for a sample. Hardcoded tags to fetch from the raw data workflow. + args: + case_id: The case id to get the raw data files for + sample_id: The sample id to get the raw data files for + """ file_tags: list[set[str]] = self.tags_fetcher.fetch_tags(Workflow.RAW_DATA).sample_tags raw_data_files: list[File] = self.hk_api.get_files_from_latest_version_containing_tags( bundle_name=sample_id, tags=file_tags diff --git a/cg/services/deliver_files/file_filter/abstract.py b/cg/services/deliver_files/file_filter/abstract.py deleted file mode 100644 index a0d846b544..0000000000 --- a/cg/services/deliver_files/file_filter/abstract.py +++ /dev/null @@ -1,10 +0,0 @@ -from abc import abstractmethod, ABC - -from cg.services.deliver_files.file_fetcher.models import DeliveryFiles - - -class FilterDeliveryFilesService(ABC): - - @abstractmethod - def filter_delivery_files(self, delivery_files: DeliveryFiles, sample_id: str) -> DeliveryFiles: - pass diff --git a/cg/services/deliver_files/file_filter/sample_service.py b/cg/services/deliver_files/file_filter/sample_service.py deleted file mode 100644 index 3f4ed9e56c..0000000000 --- a/cg/services/deliver_files/file_filter/sample_service.py +++ /dev/null @@ -1,13 +0,0 @@ -from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_filter.abstract import FilterDeliveryFilesService - - -class SampleFileFilter(FilterDeliveryFilesService): - - def filter_delivery_files(self, delivery_files: DeliveryFiles, sample_id: str) -> DeliveryFiles: - delivery_files.sample_files = [ - sample_file - for sample_file in delivery_files.sample_files - if sample_file.sample_id == sample_id - ] - return delivery_files diff --git a/cg/services/deliver_files/file_formatter/utils/__init__.py b/cg/services/deliver_files/file_formatter/destination/__init__.py similarity index 100% rename from cg/services/deliver_files/file_formatter/utils/__init__.py rename to cg/services/deliver_files/file_formatter/destination/__init__.py diff --git a/cg/services/deliver_files/file_formatter/abstract.py b/cg/services/deliver_files/file_formatter/destination/abstract.py similarity index 70% rename from cg/services/deliver_files/file_formatter/abstract.py rename to cg/services/deliver_files/file_formatter/destination/abstract.py index 31eb12f582..559f553e55 100644 --- a/cg/services/deliver_files/file_formatter/abstract.py +++ b/cg/services/deliver_files/file_formatter/destination/abstract.py @@ -1,10 +1,11 @@ from abc import abstractmethod, ABC +from pathlib import Path from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_formatter.models import FormattedFiles +from cg.services.deliver_files.file_formatter.destination.models import FormattedFiles -class DeliveryFileFormattingService(ABC): +class DeliveryDestinationFormatter(ABC): """ Abstract class that encapsulates the logic required for formatting files to deliver. """ diff --git a/cg/services/deliver_files/file_formatter/service.py b/cg/services/deliver_files/file_formatter/destination/base_service.py similarity index 56% rename from cg/services/deliver_files/file_formatter/service.py rename to cg/services/deliver_files/file_formatter/destination/base_service.py index 2265db4f2e..5b5b3493e8 100644 --- a/cg/services/deliver_files/file_formatter/service.py +++ b/cg/services/deliver_files/file_formatter/destination/base_service.py @@ -1,31 +1,42 @@ import logging -import os from pathlib import Path from cg.services.deliver_files.file_fetcher.models import CaseFile, DeliveryFiles, SampleFile -from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService -from cg.services.deliver_files.file_formatter.models import FormattedFile, FormattedFiles -from cg.services.deliver_files.file_formatter.utils.case_service import CaseFileFormatter -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( +from cg.services.deliver_files.file_formatter.destination.abstract import ( + DeliveryDestinationFormatter, +) +from cg.services.deliver_files.file_formatter.destination.models import ( + FormattedFile, + FormattedFiles, +) +from cg.services.deliver_files.file_formatter.files.case_service import CaseFileFormatter +from cg.services.deliver_files.file_formatter.files.mutant_service import ( + MutantFileFormatter, +) +from cg.services.deliver_files.file_formatter.files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.utils.sample_service import SampleFileFormatter +from cg.services.deliver_files.file_formatter.files.sample_service import ( + SampleFileFormatter, +) LOG = logging.getLogger(__name__) -class DeliveryFileFormatter(DeliveryFileFormattingService): +class BaseDeliveryFormatter(DeliveryDestinationFormatter): """ Format the files to be delivered in the generic format. - Expected structure: - /inbox/// - /inbox/// + args: + case_file_formatter: The case file formatter + sample_file_formatter: The sample file formatter. This can be a SampleFileFormatter, SampleFileConcatenationFormatter or MutantFileFormatter. """ def __init__( self, case_file_formatter: CaseFileFormatter, - sample_file_formatter: SampleFileFormatter | SampleFileConcatenationFormatter, + sample_file_formatter: ( + SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter + ), ): self.case_file_formatter = case_file_formatter self.sample_file_formatter = sample_file_formatter @@ -33,32 +44,26 @@ def __init__( def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: """Format the files to be delivered and return the formatted files in the generic format.""" LOG.debug("[FORMAT SERVICE] Formatting files for delivery") - ticket_dir_path: Path = delivery_files.delivery_data.customer_ticket_inbox - self._create_ticket_dir(ticket_dir_path) formatted_files: list[FormattedFile] = self._format_sample_and_case_files( sample_files=delivery_files.sample_files, case_files=delivery_files.case_files, - ticket_dir_path=ticket_dir_path, + delivery_path=delivery_files.delivery_data.delivery_path, ) return FormattedFiles(files=formatted_files) def _format_sample_and_case_files( - self, sample_files: list[SampleFile], case_files: list[CaseFile], ticket_dir_path: Path + self, sample_files: list[SampleFile], case_files: list[CaseFile], delivery_path: Path ) -> list[FormattedFile]: """Helper method to format both sample and case files.""" + LOG.debug(f"[FORMAT SERVICE] delivery_path: {delivery_path}") formatted_files: list[FormattedFile] = self.sample_file_formatter.format_files( moved_files=sample_files, - ticket_dir_path=ticket_dir_path, + delivery_path=delivery_path, ) if case_files: formatted_case_files: list[FormattedFile] = self.case_file_formatter.format_files( moved_files=case_files, - ticket_dir_path=ticket_dir_path, + delivery_path=delivery_path, ) formatted_files.extend(formatted_case_files) return formatted_files - - @staticmethod - def _create_ticket_dir(ticket_dir_path: Path) -> None: - """Create the ticket directory if it does not exist.""" - os.makedirs(ticket_dir_path, exist_ok=True) diff --git a/cg/services/deliver_files/file_formatter/models.py b/cg/services/deliver_files/file_formatter/destination/models.py similarity index 100% rename from cg/services/deliver_files/file_formatter/models.py rename to cg/services/deliver_files/file_formatter/destination/models.py diff --git a/cg/services/deliver_files/file_formatter/files/__init__.py b/cg/services/deliver_files/file_formatter/files/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/deliver_files/file_formatter/files/abstract.py b/cg/services/deliver_files/file_formatter/files/abstract.py new file mode 100644 index 0000000000..bb1f241b3d --- /dev/null +++ b/cg/services/deliver_files/file_formatter/files/abstract.py @@ -0,0 +1,15 @@ +from abc import abstractmethod, ABC +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import SampleFile, CaseFile +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile + + +class FileFormatter(ABC): + + @abstractmethod + def format_files( + self, moved_files: list[CaseFile | SampleFile], delivery_path: Path + ) -> list[FormattedFile]: + """Format the files to deliver.""" + pass diff --git a/cg/services/deliver_files/file_formatter/files/case_service.py b/cg/services/deliver_files/file_formatter/files/case_service.py new file mode 100644 index 0000000000..deb1a1e4b0 --- /dev/null +++ b/cg/services/deliver_files/file_formatter/files/case_service.py @@ -0,0 +1,83 @@ +import logging +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import CaseFile +from cg.services.deliver_files.file_formatter.files.abstract import FileFormatter +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, +) +from cg.services.deliver_files.utils import FileManager + +LOG = logging.getLogger(__name__) + + +class CaseFileFormatter(FileFormatter): + """ + Format the case files to deliver and return the formatted files. + args: + path_name_formatter: The path name formatter to format paths to either a flat or nested structure in the delivery destination + file_manager: The file manager + """ + + def __init__( + self, + path_name_formatter: PathNameFormatter, + file_manager: FileManager, + ): + self.path_name_formatter = path_name_formatter + self.file_manager = file_manager + + def format_files(self, moved_files: list[CaseFile], delivery_path: Path) -> list[FormattedFile]: + """Format the case files to deliver and return the formatted files. + args: + moved_files: The case files to format + delivery_path: The path to deliver the files to + """ + LOG.debug("[FORMAT SERVICE] Formatting case files") + self._create_case_name_folder( + delivery_path=delivery_path, case_name=moved_files[0].case_name + ) + return self._format_case_files(moved_files) + + def _format_case_files(self, case_files: list[CaseFile]) -> list[FormattedFile]: + """Format the case files to deliver and return the formatted files. + args: + case_files: The case files to format + """ + formatted_files: list[FormattedFile] = self._get_formatted_paths(case_files) + for formatted_file in formatted_files: + self.file_manager.rename_file( + src=formatted_file.original_path, dst=formatted_file.formatted_path + ) + return formatted_files + + def _create_case_name_folder(self, delivery_path: Path, case_name: str) -> None: + """ + Create a folder for the case in the delivery path. + The folder is only created if the provided PathStructureFormatter is a NestedStructurePathFormatter. + args: + delivery_path: The path to deliver the files to + case_name: The name of the case + """ + LOG.debug(f"[FORMAT SERVICE] Creating folder for case: {case_name}") + if isinstance(self.path_name_formatter, NestedStructurePathFormatter): + self.file_manager.create_directories(base_path=delivery_path, directories={case_name}) + + def _get_formatted_paths(self, case_files: list[CaseFile]) -> list[FormattedFile]: + """Return a list of formatted case files. + args: + case_files: The case files to format + """ + formatted_files: list[FormattedFile] = [] + for case_file in case_files: + formatted_path = self.path_name_formatter.format_file_path( + file_path=case_file.file_path, + provided_id=case_file.case_id, + provided_name=case_file.case_name, + ) + formatted_files.append( + FormattedFile(original_path=case_file.file_path, formatted_path=formatted_path) + ) + return formatted_files diff --git a/cg/services/deliver_files/file_formatter/files/concatenation_service.py b/cg/services/deliver_files/file_formatter/files/concatenation_service.py new file mode 100644 index 0000000000..b2f20b6e57 --- /dev/null +++ b/cg/services/deliver_files/file_formatter/files/concatenation_service.py @@ -0,0 +1,307 @@ +import logging +from pathlib import Path +import re + +from cg.constants.constants import ReadDirection, FileFormat, FileExtensions +from cg.services.deliver_files.file_formatter.files.abstract import FileFormatter +from cg.services.deliver_files.file_formatter.files.models import FastqFile +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter + +from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( + FastqConcatenationService, +) +from cg.services.fastq_concatenation_service.utils import generate_concatenated_fastq_delivery_path +from cg.services.deliver_files.file_fetcher.models import SampleFile +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile +from cg.services.deliver_files.file_formatter.files.sample_service import ( + FileManager, +) +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, +) +from cg.utils.files import get_all_files_in_directory_tree + +LOG = logging.getLogger(__name__) + + +class SampleFileConcatenationFormatter(FileFormatter): + """ + Format the sample files to deliver, concatenate fastq files and return the formatted files. + Used for workflows: Microsalt. + args: + file_manager: The file manager + path_name_formatter: The path name formatter to format paths to either a flat or nested structure in the delivery destination + concatenation_service: The fastq concatenation service to concatenate fastq files. + """ + + def __init__( + self, + file_manager: FileManager, + path_name_formatter: PathNameFormatter, + concatenation_service: FastqConcatenationService, + ): + self.file_manager = file_manager + self.path_name_formatter = path_name_formatter + self.concatenation_service = concatenation_service + + def format_files( + self, moved_files: list[SampleFile], delivery_path: Path + ) -> list[FormattedFile]: + """ + Format the sample files to deliver, concatenate fastq files and return the formatted files. + args: + moved_files: list[SampleFile]: List of sample files to deliver. + These are files that have been moved from housekeeper to the delivery path. + delivery_path: Path: Path to the delivery directory. + """ + LOG.debug("[FORMAT SERVICE] Formatting and concatenating sample files") + sample_names: set[str] = self._get_sample_names(sample_files=moved_files) + self._create_sample_directories(delivery_path=delivery_path, sample_names=sample_names) + formatted_files: list[FormattedFile] = self._format_sample_file_paths(moved_files) + LOG.debug( + f"[FORMAT SERVICE] number of formatted files: {len(formatted_files)}, number of moved files: {len(moved_files)}" + ) + self._rename_original_files(formatted_files) + LOG.debug(f"[FORMAT SERVICE] delivery_path: {delivery_path}") + concatenation_map: dict[Path, Path] = self._concatenate_fastq_files( + delivery_path=delivery_path, + sample_names=sample_names, + ) + self._replace_fastq_paths( + concatenation_maps=concatenation_map, + formatted_files=formatted_files, + ) + return formatted_files + + @staticmethod + def _get_sample_names(sample_files: list[SampleFile]) -> set[str]: + """Extract sample names from the sample files.""" + return {sample_file.sample_name for sample_file in sample_files} + + def _create_sample_directories(self, sample_names: set[str], delivery_path: Path) -> None: + """Create directories for each sample name only if the file name formatter is the NestedSampleFileFormatter. + args: + sample_names: set[str]: Set of sample names. + delivery_path: Path: Path to the delivery directory. + """ + if not isinstance(self.path_name_formatter, NestedStructurePathFormatter): + return + for sample_name in sample_names: + self.file_manager.create_directories(base_path=delivery_path, directories={sample_name}) + + def _format_sample_file_paths(self, sample_files: list[SampleFile]) -> list[FormattedFile]: + """ + Return a list of formatted sample files. + args: + sample_files: The sample files to format + """ + return [ + FormattedFile( + original_path=sample_file.file_path, + formatted_path=self.path_name_formatter.format_file_path( + file_path=sample_file.file_path, + provided_id=sample_file.sample_id, + provided_name=sample_file.sample_name, + ), + ) + for sample_file in sample_files + ] + + def _rename_original_files(self, formatted_files: list[FormattedFile]) -> None: + """ + Rename the formatted files. + args: + formatted_files: list[FormattedFile]: List of formatted files. + """ + LOG.debug("[FORMAT SERVICE] Renaming original files") + for formatted_file in formatted_files: + self.file_manager.rename_file( + src=formatted_file.original_path, dst=formatted_file.formatted_path + ) + + def _concatenate_fastq_files( + self, delivery_path: Path, sample_names: set[str] + ) -> dict[Path, Path]: + """Concatenate fastq files for each sample and return the forward and reverse concatenated paths. + args: + delivery_path: Path: Path to the delivery directory. + sample_names: set[str]: Set of sample names. + returns: + dict[Path, Path]: Dictionary with the original fastq file path as key and the concatenated path as value. + """ + LOG.debug(f"[FORMAT SERVICE] delivery_path: {delivery_path}") + fastq_files: list[FastqFile] = self._get_unique_sample_fastq_paths( + sample_names=sample_names, delivery_path=delivery_path + ) + grouped_fastq_files: dict[str, list[FastqFile]] = self._group_fastq_files_per_sample( + sample_names=sample_names, fastq_files=fastq_files + ) + concatenation_maps: dict[Path, Path] = {} + for sample in grouped_fastq_files.keys(): + fastq_directory: Path = grouped_fastq_files[sample][0].fastq_file_path.parent + forward_path: Path = generate_concatenated_fastq_delivery_path( + fastq_directory=fastq_directory, + sample_name=sample, + direction=ReadDirection.FORWARD, + ) + reverse_path: Path = generate_concatenated_fastq_delivery_path( + fastq_directory=fastq_directory, + sample_name=sample, + direction=ReadDirection.REVERSE, + ) + self.concatenation_service.concatenate( + sample_id=sample, + fastq_directory=fastq_directory, + forward_output_path=forward_path, + reverse_output_path=reverse_path, + remove_raw=True, + ) + concatenation_maps.update( + self._get_concatenation_map( + forward_path=forward_path, + reverse_path=reverse_path, + fastq_files=grouped_fastq_files[sample], + ) + ) + return concatenation_maps + + def _get_unique_sample_fastq_paths( + self, sample_names: set[str], delivery_path: Path + ) -> list[FastqFile]: + """ + Get a list of unique sample fastq file paths given a delivery path. + args: + sample_names: set[str]: Set of sample names. + delivery_path: Path: Path to the delivery directory + returns: + list[FastqFile]: List of FastqFile objects. + """ + sample_paths: list[FastqFile] = [] + LOG.debug( + f"[CONCATENATION SERVICE] Getting unique sample fastq file paths in {delivery_path}" + ) + list_of_files: list[Path] = get_all_files_in_directory_tree(delivery_path) + for sample_name in sample_names: + for file in list_of_files: + if sample_name in file.as_posix() and self._is_lane_fastq_file(file): + LOG.debug( + f"[CONCATENATION SERVICE] Found fastq file: {file} for sample: {sample_name}" + ) + sample_paths.append( + FastqFile( + fastq_file_path=Path(delivery_path, file), + sample_name=sample_name, + read_direction=self._determine_read_direction(file), + ) + ) + if not sample_paths: + raise FileNotFoundError( + f"Could not find any fastq files to concatenate in {delivery_path}." + ) + return sample_paths + + @staticmethod + def _get_concatenation_map( + forward_path: Path, reverse_path: Path, fastq_files: list[FastqFile] + ) -> dict[Path, Path]: + """ + Get a list of ConcatenationMap objects for a sample. + NOTE: the fastq_files must be grouped by sample name. + args: + forward_path: Path: Path to the forward concatenated file. + reverse_path: Path: Path to the reverse concatenated file. + fastq_files: list[FastqFile]: List of fastq files for a single ample. + """ + concatenation_map: dict[Path, Path] = {} + for fastq_file in fastq_files: + concatenation_map[fastq_file.fastq_file_path] = ( + forward_path if fastq_file.read_direction == ReadDirection.FORWARD else reverse_path + ) + return concatenation_map + + @staticmethod + def _determine_read_direction(fastq_path: Path) -> ReadDirection: + """Determine the read direction of a fastq file. + Assumes that the fastq file path contains 'R1' or 'R2' to determine the read direction. + args: + fastq_path: Path: Path to the fastq file. + """ + if f"R{ReadDirection.FORWARD}" in fastq_path.as_posix(): + return ReadDirection.FORWARD + return ReadDirection.REVERSE + + def _group_fastq_files_per_sample( + self, sample_names: set[str], fastq_files: list[FastqFile] + ) -> dict[str, list[FastqFile]]: + """Group fastq files per sample. + returns a dictionary with sample names as keys and a list of fastq files as values. + args: + sample_names: set[str]: Set of sample names. + fastq_files: list[FastqFile]: List of fastq files. + """ + + sample_fastq_files: dict[str, list[FastqFile]] = { + sample_name: [] for sample_name in sample_names + } + for fastq_file in fastq_files: + sample_fastq_files[fastq_file.sample_name].append(fastq_file) + self._validate_sample_fastq_file_share_same_directory(sample_fastq_files=sample_fastq_files) + return sample_fastq_files + + def _replace_fastq_paths( + self, + concatenation_maps: dict[Path, Path], + formatted_files: list[FormattedFile], + ) -> None: + """ + Replace the fastq file paths with the new concatenated fastq file paths. + Uses the concatenation map with the formatted file path as key and the concatenated path as value. + args: + concatenation_maps: list[ConcatenationMap]: List of ConcatenationMap objects. + formatted_files: list[FormattedFile]: List of formatted files. + """ + for formatted_file in formatted_files: + if self._is_lane_fastq_file(formatted_file.formatted_path): + formatted_file.formatted_path = concatenation_maps[formatted_file.formatted_path] + + @staticmethod + def _validate_sample_fastq_file_share_same_directory( + sample_fastq_files: dict[str, list[FastqFile]] + ) -> None: + """ + Assert that all fastq files for a sample share the same directory. + This is to ensure that the files are concatenated within the expected directory path. + raises: ValueError if the fastq files are not in the same directory. + args: + sample_fastq_files: dict[str, list[FastqFile]]: Dictionary of sample names and their fastq files. + """ + for sample_name in sample_fastq_files.keys(): + fastq_files: list[FastqFile] = sample_fastq_files[sample_name] + parent_dir: Path = fastq_files[0].fastq_file_path.parent + for fastq_file in fastq_files: + if fastq_file.fastq_file_path.parent != parent_dir: + raise ValueError( + f"Sample {sample_name} fastq files are not in the same directory. " + f"Cannot concatenate. It will would result in sporadic file paths." + ) + + @staticmethod + def _is_lane_fastq_file(file_path: Path) -> bool: + """Check if a fastq file is a from a lane and read direction. + Note pattern: *_L[0-9]{3}_R[1-2]_[0-9]{3}.fastq.gz + *_ is a wildcard for the flow cell id followed by sample name. + L[0-9]{3} is the lane number, i.e. L001, L002 etc. + R[1-2] is the read direction, i.e. R1 or R2. + [0-9]{3} is the trailing three digits after read direction. + args: + file_path: Path: Path to the fastq file. + """ + + pattern = f".*_L[0-9]{{3}}_R[1-2]_[0-9]{{3}}{FileExtensions.FASTQ}{FileExtensions.GZIP}" + return ( + re.fullmatch( + pattern=pattern, + string=file_path.name, + ) + is not None + ) diff --git a/cg/services/deliver_files/file_formatter/files/models.py b/cg/services/deliver_files/file_formatter/files/models.py new file mode 100644 index 0000000000..52c6db156a --- /dev/null +++ b/cg/services/deliver_files/file_formatter/files/models.py @@ -0,0 +1,12 @@ +from pathlib import Path +from pydantic import BaseModel + +from cg.constants.constants import ReadDirection + + +class FastqFile(BaseModel): + """A fastq file with a sample name, file path and read direction.""" + + sample_name: str + fastq_file_path: Path + read_direction: ReadDirection diff --git a/cg/services/deliver_files/file_formatter/files/mutant_service.py b/cg/services/deliver_files/file_formatter/files/mutant_service.py new file mode 100644 index 0000000000..910a72bd70 --- /dev/null +++ b/cg/services/deliver_files/file_formatter/files/mutant_service.py @@ -0,0 +1,147 @@ +import logging +from pathlib import Path +import re +from cg.apps.lims import LimsAPI +from cg.services.deliver_files.file_fetcher.models import SampleFile +from cg.services.deliver_files.file_formatter.files.abstract import FileFormatter +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile +from cg.services.deliver_files.file_formatter.files.concatenation_service import ( + SampleFileConcatenationFormatter, +) +from cg.services.deliver_files.file_formatter.files.sample_service import FileManager + +LOG = logging.getLogger(__name__) + + +class MutantFileFormatter(FileFormatter): + """ + Formatter for file to deliver or upload for the Mutant workflow. + Args: + lims_api: The LIMS API + file_formatter: The SampleFileConcatenationFormatter. This is used to format the files and concatenate the fastq files. + file_manager: The FileManager + + """ + + def __init__( + self, + lims_api: LimsAPI, + file_formatter: SampleFileConcatenationFormatter, + file_manager: FileManager, + ): + self.lims_api: LimsAPI = lims_api + self.file_formatter: SampleFileConcatenationFormatter = file_formatter + self.file_manager = file_manager + + def format_files( + self, moved_files: list[SampleFile], delivery_path: Path + ) -> list[FormattedFile]: + """ + Format the mutant files to deliver and return the formatted files. + args: + moved_files: The sample files to format + delivery_path: The path to deliver the files + + """ + LOG.debug("[FORMAT SERVICE] Formatting and concatenating mutant files") + formatted_files: list[FormattedFile] = self.file_formatter.format_files( + moved_files=moved_files, delivery_path=delivery_path + ) + appended_formatted_files: list[FormattedFile] = self._add_lims_metadata_to_file_name( + formatted_files=formatted_files, sample_files=moved_files + ) + unique_formatted_files: list[FormattedFile] = self._filter_unique_path_combinations( + appended_formatted_files + ) + for unique_files in unique_formatted_files: + self.file_manager.rename_file( + src=unique_files.original_path, dst=unique_files.formatted_path + ) + return unique_formatted_files + + @staticmethod + def _is_concatenated_file(file_path: Path) -> bool: + """Check if the file is a concatenated file. + Returns True if the file is a concatenated file, otherwise False. + regex pattern: *._[1,2].fastq.gz + *. is the sample id + _[1,2] is the read direction + .fastq.gz is the file extension + args: + file_path: The file path to check + """ + pattern = ".*_[1,2].fastq.gz" + return re.fullmatch(pattern, file_path.name) is not None + + def _add_lims_metadata_to_file_name( + self, formatted_files: list[FormattedFile], sample_files: list[SampleFile] + ) -> list[FormattedFile]: + """ + This functions adds the region and lab code to the file name of the formatted files. + Note: The region and lab code is fetched from LIMS using the sample id. It is required for delivery of the files. + This should only be done for concatenated fastq files. + + args: + formatted_files: The formatted files to add the metadata to + sample_files: The sample files to get the metadata from + """ + appended_formatted_files: list[FormattedFile] = [] + for formatted_file in formatted_files: + if self._is_concatenated_file(formatted_file.formatted_path): + sample_id: str = self._get_sample_id_by_original_path( + original_path=formatted_file.original_path, sample_files=sample_files + ) + lims_meta_data = self.lims_api.get_sample_region_and_lab_code(sample_id) + + new_original_path: Path = formatted_file.formatted_path + new_formatted_path = Path( + formatted_file.formatted_path.parent, + f"{lims_meta_data}{formatted_file.formatted_path.name}", + ) + appended_formatted_files.append( + FormattedFile( + original_path=new_original_path, formatted_path=new_formatted_path + ) + ) + else: + appended_formatted_files.append(formatted_file) + return appended_formatted_files + + @staticmethod + def _get_sample_id_by_original_path(original_path: Path, sample_files: list[SampleFile]) -> str: + """Get the sample id by the original path of the sample file. + args: + original_path: The original path of the sample file + sample_files: The list of sample files to search in + """ + for sample_file in sample_files: + if sample_file.file_path == original_path: + return sample_file.sample_id + raise ValueError(f"Could not find sample file with path {original_path}") + + @staticmethod + def _filter_unique_path_combinations( + formatted_files: list[FormattedFile], + ) -> list[FormattedFile]: + """ + Filter out duplicates from the formatted files list. + + note: + During fastq concatenation Sample_L1_R1 and Sample_L2_R1 files are concatenated + and moved to the same file Concat_Sample. This mean that there can be multiple entries + for the same concatenated file in the formatted_files list + coming from the SampleFileConcatenationService. + This function filters out the duplicates to avoid moving the same file multiple times + which would result in an error the second time since the files is no longer in the original path. + + args: + formatted_files: The formatted files to filter + """ + unique_combinations = set() + unique_files: list[FormattedFile] = [] + for formatted_file in formatted_files: + combination = (formatted_file.original_path, formatted_file.formatted_path) + if combination not in unique_combinations: + unique_combinations.add(combination) + unique_files.append(formatted_file) + return unique_files diff --git a/cg/services/deliver_files/file_formatter/files/sample_service.py b/cg/services/deliver_files/file_formatter/files/sample_service.py new file mode 100644 index 0000000000..276a3b2649 --- /dev/null +++ b/cg/services/deliver_files/file_formatter/files/sample_service.py @@ -0,0 +1,71 @@ +import logging +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import SampleFile +from cg.services.deliver_files.file_formatter.files.abstract import FileFormatter +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter +from cg.services.deliver_files.utils import FileManager + +LOG = logging.getLogger(__name__) + + +class SampleFileFormatter(FileFormatter): + """ + Format the sample files to deliver. + Used for all workflows except Microsalt and Mutant. + args: + file_manager: The file manager + path_name_formatter: The path name formatter to format paths to either a flat or nested structure in the delivery destination + """ + + def __init__( + self, + file_manager: FileManager, + path_name_formatter: PathNameFormatter, + ): + self.file_manager = file_manager + self.path_name_formatter = path_name_formatter + + def format_files( + self, moved_files: list[SampleFile], delivery_path: Path + ) -> list[FormattedFile]: + """ + Format the sample files to deliver and return the formatted files. + args: + moved_sample_files: The sample files to format. These are files that have been moved from housekeeper to the delivery path. + delivery_path: The path to deliver the files to + """ + LOG.debug("[FORMAT SERVICE] Formatting sample files") + sample_names: set[str] = self._get_sample_names(sample_files=moved_files) + for sample_name in sample_names: + self.file_manager.create_directories(base_path=delivery_path, directories={sample_name}) + formatted_files: list[FormattedFile] = self._format_sample_file_paths(moved_files) + for formatted_file in formatted_files: + self.file_manager.rename_file( + src=formatted_file.original_path, dst=formatted_file.formatted_path + ) + return formatted_files + + @staticmethod + def _get_sample_names(sample_files: list[SampleFile]) -> set[str]: + """Extract sample names from the sample files.""" + return {sample_file.sample_name for sample_file in sample_files} + + def _format_sample_file_paths(self, sample_files: list[SampleFile]) -> list[FormattedFile]: + """ + Return a list of formatted sample files. + args: + sample_files: The sample files to format + """ + return [ + FormattedFile( + original_path=sample_file.file_path, + formatted_path=self.path_name_formatter.format_file_path( + file_path=sample_file.file_path, + provided_id=sample_file.sample_id, + provided_name=sample_file.sample_name, + ), + ) + for sample_file in sample_files + ] diff --git a/cg/services/deliver_files/file_formatter/path_name/__init__.py b/cg/services/deliver_files/file_formatter/path_name/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/deliver_files/file_formatter/path_name/abstract.py b/cg/services/deliver_files/file_formatter/path_name/abstract.py new file mode 100644 index 0000000000..5fc0409843 --- /dev/null +++ b/cg/services/deliver_files/file_formatter/path_name/abstract.py @@ -0,0 +1,13 @@ +from abc import abstractmethod, ABC +from pathlib import Path + + +class PathNameFormatter(ABC): + """ + Abstract class that encapsulates the logic required for formatting the path name. + """ + + @abstractmethod + def format_file_path(self, file_path: Path, provided_id: str, provided_name: str) -> Path: + """Format the file path.""" + pass diff --git a/cg/services/deliver_files/file_formatter/path_name/flat_structure.py b/cg/services/deliver_files/file_formatter/path_name/flat_structure.py new file mode 100644 index 0000000000..f851a6bf7b --- /dev/null +++ b/cg/services/deliver_files/file_formatter/path_name/flat_structure.py @@ -0,0 +1,24 @@ +from pathlib import Path + +from cg.services.deliver_files.file_formatter.files.sample_service import LOG +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter + + +class FlatStructurePathFormatter(PathNameFormatter): + """ + Class to format sample file names in place. + """ + + def format_file_path(self, file_path: Path, provided_id: str, provided_name: str) -> Path: + """ + Returns formatted files with original and formatted file names: + Replaces id by name. + args: + file_path: The path to the file + provided_id: The id to replace + provided_name: The name to replace the id with + """ + LOG.debug("[FORMAT SERVICE] Formatting sample file names with flat structure.") + replaced_name = file_path.name.replace(provided_id, provided_name) + formatted_path = Path(file_path.parent, replaced_name) + return formatted_path diff --git a/cg/services/deliver_files/file_formatter/path_name/nested_structure.py b/cg/services/deliver_files/file_formatter/path_name/nested_structure.py new file mode 100644 index 0000000000..26ede0ea99 --- /dev/null +++ b/cg/services/deliver_files/file_formatter/path_name/nested_structure.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from cg.services.deliver_files.file_formatter.files.sample_service import LOG +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter + + +class NestedStructurePathFormatter(PathNameFormatter): + """ + Class to format sample file names and paths in a nested format used to deliver files to a customer inbox. + """ + + def format_file_path(self, file_path: Path, provided_id: str, provided_name: str) -> Path: + """ + Returns formatted files with original and formatted file names: + 1. Adds a folder with provided name to the path of the files. + 2. Replaces id by name. + + args: + file_path: The path to the file + provided_id: The id to replace + provided_name: The name to replace the id with + """ + LOG.debug("[FORMAT SERVICE] Formatting sample file names with nested structure.") + replaced_name = file_path.name.replace(provided_id, provided_name) + formatted_path = Path(file_path.parent, provided_name, replaced_name) + return formatted_path diff --git a/cg/services/deliver_files/file_formatter/utils/case_service.py b/cg/services/deliver_files/file_formatter/utils/case_service.py deleted file mode 100644 index ccc4f656e6..0000000000 --- a/cg/services/deliver_files/file_formatter/utils/case_service.py +++ /dev/null @@ -1,48 +0,0 @@ -import os -from pathlib import Path - -from cg.services.deliver_files.file_fetcher.models import CaseFile -from cg.services.deliver_files.file_formatter.models import FormattedFile - - -class CaseFileFormatter: - - def format_files( - self, moved_files: list[CaseFile], ticket_dir_path: Path - ) -> list[FormattedFile]: - """Format the case files to deliver and return the formatted files.""" - self._create_case_name_folder( - ticket_path=ticket_dir_path, case_name=moved_files[0].case_name - ) - return self._format_case_files(moved_files) - - def _format_case_files(self, case_files: list[CaseFile]) -> list[FormattedFile]: - formatted_files: list[FormattedFile] = self._get_formatted_files(case_files) - for formatted_file in formatted_files: - os.rename(src=formatted_file.original_path, dst=formatted_file.formatted_path) - return formatted_files - - @staticmethod - def _create_case_name_folder(ticket_path: Path, case_name: str) -> None: - case_dir_path = Path(ticket_path, case_name) - case_dir_path.mkdir(exist_ok=True) - - @staticmethod - def _get_formatted_files(case_files: list[CaseFile]) -> list[FormattedFile]: - """ - Returns formatted files: - 1. Adds a folder with case name to the path of the case files. - 2. Replaces case id by case name. - """ - formatted_files: list[FormattedFile] = [] - for case_file in case_files: - replaced_case_file_name: str = case_file.file_path.name.replace( - case_file.case_id, case_file.case_name - ) - formatted_file_path = Path( - case_file.file_path.parent, case_file.case_name, replaced_case_file_name - ) - formatted_files.append( - FormattedFile(original_path=case_file.file_path, formatted_path=formatted_file_path) - ) - return formatted_files diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py deleted file mode 100644 index c7eaea6b63..0000000000 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ /dev/null @@ -1,128 +0,0 @@ -from pathlib import Path - -from cg.constants.constants import ReadDirection, FileFormat, FileExtensions - -from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( - FastqConcatenationService, -) -from cg.services.fastq_concatenation_service.utils import generate_concatenated_fastq_delivery_path -from cg.services.deliver_files.file_fetcher.models import SampleFile -from cg.services.deliver_files.file_formatter.models import FormattedFile -from cg.services.deliver_files.file_formatter.utils.sample_service import ( - SampleFileNameFormatter, - FileManagingService, -) - - -class SampleFileConcatenationFormatter: - """ - Format the sample files to deliver, concatenate fastq files and return the formatted files. - Used for workflows: Microsalt. - """ - - def __init__( - self, - file_manager: FileManagingService, - file_formatter: SampleFileNameFormatter, - concatenation_service: FastqConcatenationService, - ): - self.file_manager = file_manager - self.file_name_formatter = file_formatter - self.concatenation_service = concatenation_service - - def format_files( - self, moved_files: list[SampleFile], ticket_dir_path: Path - ) -> list[FormattedFile]: - """Format the sample files to deliver, concatenate fastq files and return the formatted files.""" - sample_names: set[str] = self.file_name_formatter.get_sample_names(sample_files=moved_files) - for sample_name in sample_names: - self.file_manager.create_directories( - base_path=ticket_dir_path, directories={sample_name} - ) - formatted_files: list[FormattedFile] = self.file_name_formatter.format_sample_file_names( - sample_files=moved_files - ) - for formatted_file in formatted_files: - self.file_manager.rename_file( - src=formatted_file.original_path, dst=formatted_file.formatted_path - ) - forward_paths, reverse_path = self._concatenate_fastq_files(formatted_files=formatted_files) - self._replace_fastq_paths( - reverse_paths=reverse_path, - forward_paths=forward_paths, - formatted_files=formatted_files, - ) - return formatted_files - - def _concatenate_fastq_files( - self, formatted_files: list[FormattedFile] - ) -> tuple[list[Path], list[Path]]: - unique_sample_dir_paths: set[Path] = self._get_unique_sample_paths( - sample_files=formatted_files - ) - forward_paths: list[Path] = [] - reverse_paths: list[Path] = [] - for fastq_directory in unique_sample_dir_paths: - sample_name: str = fastq_directory.name - - forward_path: Path = generate_concatenated_fastq_delivery_path( - fastq_directory=fastq_directory, - sample_name=sample_name, - direction=ReadDirection.FORWARD, - ) - forward_paths.append(forward_path) - reverse_path: Path = generate_concatenated_fastq_delivery_path( - fastq_directory=fastq_directory, - sample_name=sample_name, - direction=ReadDirection.REVERSE, - ) - reverse_paths.append(reverse_path) - self.concatenation_service.concatenate( - fastq_directory=fastq_directory, - forward_output_path=forward_path, - reverse_output_path=reverse_path, - remove_raw=True, - ) - return forward_paths, reverse_paths - - @staticmethod - def _get_unique_sample_paths(sample_files: list[FormattedFile]) -> set[Path]: - sample_paths: list[Path] = [] - for sample_file in sample_files: - sample_paths.append(sample_file.formatted_path.parent) - return set(sample_paths) - - @staticmethod - def _replace_fastq_formatted_file_path( - formatted_files: list[FormattedFile], - direction: ReadDirection, - new_path: Path, - ) -> None: - """Replace the formatted file path with the new path.""" - for formatted_file in formatted_files: - if ( - formatted_file.formatted_path.parent == new_path.parent - and f"{FileFormat.FASTQ}{FileExtensions.GZIP}" in formatted_file.formatted_path.name - and f"R{direction}" in formatted_file.formatted_path.name - ): - formatted_file.formatted_path = new_path - - def _replace_fastq_paths( - self, - forward_paths: list[Path], - reverse_paths: list[Path], - formatted_files: list[FormattedFile], - ) -> None: - """Replace the fastq file paths with the new concatenated fastq file paths.""" - for forward_path in forward_paths: - self._replace_fastq_formatted_file_path( - formatted_files=formatted_files, - direction=ReadDirection.FORWARD, - new_path=forward_path, - ) - for reverse_path in reverse_paths: - self._replace_fastq_formatted_file_path( - formatted_files=formatted_files, - direction=ReadDirection.REVERSE, - new_path=reverse_path, - ) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_service.py b/cg/services/deliver_files/file_formatter/utils/sample_service.py deleted file mode 100644 index 8efc383d1c..0000000000 --- a/cg/services/deliver_files/file_formatter/utils/sample_service.py +++ /dev/null @@ -1,84 +0,0 @@ -import os -from pathlib import Path -from cg.services.deliver_files.file_fetcher.models import SampleFile -from cg.services.deliver_files.file_formatter.models import FormattedFile - - -class FileManagingService: - """ - Service to manage files. - Handles operations that create or rename files and directories. - """ - - @staticmethod - def create_directories(base_path: Path, directories: set[str]) -> None: - """Create directories for given names under the base path.""" - for directory in directories: - Path(base_path, directory).mkdir(exist_ok=True) - - @staticmethod - def rename_file(src: Path, dst: Path) -> None: - """Rename a file from src to dst.""" - os.rename(src, dst) - - -class SampleFileNameFormatter: - """ - Class to format sample file names. - """ - - @staticmethod - def get_sample_names(sample_files: list[SampleFile]) -> set[str]: - """Extract sample names from the sample files.""" - return {sample_file.sample_name for sample_file in sample_files} - - @staticmethod - def format_sample_file_names(sample_files: list[SampleFile]) -> list[FormattedFile]: - """ - Returns formatted files with original and formatted file names: - 1. Adds a folder with sample name to the path of the sample files. - 2. Replaces sample id by sample name. - """ - formatted_files = [] - for sample_file in sample_files: - replaced_name = sample_file.file_path.name.replace( - sample_file.sample_id, sample_file.sample_name - ) - formatted_path = Path( - sample_file.file_path.parent, sample_file.sample_name, replaced_name - ) - formatted_files.append( - FormattedFile(original_path=sample_file.file_path, formatted_path=formatted_path) - ) - return formatted_files - - -class SampleFileFormatter: - """ - Format the sample files to deliver. - Used for all workflows except Microsalt and Mutant. - """ - - def __init__( - self, file_manager: FileManagingService, file_name_formatter: SampleFileNameFormatter - ): - self.file_manager = file_manager - self.file_name_formatter = file_name_formatter - - def format_files( - self, moved_files: list[SampleFile], ticket_dir_path: Path - ) -> list[FormattedFile]: - """Format the sample files to deliver and return the formatted files.""" - sample_names: set[str] = self.file_name_formatter.get_sample_names(sample_files=moved_files) - for sample_name in sample_names: - self.file_manager.create_directories( - base_path=ticket_dir_path, directories={sample_name} - ) - formatted_files: list[FormattedFile] = self.file_name_formatter.format_sample_file_names( - sample_files=moved_files - ) - for formatted_file in formatted_files: - self.file_manager.rename_file( - src=formatted_file.original_path, dst=formatted_file.formatted_path - ) - return formatted_files diff --git a/cg/services/deliver_files/file_mover/abstract.py b/cg/services/deliver_files/file_mover/abstract.py new file mode 100644 index 0000000000..54bf7638a6 --- /dev/null +++ b/cg/services/deliver_files/file_mover/abstract.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import DeliveryFiles + + +class DestinationFilesMover(ABC): + @abstractmethod + def move_files(self, delivery_files: DeliveryFiles, delivery_base_path: Path) -> DeliveryFiles: + """Move files to the delivery folder.""" + pass diff --git a/cg/services/deliver_files/file_mover/base_service.py b/cg/services/deliver_files/file_mover/base_service.py new file mode 100644 index 0000000000..67ad1827b1 --- /dev/null +++ b/cg/services/deliver_files/file_mover/base_service.py @@ -0,0 +1,30 @@ +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import DeliveryFiles, SampleFile, CaseFile +from cg.services.deliver_files.file_mover.abstract import DestinationFilesMover +from cg.services.deliver_files.utils import FileMover + + +class BaseDestinationFilesMover(DestinationFilesMover): + """ + Class to move files directly to the delivery base path. + """ + + def __init__(self, file_mover: FileMover): + self.file_mover = file_mover + + def move_files(self, delivery_files: DeliveryFiles, delivery_base_path: Path) -> DeliveryFiles: + """ + Move the files directly to the delivery base path. + args: + delivery_files: DeliveryFiles: The files to move. + delivery_base_path: Path: The path to move the files to. + """ + delivery_files.delivery_data.delivery_path = delivery_base_path + delivery_files.case_files = self.file_mover.move_and_update_files( + file_models=delivery_files.case_files, target_dir=delivery_base_path + ) + delivery_files.sample_files = self.file_mover.move_and_update_files( + file_models=delivery_files.sample_files, target_dir=delivery_base_path + ) + return delivery_files diff --git a/cg/services/deliver_files/file_mover/customer_inbox_service.py b/cg/services/deliver_files/file_mover/customer_inbox_service.py new file mode 100644 index 0000000000..d613bca4bf --- /dev/null +++ b/cg/services/deliver_files/file_mover/customer_inbox_service.py @@ -0,0 +1,61 @@ +import logging +from pathlib import Path + +from cg.constants.delivery import INBOX_NAME +from cg.services.deliver_files.file_fetcher.models import ( + DeliveryFiles, + DeliveryMetaData, +) +from cg.services.deliver_files.file_mover.abstract import DestinationFilesMover +from cg.services.deliver_files.utils import FileMover + +LOG = logging.getLogger(__name__) + + +class CustomerInboxDestinationFilesMover(DestinationFilesMover): + """ + Class to move files to the customer folder. + """ + + def __init__(self, file_mover: FileMover): + self.file_mover = file_mover + + def move_files(self, delivery_files: DeliveryFiles, delivery_base_path: Path) -> DeliveryFiles: + """ + Move the files to the customer folder. + args: + delivery_files: DeliveryFiles: The files to move. + delivery_base_path: Path: The path to move the files to. + """ + inbox_ticket_dir_path: Path = self._create_ticket_inbox_dir_path( + delivery_base_path=delivery_base_path, delivery_data=delivery_files.delivery_data + ) + delivery_files.delivery_data.delivery_path = inbox_ticket_dir_path + + self.file_mover.create_directories( + base_path=delivery_base_path, + directories={str(inbox_ticket_dir_path.relative_to(delivery_base_path))}, + ) + delivery_files.case_files = self.file_mover.move_and_update_files( + file_models=delivery_files.case_files, target_dir=inbox_ticket_dir_path + ) + delivery_files.sample_files = self.file_mover.move_and_update_files( + file_models=delivery_files.sample_files, target_dir=inbox_ticket_dir_path + ) + return delivery_files + + @staticmethod + def _create_ticket_inbox_dir_path( + delivery_base_path: Path, delivery_data: DeliveryMetaData + ) -> Path: + """Generate the path to the ticket inbox directory. + args: + delivery_base_path: The base path to the delivery folder. + delivery_data: The delivery data containing the customer internal id and ticket id. + """ + return Path( + delivery_base_path, + delivery_data.customer_internal_id, + INBOX_NAME, + delivery_data.ticket_id, + ) diff --git a/cg/services/deliver_files/file_mover/service.py b/cg/services/deliver_files/file_mover/service.py deleted file mode 100644 index d02d55d6be..0000000000 --- a/cg/services/deliver_files/file_mover/service.py +++ /dev/null @@ -1,111 +0,0 @@ -import logging -from pathlib import Path - -from cg.constants.delivery import INBOX_NAME -from cg.services.deliver_files.file_fetcher.models import ( - CaseFile, - DeliveryFiles, - DeliveryMetaData, - SampleFile, -) -from cg.utils.files import link_or_overwrite_file - -LOG = logging.getLogger(__name__) - - -class DeliveryFilesMover: - """ - Class that encapsulates the logic required for moving files to the customer folder. - """ - - def move_files(self, delivery_files: DeliveryFiles, delivery_base_path: Path) -> DeliveryFiles: - """Move the files to the customer folder.""" - inbox_ticket_dir_path: Path = self._create_ticket_inbox_dir_path( - delivery_base_path=delivery_base_path, delivery_data=delivery_files.delivery_data - ) - delivery_files.delivery_data.customer_ticket_inbox = inbox_ticket_dir_path - self._create_ticket_inbox_folder(inbox_ticket_dir_path) - self._create_hard_links_for_delivery_files( - delivery_files=delivery_files, inbox_dir_path=inbox_ticket_dir_path - ) - return self._replace_file_paths_with_inbox_dir_paths( - delivery_files=delivery_files, inbox_dir_path=inbox_ticket_dir_path - ) - - @staticmethod - def _create_ticket_inbox_folder( - inbox_ticket_dir_path: Path, - ) -> Path: - """Create a ticket inbox folder in the customer folder, overwrites if already present.""" - LOG.debug(f"[MOVE SERVICE] Creating ticket inbox folder: {inbox_ticket_dir_path}") - inbox_ticket_dir_path.mkdir(parents=True, exist_ok=True) - return inbox_ticket_dir_path - - @staticmethod - def _create_ticket_inbox_dir_path( - delivery_base_path: Path, delivery_data: DeliveryMetaData - ) -> Path: - """Create the path to the ticket inbox folder.""" - return Path( - delivery_base_path, - delivery_data.customer_internal_id, - INBOX_NAME, - delivery_data.ticket_id, - ) - - @staticmethod - def _create_inbox_file_path(file_path: Path, inbox_dir_path: Path) -> Path: - """Create the path to the inbox file.""" - return Path(inbox_dir_path, file_path.name) - - def _create_hard_link_file_paths( - self, file_models: list[SampleFile | CaseFile], inbox_dir_path: Path - ) -> None: - """Create hard links to the sample files in the customer folder.""" - for file_model in file_models: - inbox_file_path: Path = self._create_inbox_file_path( - file_path=file_model.file_path, inbox_dir_path=inbox_dir_path - ) - link_or_overwrite_file(src=file_model.file_path, dst=inbox_file_path) - - def _create_hard_links_for_delivery_files( - self, delivery_files: DeliveryFiles, inbox_dir_path: Path - ) -> None: - """Create hard links to the files in the customer folder.""" - LOG.debug(f"[MOVE SERVICE] Creating hard links for delivery files in: {inbox_dir_path}") - if delivery_files.case_files: - self._create_hard_link_file_paths( - file_models=delivery_files.case_files, inbox_dir_path=inbox_dir_path - ) - self._create_hard_link_file_paths( - file_models=delivery_files.sample_files, inbox_dir_path=inbox_dir_path - ) - - def _replace_file_path_with_inbox_dir_path( - self, file_models: list[SampleFile | CaseFile], inbox_dir_path: Path - ) -> list[SampleFile | CaseFile]: - """Replace the file path with the inbox path.""" - for file_model in file_models: - inbox_file_path: Path = self._create_inbox_file_path( - file_path=file_model.file_path, inbox_dir_path=inbox_dir_path - ) - file_model.file_path = inbox_file_path - return file_models - - def _replace_file_paths_with_inbox_dir_paths( - self, - delivery_files: DeliveryFiles, - inbox_dir_path: Path, - ) -> DeliveryFiles: - """ - Replace to original file paths in the delivery files with the customer inbox file paths. - """ - LOG.debug(f"[MOVE SERVICE] Replacing file paths with inbox dir path: {inbox_dir_path}") - if delivery_files.case_files: - delivery_files.case_files = self._replace_file_path_with_inbox_dir_path( - file_models=delivery_files.case_files, inbox_dir_path=inbox_dir_path - ) - delivery_files.sample_files = self._replace_file_path_with_inbox_dir_path( - file_models=delivery_files.sample_files, inbox_dir_path=inbox_dir_path - ) - return delivery_files diff --git a/cg/services/deliver_files/tag_fetcher/bam_service.py b/cg/services/deliver_files/tag_fetcher/bam_service.py index 571cf265df..6abf3a2830 100644 --- a/cg/services/deliver_files/tag_fetcher/bam_service.py +++ b/cg/services/deliver_files/tag_fetcher/bam_service.py @@ -14,7 +14,12 @@ class BamDeliveryTagsFetcher(FetchDeliveryFileTagsService): @handle_tag_errors def fetch_tags(self, workflow: Workflow) -> DeliveryFileTags: - """Fetch the tags for the bam files to deliver.""" + """ + Fetch the tags for the bam files to deliver. + Hardcoded to only return the BAM tag. + args: + workflow: The workflow to fetch tags for + """ self._validate_workflow(workflow=workflow) return DeliveryFileTags( case_tags=None, diff --git a/cg/services/deliver_files/tag_fetcher/fohm_upload_service.py b/cg/services/deliver_files/tag_fetcher/fohm_upload_service.py new file mode 100644 index 0000000000..e0a42e393f --- /dev/null +++ b/cg/services/deliver_files/tag_fetcher/fohm_upload_service.py @@ -0,0 +1,47 @@ +from cg.constants import Workflow, SequencingFileTag +from cg.services.deliver_files.tag_fetcher.abstract import FetchDeliveryFileTagsService +from cg.services.deliver_files.tag_fetcher.error_handling import handle_tag_errors +from cg.services.deliver_files.tag_fetcher.models import DeliveryFileTags + + +class FOHMUploadTagsFetcher(FetchDeliveryFileTagsService): + """Class to fetch tags for FOHM upload files.""" + + @handle_tag_errors + def fetch_tags(self, workflow: Workflow) -> DeliveryFileTags: + """ + Fetch the tags for the bam files to deliver. + NOTE: workflow raw data here is required to fit the implementation of the raw data delivery file fetcher. + if workflow is MUTANT, return tags for consensus-sample and vcf-report to fetch sample files from the case bundle. + if workflow is RAW_DATA, return tags for fastq to fetch fastq files from the sample bundle. + Required since some of the sample specific files are stored on the case bundle, but also fastq files. + Not separating these would cause fetching of case bundle fastq files if present. + + Hardcoded to only return the tags for the files to deliver. + args: + workflow: Workflow: The workflow to fetch tags + """ + self._validate_workflow(workflow=workflow) + return ( + DeliveryFileTags( + case_tags=None, + sample_tags=[{"consensus-sample"}, {"vcf-report"}], + ) + if workflow == Workflow.MUTANT + else DeliveryFileTags( + case_tags=None, + sample_tags=[{SequencingFileTag.FASTQ}], + ) + ) + + @staticmethod + def _validate_workflow(workflow: Workflow): + """ + Validate the workflow. + NOTE: workflow raw data here is required to fit the implementation of the raw data delivery file fetcher. + args: + workflow: Workflow: The workflow to validate. + """ + if workflow not in [Workflow.MUTANT, Workflow.RAW_DATA]: + raise ValueError(f"Workflow {workflow} is not supported for FOHM upload file delivery.") + return workflow diff --git a/cg/services/deliver_files/tag_fetcher/models.py b/cg/services/deliver_files/tag_fetcher/models.py index 580e95c663..791b7b767e 100644 --- a/cg/services/deliver_files/tag_fetcher/models.py +++ b/cg/services/deliver_files/tag_fetcher/models.py @@ -2,5 +2,11 @@ class DeliveryFileTags(BaseModel): + """ + Model to hold the tags for the files to deliver. + case_tags: The tags for the case files to deliver + sample_tags: The tags for the sample files to deliver + """ + case_tags: list[set[str]] | None sample_tags: list[set[str]] diff --git a/cg/services/deliver_files/tag_fetcher/sample_and_case_service.py b/cg/services/deliver_files/tag_fetcher/sample_and_case_service.py index 14bc032266..fe822b9b2b 100644 --- a/cg/services/deliver_files/tag_fetcher/sample_and_case_service.py +++ b/cg/services/deliver_files/tag_fetcher/sample_and_case_service.py @@ -13,7 +13,10 @@ class SampleAndCaseDeliveryTagsFetcher(FetchDeliveryFileTagsService): @handle_tag_errors def fetch_tags(self, workflow: Workflow) -> DeliveryFileTags: - """Get the case tags for the files that need to be delivered for a workflow.""" + """Get the case tags for the files that need to be delivered for a workflow. + args: + workflow: The workflow to fetch tags for + """ self._validate_workflow(workflow) return DeliveryFileTags( case_tags=PIPELINE_ANALYSIS_TAG_MAP[workflow]["case_tags"], diff --git a/cg/services/deliver_files/utils.py b/cg/services/deliver_files/utils.py new file mode 100644 index 0000000000..69452ef988 --- /dev/null +++ b/cg/services/deliver_files/utils.py @@ -0,0 +1,123 @@ +import logging +import os +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import SampleFile, CaseFile + +LOG = logging.getLogger(__name__) + + +class FileManager: + """ + Service to manage files. + Handles operations that create or rename files and directories. + """ + + @staticmethod + def create_directories(base_path: Path, directories: set[str]) -> None: + """Create directories for given names under the base path. + args: + base_path: The base path to create the directories under. + directories: The directories to create within the given base path. Can be a list of one. + """ + + for directory in directories: + LOG.debug(f"[FileManager] Creating directory or file: {base_path}/{directory}") + Path(base_path, directory).mkdir(parents=True, exist_ok=True) + + @staticmethod + def rename_file(src: Path, dst: Path) -> None: + """ + Rename a file from src to dst. + raise ValueError if src does not exist. + args: + src: The source file path. + dst: The destination file path. + """ + if not src or not dst: + raise ValueError("Source and destination paths cannot be None.") + LOG.debug(f"[FileManager] Renaming file: {src} -> {dst}") + if not src.exists(): + raise FileNotFoundError(f"Source file {src} does not exist.") + os.rename(src=src, dst=dst) + + @staticmethod + def create_hard_link(src: Path, dst: Path) -> None: + """ + Create a hard link from src to dst. + args: + src: The source file path. + dst: The destination file path. + """ + LOG.debug(f"[FileManager] Creating hard link: {src} -> {dst}") + os.link(src=src, dst=dst) + + +class FileMover: + """ + Service class to move files. + Requires a file management service to perform file operations. + """ + + def __init__(self, file_manager): + """ + args: + file_manager: Service for file operations (e.g., create directories, move files). + """ + self.file_management_service = file_manager + + def create_directories(self, base_path: Path, directories: set[str]) -> None: + """Create required directories. + args: + base_path: The base path to create the directories under. + directories: The directories to create. + """ + self.file_management_service.create_directories(base_path, directories) + + def move_files_to_directory(self, file_models: list, target_dir: Path) -> None: + """Move files to the target directory. + args: + file_models: The file models that contain the files to move. + target_dir: The directory to move the files to. + """ + for file_model in file_models: + target_path = Path(target_dir, file_model.file_path.name) + self._move_or_link_file(src=file_model.file_path, dst=target_path) + + @staticmethod + def update_file_paths( + file_models: list[CaseFile | SampleFile], target_dir: Path + ) -> list[CaseFile | SampleFile]: + """Update file paths to point to the target directory. + args: + file_models: The file models to update. + target_dir: The target directory to point the file paths to. + """ + for file_model in file_models: + file_model.file_path = Path(target_dir, file_model.file_path.name) + return file_models + + def move_and_update_files( + self, file_models: list[CaseFile | SampleFile], target_dir: Path + ) -> list[CaseFile | SampleFile]: + """Move files to the target directory and update the file paths. + args: + file_models: The file models that contain the files to move. + target_dir: The directory to move the files to. + """ + if file_models: + self.move_files_to_directory(file_models=file_models, target_dir=target_dir) + return self.update_file_paths(file_models=file_models, target_dir=target_dir) + return file_models + + def _move_or_link_file(self, src: Path, dst: Path) -> None: + """Move or create a hard link for a file. + args: + src: The source file path + dst: The destination file path + """ + LOG.debug(f"[FileMover] Moving file: {src} -> {dst}") + if dst.exists(): + LOG.debug(f"Overwriting existing file: {dst}") + dst.unlink() + self.file_management_service.create_hard_link(src=src, dst=dst) diff --git a/cg/services/fastq_concatenation_service/fastq_concatenation_service.py b/cg/services/fastq_concatenation_service/fastq_concatenation_service.py index c36673cee6..4aaec3cf02 100644 --- a/cg/services/fastq_concatenation_service/fastq_concatenation_service.py +++ b/cg/services/fastq_concatenation_service/fastq_concatenation_service.py @@ -1,28 +1,48 @@ import logging from pathlib import Path +from cg.constants.constants import ReadDirection from cg.services.fastq_concatenation_service.utils import ( - concatenate_forward_reads, - concatenate_reverse_reads, remove_raw_fastqs, + concatenate_fastq_reads_for_direction, ) LOG = logging.getLogger(__name__) class FastqConcatenationService: + """Fastq file concatenation service.""" + + @staticmethod def concatenate( - self, + sample_id: str, fastq_directory: Path, forward_output_path: Path, reverse_output_path: Path, remove_raw: bool = False, ): - temp_forward: Path | None = concatenate_forward_reads(fastq_directory) - temp_reverse: Path | None = concatenate_reverse_reads(fastq_directory) + """Concatenate fastq files for a given sample in a directory and write the concatenated files to the output path. + + Args: + sample_id: The identifier to identify the samples by it should be a unique identifier in the file name. + fastq_directory: The directory containing the fastq files. + forward_output_path: The path where the concatenated forward reads will be written. + reverse_output_path: The path where the concatenated reverse reads will be written. + remove_raw: If True, remove the raw fastq files after concatenation. + """ + LOG.debug( + f"[Concatenation Service] Concatenating fastq files for {sample_id} in {fastq_directory}" + ) + temp_forward: Path | None = concatenate_fastq_reads_for_direction( + directory=fastq_directory, sample_id=sample_id, direction=ReadDirection.FORWARD + ) + temp_reverse: Path | None = concatenate_fastq_reads_for_direction( + directory=fastq_directory, sample_id=sample_id, direction=ReadDirection.REVERSE + ) if remove_raw: remove_raw_fastqs( + sample_id=sample_id, fastq_directory=fastq_directory, forward_file=temp_forward, reverse_file=temp_reverse, diff --git a/cg/services/fastq_concatenation_service/utils.py b/cg/services/fastq_concatenation_service/utils.py index b3196cd7cf..bfeb12c39e 100644 --- a/cg/services/fastq_concatenation_service/utils.py +++ b/cg/services/fastq_concatenation_service/utils.py @@ -8,8 +8,12 @@ from cg.constants import FileExtensions -def concatenate_forward_reads(directory: Path) -> Path | None: - fastqs: list[Path] = get_forward_read_fastqs(directory) +def concatenate_fastq_reads_for_direction( + directory: Path, sample_id: str, direction: ReadDirection +) -> Path | None: + fastqs: list[Path] = get_fastqs_by_direction( + fastq_directory=directory, direction=direction, sample_id=sample_id + ) if not fastqs: return output_file: Path = get_new_unique_file(directory) @@ -18,31 +22,19 @@ def concatenate_forward_reads(directory: Path) -> Path | None: return output_file -def concatenate_reverse_reads(directory: Path) -> Path | None: - fastqs: list[Path] = get_reverse_read_fastqs(directory) - if not fastqs: - return - file: Path = get_new_unique_file(directory) - concatenate(input_files=fastqs, output_file=file) - validate_concatenation(input_files=fastqs, output_file=file) - return file - - def get_new_unique_file(directory: Path) -> Path: unique_id = uuid.uuid4() return Path(directory, f"{unique_id}{FileExtensions.FASTQ}{FileExtensions.GZIP}") -def get_forward_read_fastqs(fastq_directory: Path) -> list[Path]: - return get_fastqs_by_direction(fastq_directory=fastq_directory, direction=ReadDirection.FORWARD) - - -def get_reverse_read_fastqs(fastq_directory: Path) -> list[Path]: - return get_fastqs_by_direction(fastq_directory=fastq_directory, direction=ReadDirection.REVERSE) - - -def get_fastqs_by_direction(fastq_directory: Path, direction: int) -> list[Path]: - pattern = f".+_R{direction}_[0-9]+{FileExtensions.FASTQ}{FileExtensions.GZIP}" +def get_fastqs_by_direction(fastq_directory: Path, direction: int, sample_id: str) -> list[Path]: + """Get fastq files by direction and sample id in a given directory. + args: + fastq_directory: Path: The directory containing the fastq files. + direction: int: The direction of the reads. + sample_id: str: The identifier to identify the samples by it should be a unique identifier in the file name. + """ + pattern = f".*{sample_id}.*_R{direction}_[0-9]+{FileExtensions.FASTQ}{FileExtensions.GZIP}" fastqs: list[Path] = [] for file in fastq_directory.iterdir(): if re.match(pattern, file.name): @@ -72,17 +64,30 @@ def sort_files_by_name(files: list[Path]) -> list[Path]: return sorted(files, key=lambda file: file.name) -def file_can_be_removed(file: Path, forward_file: Path, reverse_file: Path) -> bool: +def file_can_be_removed(file: Path, forward_file: Path, reverse_file: Path, sample_id: str) -> bool: + """ + Check if a file can be removed. + args: + file: Path: The file to check. + forward_file: Path: The forward file. + reverse_file: Path: The reverse file. + sample_id: str: The identifier to identify the samples by it should be a unique identifier in the file name. + """ return ( f"{FileFormat.FASTQ}{FileExtensions.GZIP}" in file.name + and sample_id in file.name and file != forward_file and file != reverse_file ) -def remove_raw_fastqs(fastq_directory: Path, forward_file: Path, reverse_file: Path) -> None: +def remove_raw_fastqs( + fastq_directory: Path, forward_file: Path, reverse_file: Path, sample_id: str +) -> None: for file in fastq_directory.iterdir(): - if file_can_be_removed(file=file, forward_file=forward_file, reverse_file=reverse_file): + if file_can_be_removed( + file=file, forward_file=forward_file, reverse_file=reverse_file, sample_id=sample_id + ): file.unlink() @@ -92,23 +97,3 @@ def generate_concatenated_fastq_delivery_path( return Path( fastq_directory, f"{sample_name}_{direction}{FileExtensions.FASTQ}{FileExtensions.GZIP}" ) - - -def generate_forward_concatenated_fastq_delivery_path( - fastq_directory: Path, sample_name: str -) -> Path: - return generate_concatenated_fastq_delivery_path( - fastq_directory=fastq_directory, - sample_name=sample_name, - direction=ReadDirection.FORWARD, - ) - - -def generate_reverse_concatenated_fastq_delivery_path( - fastq_directory: Path, sample_name: str -) -> Path: - return generate_concatenated_fastq_delivery_path( - fastq_directory=fastq_directory, - sample_name=sample_name, - direction=ReadDirection.REVERSE, - ) diff --git a/tests/conftest.py b/tests/conftest.py index 68903ee995..79b389e3dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4054,7 +4054,6 @@ def store_with_case_and_sample_with_reads( customer_id=case.customer_id, ticket_id=case.latest_ticket, order_date=case.ordered_at, - workflow=case.data_analysis, ) case.orders.append(order) for sample_internal_id in [downsample_sample_internal_id_1, downsample_sample_internal_id_2]: diff --git a/tests/fixture_plugins/delivery_fixtures/bundle_fixtures.py b/tests/fixture_plugins/delivery_fixtures/bundle_fixtures.py index 4196daa0ba..489a3f5a99 100644 --- a/tests/fixture_plugins/delivery_fixtures/bundle_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/bundle_fixtures.py @@ -100,3 +100,59 @@ def hk_delivery_case_bundle( }, ] return case_hk_bundle + + +@pytest.fixture +def hk_delivery_case_bundle_fohm_upload( + case_hk_bundle_no_files: dict[str, Any], + case_id: str, + sample_id: str, + another_sample_id: str, + delivery_report_file: Path, + delivery_case_fastq_file: Path, + delivery_another_case_fastq_file: Path, + delivery_consensus_sample_file: Path, + delivery_another_consensus_sample_file: Path, + delivery_vcf_report_file: Path, + delivery_another_vcf_report_file: Path, +) -> dict: + case_hk_bundle: dict[str, Any] = deepcopy(case_hk_bundle_no_files) + case_hk_bundle["name"] = case_id + case_hk_bundle["files"] = [ + { + "archive": False, + "path": delivery_report_file.as_posix(), + "tags": [HK_DELIVERY_REPORT_TAG, case_id], + }, + { + "archive": False, + "path": delivery_case_fastq_file.as_posix(), + "tags": ["fastq", sample_id], + }, + { + "archive": False, + "path": delivery_another_case_fastq_file.as_posix(), + "tags": ["fastq", another_sample_id], + }, + { + "archive": False, + "path": delivery_consensus_sample_file.as_posix(), + "tags": ["consensus-sample", sample_id], + }, + { + "archive": False, + "path": delivery_another_consensus_sample_file.as_posix(), + "tags": ["consensus-sample", another_sample_id], + }, + { + "archive": False, + "path": delivery_vcf_report_file.as_posix(), + "tags": ["vcf-report", sample_id], + }, + { + "archive": False, + "path": delivery_another_vcf_report_file.as_posix(), + "tags": ["vcf-report", another_sample_id], + }, + ] + return case_hk_bundle diff --git a/tests/fixture_plugins/delivery_fixtures/context_fixtures.py b/tests/fixture_plugins/delivery_fixtures/context_fixtures.py index 3c217896c0..95a8e576be 100644 --- a/tests/fixture_plugins/delivery_fixtures/context_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/context_fixtures.py @@ -7,7 +7,7 @@ from cg.apps.housekeeper.hk import HousekeeperAPI from cg.constants import DataDelivery, Workflow from cg.models.cg_config import CGConfig -from cg.store.models import Case, Sample +from cg.store.models import Case, Sample, Order from cg.store.store import Store from tests.store_helpers import StoreHelpers @@ -21,16 +21,33 @@ def delivery_housekeeper_api( hk_delivery_case_bundle: dict[str, Any], ) -> HousekeeperAPI: """Delivery API Housekeeper context.""" + hk_api: HousekeeperAPI = real_housekeeper_api + helpers.ensure_hk_bundle(store=hk_api, bundle_data=hk_delivery_sample_bundle, include=True) helpers.ensure_hk_bundle( - store=real_housekeeper_api, bundle_data=hk_delivery_sample_bundle, include=True + store=hk_api, bundle_data=hk_delivery_another_sample_bundle, include=True ) + helpers.ensure_hk_bundle(store=hk_api, bundle_data=hk_delivery_case_bundle, include=True) + return hk_api + + +@pytest.fixture +def delivery_fohm_upload_housekeeper_api( + real_housekeeper_api: HousekeeperAPI, + helpers: StoreHelpers, + hk_delivery_case_bundle_fohm_upload: dict[str, Any], + hk_delivery_sample_bundle: dict[str, Any], + hk_delivery_another_sample_bundle: dict[str, Any], +) -> HousekeeperAPI: + """Delivery API Housekeeper context.""" + hk_api: HousekeeperAPI = real_housekeeper_api + helpers.ensure_hk_bundle(store=hk_api, bundle_data=hk_delivery_sample_bundle, include=True) helpers.ensure_hk_bundle( - store=real_housekeeper_api, bundle_data=hk_delivery_another_sample_bundle, include=True + store=hk_api, bundle_data=hk_delivery_another_sample_bundle, include=True ) helpers.ensure_hk_bundle( - store=real_housekeeper_api, bundle_data=hk_delivery_case_bundle, include=True + store=hk_api, bundle_data=hk_delivery_case_bundle_fohm_upload, include=True ) - return real_housekeeper_api + return hk_api @pytest.fixture @@ -112,7 +129,8 @@ def delivery_store_microsalt( data_analysis=Workflow.MICROSALT, data_delivery=DataDelivery.FASTQ_QC, ) - + order: Order = helpers.add_order(store=status_db, customer_id=case.customer.id, ticket_id=1) + case.orders.append(order) # MicroSALT samples sample: Sample = helpers.add_sample( store=status_db, @@ -143,6 +161,68 @@ def delivery_store_microsalt( return status_db +@pytest.fixture +def delivery_store_mutant( + cg_context: CGConfig, + helpers: StoreHelpers, + case_id: str, + no_sample_case_id: str, + case_name: str, + sample_id: str, + another_sample_id: str, + sample_id_not_enough_reads: str, + total_sequenced_reads_pass: int, + total_sequenced_reads_not_pass: int, + sample_name: str, + another_sample_name: str, + microbial_application_tag: str, +) -> Store: + """Delivery API StatusDB context for Mutant.""" + status_db: Store = cg_context.status_db + + # Error case without samples + helpers.add_case(store=status_db, internal_id=no_sample_case_id, name=no_sample_case_id) + + # Mutant case with fastq-analysis as data delivery + case: Case = helpers.add_case( + store=status_db, + internal_id=case_id, + name=case_name, + data_analysis=Workflow.MUTANT, + data_delivery=DataDelivery.FASTQ_ANALYSIS, + ) + order: Order = helpers.add_order(store=status_db, customer_id=case.customer.id, ticket_id=1) + case.orders.append(order) + # Mutant samples + sample: Sample = helpers.add_sample( + store=status_db, + application_tag=microbial_application_tag, + internal_id=sample_id, + name=sample_name, + reads=total_sequenced_reads_pass, + ) + + another_sample: Sample = helpers.add_sample( + store=status_db, + application_tag=microbial_application_tag, + internal_id=another_sample_id, + name=another_sample_name, + reads=total_sequenced_reads_pass, + ) + + sample_not_enough_reads: Sample = helpers.add_sample( + store=status_db, + application_tag=microbial_application_tag, + internal_id=sample_id_not_enough_reads, + reads=total_sequenced_reads_not_pass, + ) + + for sample_mutant in [sample, another_sample, sample_not_enough_reads]: + helpers.add_relationship(store=status_db, case=case, sample=sample_mutant) + + return status_db + + @pytest.fixture def delivery_context_balsamic( cg_context: CGConfig, diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py index a252c4791c..ea2b2e8337 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py @@ -1,3 +1,4 @@ +import os from pathlib import Path import pytest @@ -15,6 +16,7 @@ DeliveryMetaData, SampleFile, ) +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile from cg.store.models import Case from cg.store.store import Store @@ -89,6 +91,78 @@ def expected_bam_delivery_files( return DeliveryFiles(delivery_data=delivery_meta_data, case_files=[], sample_files=sample_files) +@pytest.fixture() +def expected_bam_delivery_files_single_sample( + expected_bam_delivery_files: DeliveryFiles, sample_id: str +) -> DeliveryFiles: + expected_bam_delivery_files.sample_files = [ + sample_file + for sample_file in expected_bam_delivery_files.sample_files + if sample_file.sample_id == sample_id + ] + return expected_bam_delivery_files + + +@pytest.fixture +def expected_fohm_delivery_files( + delivery_fohm_upload_housekeeper_api: HousekeeperAPI, + case_id: str, + case_name: str, + sample_id: str, + sample_name: str, + another_sample_id: str, + another_sample_name: str, + delivery_store_mutant: Store, +) -> DeliveryFiles: + """Return the expected fastq delivery files.""" + sample_info: list[tuple[str, str]] = [ + (sample_id, sample_name), + (another_sample_id, another_sample_name), + ] + sample_files: list[SampleFile] = [ + SampleFile( + case_id=case_id, + sample_id=sample[0], + sample_name=sample[1], + file_path=delivery_fohm_upload_housekeeper_api.get_files_from_latest_version( + bundle_name=sample[0], tags=[SequencingFileTag.FASTQ] + )[0].full_path, + ) + for sample in sample_info + ] + case_sample_info: list[tuple[str, str, str]] = [ + (sample_id, sample_name, "consensus-sample"), + (sample_id, sample_name, "vcf-report"), + (another_sample_id, another_sample_name, "consensus-sample"), + (another_sample_id, another_sample_name, "vcf-report"), + ] + case_sample_files: list[SampleFile] = [ + SampleFile( + case_id=case_id, + sample_id=sample[0], + sample_name=sample[1], + file_path=delivery_fohm_upload_housekeeper_api.get_files_from_latest_version_containing_tags( + bundle_name=case_id, tags=[{sample[2], sample[0]}] + )[ + 0 + ].full_path, + ) + for sample in case_sample_info + ] + + case: Case = delivery_store_mutant.get_case_by_internal_id(case_id) + delivery_meta_data = DeliveryMetaData( + case_id=case.internal_id, + customer_internal_id=case.customer.internal_id, + ticket_id=case.latest_ticket, + ) + return DeliveryFiles( + delivery_data=delivery_meta_data, + case_files=[], + sample_files=case_sample_files + sample_files, + ) + + @pytest.fixture def expected_analysis_delivery_files( delivery_housekeeper_api: HousekeeperAPI, @@ -152,7 +226,7 @@ def expected_moved_fastq_delivery_files( INBOX_NAME, delivery_files.delivery_data.ticket_id, ) - delivery_files.delivery_data.customer_ticket_inbox = inbox_dir_path + delivery_files.delivery_data.delivery_path = inbox_dir_path new_sample_files: list[SampleFile] = swap_file_paths_with_inbox_paths( file_models=delivery_files.sample_files, inbox_dir_path=inbox_dir_path ) @@ -175,7 +249,7 @@ def expected_moved_analysis_delivery_files( INBOX_NAME, delivery_files.delivery_data.ticket_id, ) - delivery_files.delivery_data.customer_ticket_inbox = inbox_dir_path + delivery_files.delivery_data.delivery_path = inbox_dir_path new_case_files: list[CaseFile] = swap_file_paths_with_inbox_paths( file_models=delivery_files.case_files, inbox_dir_path=inbox_dir_path ) @@ -214,23 +288,65 @@ def expected_moved_analysis_case_delivery_files( @pytest.fixture -def fastq_concatenation_sample_files(tmp_path: Path) -> list[SampleFile]: - some_ticket: str = "some_ticket" - fastq_paths: list[Path] = [ - Path(tmp_path, some_ticket, "S1_1_R1_1.fastq.gz"), - Path(tmp_path, some_ticket, "S1_2_R1_1.fastq.gz"), - Path(tmp_path, some_ticket, "S1_1_R2_1.fastq.gz"), - Path(tmp_path, some_ticket, "S1_2_R2_1.fastq.gz"), - ] - return [ - SampleFile( - sample_id="S1", - case_id="Case1", - sample_name="Sample1", - file_path=fastq_path, +def fastq_concatenation_sample_files( + tmp_path: Path, expected_fastq_delivery_files: DeliveryFiles +) -> list[SampleFile]: + """ + Return a list of sample files that are to be concatenated. + """ + inbox = Path( + expected_fastq_delivery_files.delivery_data.customer_internal_id, + INBOX_NAME, + expected_fastq_delivery_files.delivery_data.ticket_id, + ) + sample_data = [("Sample_ID1", "Sample_Name1"), ("Sample_ID2", "Sample_Name2")] + sample_files = [] + for sample_id, sample_name in sample_data: + fastq_paths: list[Path] = [ + Path(tmp_path, inbox, f"{sample_id}_L001_R1_001.fastq.gz"), + Path(tmp_path, inbox, f"{sample_id}_L002_R1_001.fastq.gz"), + Path(tmp_path, inbox, f"{sample_id}_L001_R2_001.fastq.gz"), + Path(tmp_path, inbox, f"{sample_id}_L002_R2_001.fastq.gz"), + ] + + sample_files.extend( + [ + SampleFile( + sample_id=sample_id, + case_id="Case1", + sample_name=sample_name, + file_path=fastq_path, + ) + for fastq_path in fastq_paths + ] ) - for fastq_path in fastq_paths - ] + return sample_files + + +@pytest.fixture +def fastq_concatenation_sample_files_flat(tmp_path: Path) -> list[SampleFile]: + sample_data = [("Sample_ID2", "Sample_Name2"), ("Sample_ID1", "Sample_Name1")] + sample_files = [] + for sample_id, sample_name in sample_data: + fastq_paths: list[Path] = [ + Path(tmp_path, f"{sample_id}_L001_R1_001.fastq.gz"), + Path(tmp_path, f"{sample_id}_L002_R1_001.fastq.gz"), + Path(tmp_path, f"{sample_id}_L001_R2_001.fastq.gz"), + Path(tmp_path, f"{sample_id}_L002_R2_001.fastq.gz"), + ] + + sample_files.extend( + [ + SampleFile( + sample_id=sample_id, + case_id="Case1", + sample_name=sample_name, + file_path=fastq_path, + ) + for fastq_path in fastq_paths + ] + ) + return sample_files def swap_file_paths_with_inbox_paths( @@ -243,3 +359,57 @@ def swap_file_paths_with_inbox_paths( new_file_model.file_path = Path(inbox_dir_path, file_model.file_path.name) new_file_models.append(new_file_model) return new_file_models + + +@pytest.fixture +def lims_naming_metadata() -> str: + return "01_SE100_" + + +@pytest.fixture +def expected_mutant_formatted_files( + expected_concatenated_fastq_formatted_files, lims_naming_metadata +) -> list[FormattedFile]: + unique_combinations = [] + for formatted_file in expected_concatenated_fastq_formatted_files: + formatted_file.original_path = formatted_file.formatted_path + formatted_file.formatted_path = Path( + formatted_file.formatted_path.parent, + f"{lims_naming_metadata}{formatted_file.formatted_path.name}", + ) + if formatted_file not in unique_combinations: + unique_combinations.append(formatted_file) + return unique_combinations + + +@pytest.fixture +def mutant_moved_files(fastq_concatenation_sample_files) -> list[SampleFile]: + return fastq_concatenation_sample_files + + +@pytest.fixture +def expected_upload_files(expected_analysis_delivery_files: DeliveryFiles): + return expected_analysis_delivery_files + + +@pytest.fixture +def expected_moved_upload_files(expected_analysis_delivery_files: DeliveryFiles, tmp_path: Path): + delivery_files = DeliveryFiles(**expected_analysis_delivery_files.model_dump()) + delivery_files.delivery_data.delivery_path = tmp_path + new_case_files: list[CaseFile] = swap_file_paths_with_inbox_paths( + file_models=delivery_files.case_files, inbox_dir_path=tmp_path + ) + new_sample_files: list[SampleFile] = swap_file_paths_with_inbox_paths( + file_models=delivery_files.sample_files, inbox_dir_path=tmp_path + ) + + return DeliveryFiles( + delivery_data=delivery_files.delivery_data, + case_files=new_case_files, + sample_files=new_sample_files, + ) + + +@pytest.fixture +def empty_sample() -> None: + return None diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py index 89b614b584..2e90df0f80 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py @@ -2,8 +2,8 @@ import pytest -from cg.services.deliver_files.file_fetcher.models import DeliveryFiles, SampleFile -from cg.services.deliver_files.file_formatter.models import FormattedFile +from cg.services.deliver_files.file_fetcher.models import DeliveryFiles +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile @pytest.fixture @@ -42,6 +42,22 @@ def expected_formatted_analysis_sample_files( return formatted_files +@pytest.fixture +def expected_flat_formatted_analysis_sample_files( + expected_moved_analysis_delivery_files: DeliveryFiles, +) -> list[FormattedFile]: + formatted_files: list[FormattedFile] = [] + for sample_file in expected_moved_analysis_delivery_files.sample_files: + replaced_sample_file_name: str = sample_file.file_path.name.replace( + sample_file.sample_id, sample_file.sample_name + ) + formatted_file_path = Path(sample_file.file_path.parent, replaced_sample_file_name) + formatted_files.append( + FormattedFile(original_path=sample_file.file_path, formatted_path=formatted_file_path) + ) + return formatted_files + + @pytest.fixture def expected_formatted_fastq_sample_files( expected_moved_fastq_delivery_files: DeliveryFiles, @@ -69,10 +85,10 @@ def expected_concatenated_fastq_formatted_files( replaced_sample_file_name: str = sample_file.file_path.name.replace( sample_file.sample_id, sample_file.sample_name ) - replaced_sample_file_name = replaced_sample_file_name.replace("1_R1_1", "1") - replaced_sample_file_name = replaced_sample_file_name.replace("2_R1_1", "1") - replaced_sample_file_name = replaced_sample_file_name.replace("1_R2_1", "2") - replaced_sample_file_name = replaced_sample_file_name.replace("2_R2_1", "2") + replaced_sample_file_name = replaced_sample_file_name.replace("L001_R1_001", "1") + replaced_sample_file_name = replaced_sample_file_name.replace("L002_R1_001", "1") + replaced_sample_file_name = replaced_sample_file_name.replace("L001_R2_001", "2") + replaced_sample_file_name = replaced_sample_file_name.replace("L002_R2_001", "2") formatted_file_path = Path( sample_file.file_path.parent, sample_file.sample_name, replaced_sample_file_name ) @@ -82,6 +98,26 @@ def expected_concatenated_fastq_formatted_files( return formatted_files +@pytest.fixture +def expected_concatenated_fastq_flat_formatted_files( + fastq_concatenation_sample_files_flat, +) -> list[FormattedFile]: + formatted_files: list[FormattedFile] = [] + for sample_file in fastq_concatenation_sample_files_flat: + replaced_sample_file_name: str = sample_file.file_path.name.replace( + sample_file.sample_id, sample_file.sample_name + ) + replaced_sample_file_name = replaced_sample_file_name.replace("L001_R1_001", "1") + replaced_sample_file_name = replaced_sample_file_name.replace("L002_R1_001", "1") + replaced_sample_file_name = replaced_sample_file_name.replace("L001_R2_001", "2") + replaced_sample_file_name = replaced_sample_file_name.replace("L002_R2_001", "2") + formatted_file_path = Path(sample_file.file_path.parent, replaced_sample_file_name) + formatted_files.append( + FormattedFile(original_path=sample_file.file_path, formatted_path=formatted_file_path) + ) + return formatted_files + + @pytest.fixture def empty_case_files() -> list: return [] diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py index 5d81346d36..60d898ed81 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py @@ -1,12 +1,14 @@ import pytest from cg.apps.housekeeper.hk import HousekeeperAPI +from cg.services.deliver_files.file_fetcher.analysis_raw_data_service import ( + RawDataAndAnalysisDeliveryFileFetcher, +) +from cg.services.deliver_files.file_formatter.destination.base_service import BaseDeliveryFormatter from cg.services.deliver_files.tag_fetcher.bam_service import ( BamDeliveryTagsFetcher, ) -from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( - FastqConcatenationService, -) +from cg.services.deliver_files.tag_fetcher.fohm_upload_service import FOHMUploadTagsFetcher from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( SampleAndCaseDeliveryTagsFetcher, ) @@ -16,19 +18,15 @@ from cg.services.deliver_files.file_fetcher.raw_data_service import ( RawDataDeliveryFileFetcher, ) -from cg.services.deliver_files.file_formatter.service import ( - DeliveryFileFormatter, -) -from cg.services.deliver_files.file_formatter.utils.case_service import ( +from cg.services.deliver_files.file_formatter.files.case_service import ( CaseFileFormatter, ) -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( - SampleFileConcatenationFormatter, -) -from cg.services.deliver_files.file_formatter.utils.sample_service import ( +from cg.services.deliver_files.file_formatter.files.sample_service import ( SampleFileFormatter, - FileManagingService, - SampleFileNameFormatter, + FileManager, +) +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, ) from cg.store.store import Store @@ -89,6 +87,20 @@ def bam_data_delivery_service_no_housekeeper_bundle( ) +@pytest.fixture +def fohm_data_delivery_service( + delivery_fohm_upload_housekeeper_api: HousekeeperAPI, + delivery_store_mutant: Store, +) -> RawDataAndAnalysisDeliveryFileFetcher: + """Fixture to get an instance of FetchFastqDeliveryFilesService.""" + tag_service = FOHMUploadTagsFetcher() + return RawDataAndAnalysisDeliveryFileFetcher( + hk_api=delivery_fohm_upload_housekeeper_api, + status_db=delivery_store_mutant, + tags_fetcher=tag_service, + ) + + @pytest.fixture def analysis_delivery_service( delivery_housekeeper_api: HousekeeperAPI, @@ -118,11 +130,14 @@ def analysis_delivery_service_no_housekeeper_bundle( @pytest.fixture -def generic_delivery_file_formatter() -> DeliveryFileFormatter: +def generic_delivery_file_formatter() -> BaseDeliveryFormatter: """Fixture to get an instance of GenericDeliveryFileFormatter.""" - return DeliveryFileFormatter( + return BaseDeliveryFormatter( sample_file_formatter=SampleFileFormatter( - file_manager=FileManagingService(), file_name_formatter=SampleFileNameFormatter() + file_manager=FileManager(), path_name_formatter=NestedStructurePathFormatter() + ), + case_file_formatter=CaseFileFormatter( + file_manager=FileManager(), + path_name_formatter=NestedStructurePathFormatter(), ), - case_file_formatter=CaseFileFormatter(), ) diff --git a/tests/fixture_plugins/delivery_fixtures/path_fixtures.py b/tests/fixture_plugins/delivery_fixtures/path_fixtures.py index 06b77d6959..22d682b014 100644 --- a/tests/fixture_plugins/delivery_fixtures/path_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/path_fixtures.py @@ -9,7 +9,18 @@ @pytest.fixture def delivery_fastq_file(tmp_path: Path, sample_id: str) -> Path: - file = Path(tmp_path, f"{sample_id}_R1_001{FileExtensions.FASTQ_GZ}") + file = Path(tmp_path, f"{sample_id}_L001_R1_001{FileExtensions.FASTQ_GZ}") + file.touch() + return file + + +@pytest.fixture +def delivery_case_fastq_file(tmp_path: Path, sample_id: str) -> Path: + """ + This represents a fastq file stored on a case bundle. Mutant stored file like this in the past. + This fixture servers the purpose to make sure these files are not fetched during delivery. + """ + file = Path(tmp_path, f"{sample_id}_concat_{FileExtensions.FASTQ_GZ}") file.touch() return file @@ -23,7 +34,18 @@ def delivery_bam_file(tmp_path: Path, sample_id: str) -> Path: @pytest.fixture def delivery_another_fastq_file(tmp_path: Path, another_sample_id: str) -> Path: - file = Path(tmp_path, f"{another_sample_id}_R1_001{FileExtensions.FASTQ_GZ}") + file = Path(tmp_path, f"{another_sample_id}L001_R1_001{FileExtensions.FASTQ_GZ}") + file.touch() + return file + + +@pytest.fixture +def delivery_another_case_fastq_file(tmp_path: Path, another_sample_id: str) -> Path: + """ + This represents a fastq file stored on a case bundle. Mutant stored file like this in the past. + This fixture servers the purpose to make sure these files are not fetched during delivery. + """ + file = Path(tmp_path, f"{another_sample_id}_concat_{FileExtensions.FASTQ_GZ}") file.touch() return file @@ -73,3 +95,31 @@ def delivery_another_cram_file(tmp_path: Path, another_sample_id: str) -> Path: @pytest.fixture def delivery_ticket_dir_path(tmp_path: Path, ticket_id: str) -> Path: return Path(tmp_path, ticket_id) + + +@pytest.fixture +def delivery_consensus_sample_file(tmp_path: Path, sample_id: str) -> Path: + file = Path(tmp_path, f"{sample_id}_consensus_sample{FileExtensions.VCF}") + file.touch() + return file + + +@pytest.fixture +def delivery_another_consensus_sample_file(tmp_path: Path, another_sample_id: str) -> Path: + file = Path(tmp_path, f"{another_sample_id}_consensus_sample{FileExtensions.VCF}") + file.touch() + return file + + +@pytest.fixture +def delivery_vcf_report_file(tmp_path: Path, sample_id: str) -> Path: + file = Path(tmp_path, f"{sample_id}_vcf_report{FileExtensions.VCF}") + file.touch() + return file + + +@pytest.fixture +def delivery_another_vcf_report_file(tmp_path: Path, another_sample_id: str) -> Path: + file = Path(tmp_path, f"{another_sample_id}_vcf_report{FileExtensions.VCF}") + file.touch() + return file diff --git a/tests/fixture_plugins/fohm/fohm_fixtures.py b/tests/fixture_plugins/fohm/fohm_fixtures.py index 17531570e3..eef17e9f4c 100644 --- a/tests/fixture_plugins/fohm/fohm_fixtures.py +++ b/tests/fixture_plugins/fohm/fohm_fixtures.py @@ -109,7 +109,7 @@ def fohm_upload_api( cg_context: CGConfig, mocker: MockFixture, helpers: StoreHelpers ) -> FOHMUploadAPI: """FOHM upload API fixture.""" - fohm_upload_api = FOHMUploadAPI(cg_context) + fohm_upload_api = FOHMUploadAPI(config=cg_context) # Mock getting Sample object from StatusDB mocker.patch.object( diff --git a/tests/fixtures/orderforms/2184.10.sarscov2.xlsx b/tests/fixtures/orderforms/2184.10.sarscov2.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..80b49b58a300717f8f17d2494e6223bd4e7f14e8 GIT binary patch literal 214456 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≈gdPflw5#4;UUT{6PfB` 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/orderforms/2184.9.sarscov2.xlsx b/tests/fixtures/orderforms/2184.9.sarscov2.xlsx deleted file mode 100644 index 6f4de53e5dfe9f1961c6cfff9aeeaa1cb5e3801d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 223184 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⪼6fu3WAvOjZ;cto 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 DRkY!q diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/fastq_file_service/conftest.py b/tests/services/fastq_file_service/conftest.py index 4f5b20a92f..06860373a7 100644 --- a/tests/services/fastq_file_service/conftest.py +++ b/tests/services/fastq_file_service/conftest.py @@ -11,29 +11,67 @@ def fastq_file_service(): return FastqConcatenationService() -def create_fastqs_directory(number_forward_reads, number_reverse_reads, tmp_path): +def create_fastqs_directory(tmp_path: Path): fastq_dir = Path(tmp_path, "fastqs") fastq_dir.mkdir() + return fastq_dir + + +def create_fastq_files( + fastq_dir: Path, number_forward_reads: int, number_reverse_reads: int, sample_id: str +): for i in range(number_forward_reads): - file = Path(fastq_dir, f"sample_R1_{i}.fastq.gz") - file.write_text(f"forward read {i}") + file = Path(fastq_dir, f"{sample_id}_R1_{i}.fastq.gz") + file.write_text(f"{sample_id} forward read {i}") for i in range(number_reverse_reads): - file = Path(fastq_dir, f"sample_R2_{i}.fastq.gz") - file.write_text(f"reverse read {i}") + file = Path(fastq_dir, f"{sample_id}_R2_{i}.fastq.gz") + file.write_text(f"{sample_id} reverse read {i}") + + +@pytest.fixture +def fastqs_dir(tmp_path: Path, sample_id: str) -> Path: + fastq_dir: Path = create_fastqs_directory(tmp_path=tmp_path) + create_fastq_files( + fastq_dir=fastq_dir, number_forward_reads=3, number_reverse_reads=3, sample_id=sample_id + ) return fastq_dir @pytest.fixture -def fastqs_dir(tmp_path) -> Path: - return create_fastqs_directory( - number_forward_reads=3, number_reverse_reads=3, tmp_path=tmp_path +def fastq_dir_existing_concatenated_files(tmp_path: Path, sample_id: str) -> Path: + fastq_dir: Path = create_fastqs_directory(tmp_path=tmp_path) + create_fastq_files( + fastq_dir=fastq_dir, number_forward_reads=3, number_reverse_reads=3, sample_id=sample_id ) + forward_output_path = Path(fastq_dir, "forward.fastq.gz") + reverse_output_path = Path(fastq_dir, "reverse.fastq.gz") + forward_output_path.write_text("Existing concatenated forward reads") + reverse_output_path.write_text("Existing concatenated reverse reads") + return fastq_dir @pytest.fixture -def fastqs_forward(tmp_path) -> Path: +def fastqs_forward(tmp_path: Path, sample_id: str) -> Path: """Return a directory with only forward reads.""" - return create_fastqs_directory( - number_forward_reads=3, number_reverse_reads=0, tmp_path=tmp_path + fastq_dir: Path = create_fastqs_directory(tmp_path=tmp_path) + create_fastq_files( + fastq_dir=fastq_dir, number_forward_reads=3, number_reverse_reads=0, sample_id=sample_id ) + return fastq_dir + + +@pytest.fixture +def fastqs_multiple_samples(tmp_path: Path, sample_id: str, another_sample_id: str) -> Path: + """Return a directory with fastq files for multiple samples.""" + fastq_dir: Path = create_fastqs_directory(tmp_path=tmp_path) + create_fastq_files( + fastq_dir=fastq_dir, number_forward_reads=3, number_reverse_reads=3, sample_id=sample_id + ) + create_fastq_files( + fastq_dir=fastq_dir, + number_forward_reads=3, + number_reverse_reads=3, + sample_id=another_sample_id, + ) + return fastq_dir diff --git a/tests/services/fastq_file_service/test_fastq_file_service.py b/tests/services/fastq_file_service/test_fastq_file_service.py index a4dc9e25d1..546438c6d9 100644 --- a/tests/services/fastq_file_service/test_fastq_file_service.py +++ b/tests/services/fastq_file_service/test_fastq_file_service.py @@ -9,7 +9,9 @@ from cg.services.fastq_concatenation_service.utils import generate_concatenated_fastq_delivery_path -def test_empty_directory(fastq_file_service: FastqConcatenationService, tmp_path): +def test_empty_directory( + fastq_file_service: FastqConcatenationService, tmp_path: Path, sample_id: str +): # GIVEN an empty directory # GIVEN output files @@ -18,6 +20,7 @@ def test_empty_directory(fastq_file_service: FastqConcatenationService, tmp_path # WHEN concatenating the reads fastq_file_service.concatenate( + sample_id=sample_id, fastq_directory=tmp_path, forward_output_path=forward_output_path, reverse_output_path=reverse_output_path, @@ -28,7 +31,9 @@ def test_empty_directory(fastq_file_service: FastqConcatenationService, tmp_path assert not reverse_output_path.exists() -def test_concatenate(fastq_file_service: FastqConcatenationService, fastqs_dir: Path): +def test_concatenate( + fastq_file_service: FastqConcatenationService, fastqs_dir: Path, sample_id: str +): # GIVEN a directory with forward and reverse reads # GIVEN output files for the concatenated reads @@ -37,6 +42,7 @@ def test_concatenate(fastq_file_service: FastqConcatenationService, fastqs_dir: # WHEN concatenating the reads fastq_file_service.concatenate( + sample_id=sample_id, fastq_directory=fastqs_dir, forward_output_path=forward_output_path, reverse_output_path=reverse_output_path, @@ -57,19 +63,25 @@ def test_concatenate(fastq_file_service: FastqConcatenationService, fastqs_dir: def test_concatenate_when_output_exists( - fastq_file_service: FastqConcatenationService, fastqs_dir: Path + fastq_file_service: FastqConcatenationService, + fastq_dir_existing_concatenated_files: Path, + sample_id: str, ): + """Test that existing concatenated files are overwritten when already existing.""" # GIVEN a directory with forward and reverse reads - existing_fastq_files = list(fastqs_dir.iterdir()) - existing_forward: Path = existing_fastq_files[0] + forward_output_path = Path(fastq_dir_existing_concatenated_files, "forward.fastq.gz") + reverse_output_path = Path(fastq_dir_existing_concatenated_files, "reverse.fastq.gz") # GIVEN that the forward output file already exists - forward_output_path = existing_forward - reverse_output_path = Path(fastqs_dir, "reverse.fastq.gz") + assert forward_output_path.exists() + assert reverse_output_path.exists() + assert "Existing" in forward_output_path.read_text() + assert "Existing" in reverse_output_path.read_text() # WHEN concatenating the reads fastq_file_service.concatenate( - fastq_directory=fastqs_dir, + sample_id=sample_id, + fastq_directory=fastq_dir_existing_concatenated_files, forward_output_path=forward_output_path, reverse_output_path=reverse_output_path, remove_raw=True, @@ -82,14 +94,16 @@ def test_concatenate_when_output_exists( # THEN the concatenated forward reads only contain forward reads assert "forward" in forward_output_path.read_text() assert "reverse" not in forward_output_path.read_text() + assert "Existing" not in forward_output_path.read_text() # THEN the concatenated reverse reads only contain reverse reads assert "reverse" in reverse_output_path.read_text() assert "forward" not in reverse_output_path.read_text() + assert "Existing" not in reverse_output_path.read_text() def test_concatenate_missing_reverse( - fastq_file_service: FastqConcatenationService, fastqs_forward: Path, tmp_path + fastq_file_service: FastqConcatenationService, fastqs_forward: Path, tmp_path, sample_id: str ): # GIVEN a directory with forward reads only @@ -99,6 +113,7 @@ def test_concatenate_missing_reverse( # WHEN concatenating the reads fastq_file_service.concatenate( + sample_id=sample_id, fastq_directory=fastqs_forward, forward_output_path=forward_output_path, reverse_output_path=reverse_output_path, @@ -111,6 +126,48 @@ def test_concatenate_missing_reverse( assert not reverse_output_path.exists() +def test_concatenate_fastqs_multiple_samples_in_dir( + fastqs_multiple_samples: Path, + fastq_file_service: FastqConcatenationService, + sample_id: str, + another_sample_id: str, + tmp_path: Path, +): + # GIVEN a fastq directory with fastq files for multiple samples that should be concatenated + samples: list[str] = [sample_id, another_sample_id] + + # GIVEN output files for the concatenated reads + for fastq_sample in samples: + forward_output_path = Path(tmp_path, f"{fastq_sample}_forward.fastq.gz") + reverse_output_path = Path(tmp_path, f"{fastq_sample}_reverse.fastq.gz") + + # WHEN concatenating the reads + fastq_file_service.concatenate( + sample_id=fastq_sample, + fastq_directory=fastqs_multiple_samples, + forward_output_path=forward_output_path, + reverse_output_path=reverse_output_path, + remove_raw=True, + ) + + not_current_sample: str = another_sample_id if fastq_sample == sample_id else sample_id + # THEN the output files should exist + assert forward_output_path.exists() + assert reverse_output_path.exists() + + # THEN the concatenated forward reads only contain forward reads + assert "forward" in forward_output_path.read_text() + assert "reverse" not in forward_output_path.read_text() + assert fastq_sample in forward_output_path.read_text() + assert not_current_sample not in forward_output_path.read_text() + + # THEN the concatenated reverse reads only contain reverse reads + assert "reverse" in reverse_output_path.read_text() + assert "forward" not in reverse_output_path.read_text() + assert fastq_sample in reverse_output_path.read_text() + assert not_current_sample not in reverse_output_path.read_text() + + @pytest.mark.parametrize( "fastq_directory, sample_name, direction, expected_output_path", [ diff --git a/tests/services/file_delivery/delivery_file_service/test_service.py b/tests/services/file_delivery/delivery_file_service/test_service.py index 256869e81e..7315fa4514 100644 --- a/tests/services/file_delivery/delivery_file_service/test_service.py +++ b/tests/services/file_delivery/delivery_file_service/test_service.py @@ -1,6 +1,5 @@ from unittest import mock from unittest.mock import Mock - from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) @@ -13,7 +12,6 @@ def test_file_delivery_service_no_files(empty_delivery_files: DeliveryFiles): file_delivery_service = DeliverFilesService( delivery_file_manager_service=Mock(), move_file_service=Mock(), - file_filter=Mock(), file_formatter_service=Mock(), rsync_service=Mock(), tb_service=Mock(), diff --git a/tests/services/file_delivery/delivery_file_service/test_service_builder.py b/tests/services/file_delivery/delivery_file_service/test_service_builder.py index 1a16496a0a..29fe6d17af 100644 --- a/tests/services/file_delivery/delivery_file_service/test_service_builder.py +++ b/tests/services/file_delivery/delivery_file_service/test_service_builder.py @@ -5,10 +5,11 @@ from pydantic import BaseModel from cg.constants import DataDelivery, Workflow +from cg.services.deliver_files.constants import DeliveryDestination, DeliveryStructure from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) -from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( +from cg.services.deliver_files.factory import ( DeliveryServiceFactory, ) from cg.services.deliver_files.file_fetcher.abstract import FetchDeliveryFilesService @@ -17,12 +18,29 @@ ) from cg.services.deliver_files.file_fetcher.analysis_service import AnalysisDeliveryFileFetcher from cg.services.deliver_files.file_fetcher.raw_data_service import RawDataDeliveryFileFetcher -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( +from cg.services.deliver_files.file_formatter.files.mutant_service import ( + MutantFileFormatter, +) +from cg.services.deliver_files.file_formatter.files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.utils.sample_service import SampleFileFormatter -from cg.services.deliver_files.file_mover.service import DeliveryFilesMover +from cg.services.deliver_files.file_formatter.files.sample_service import ( + SampleFileFormatter, +) +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter +from cg.services.deliver_files.file_formatter.path_name.flat_structure import ( + FlatStructurePathFormatter, +) +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, +) +from cg.services.deliver_files.file_mover.abstract import DestinationFilesMover +from cg.services.deliver_files.file_mover.base_service import BaseDestinationFilesMover +from cg.services.deliver_files.file_mover.customer_inbox_service import ( + CustomerInboxDestinationFilesMover, +) from cg.services.deliver_files.tag_fetcher.abstract import FetchDeliveryFileTagsService +from cg.services.deliver_files.tag_fetcher.fohm_upload_service import FOHMUploadTagsFetcher from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( SampleAndCaseDeliveryTagsFetcher, ) @@ -36,9 +54,14 @@ class DeliveryServiceScenario(BaseModel): delivery_type: DataDelivery expected_tag_fetcher: type[FetchDeliveryFileTagsService] expected_file_fetcher: type[FetchDeliveryFilesService] - expected_file_mover: type[DeliveryFilesMover] - expected_sample_file_formatter: type[SampleFileFormatter | SampleFileConcatenationFormatter] + expected_file_mover: type[DestinationFilesMover] + expected_sample_file_formatter: type[ + SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter + ] + expected_path_name_formatter: type[PathNameFormatter] store_name: str + delivery_destination: DeliveryDestination + delivery_structure: DeliveryStructure @pytest.mark.parametrize( @@ -50,9 +73,12 @@ class DeliveryServiceScenario(BaseModel): delivery_type=DataDelivery.FASTQ, expected_tag_fetcher=SampleAndCaseDeliveryTagsFetcher, expected_file_fetcher=RawDataDeliveryFileFetcher, - expected_file_mover=DeliveryFilesMover, + expected_file_mover=CustomerInboxDestinationFilesMover, expected_sample_file_formatter=SampleFileConcatenationFormatter, + expected_path_name_formatter=NestedStructurePathFormatter, store_name="microbial_store", + delivery_destination=DeliveryDestination.CUSTOMER, + delivery_structure=DeliveryStructure.NESTED, ), DeliveryServiceScenario( app_tag="VWGDPTR001", @@ -60,9 +86,12 @@ class DeliveryServiceScenario(BaseModel): delivery_type=DataDelivery.ANALYSIS_FILES, expected_tag_fetcher=SampleAndCaseDeliveryTagsFetcher, expected_file_fetcher=AnalysisDeliveryFileFetcher, - expected_file_mover=DeliveryFilesMover, - expected_sample_file_formatter=SampleFileFormatter, + expected_file_mover=CustomerInboxDestinationFilesMover, + expected_sample_file_formatter=MutantFileFormatter, + expected_path_name_formatter=NestedStructurePathFormatter, store_name="mutant_store", + delivery_destination=DeliveryDestination.CUSTOMER, + delivery_structure=DeliveryStructure.NESTED, ), DeliveryServiceScenario( app_tag="PANKTTR020", @@ -70,16 +99,46 @@ class DeliveryServiceScenario(BaseModel): delivery_type=DataDelivery.FASTQ_ANALYSIS, expected_tag_fetcher=SampleAndCaseDeliveryTagsFetcher, expected_file_fetcher=RawDataAndAnalysisDeliveryFileFetcher, - expected_file_mover=DeliveryFilesMover, + expected_file_mover=CustomerInboxDestinationFilesMover, expected_sample_file_formatter=SampleFileFormatter, + expected_path_name_formatter=NestedStructurePathFormatter, store_name="applications_store", + delivery_destination=DeliveryDestination.CUSTOMER, + delivery_structure=DeliveryStructure.NESTED, + ), + DeliveryServiceScenario( + app_tag="VWGDPTR001", + data_analysis=Workflow.MUTANT, + delivery_type=DataDelivery.ANALYSIS_FILES, + expected_tag_fetcher=FOHMUploadTagsFetcher, + expected_file_fetcher=AnalysisDeliveryFileFetcher, + expected_file_mover=BaseDestinationFilesMover, + expected_sample_file_formatter=MutantFileFormatter, + expected_path_name_formatter=FlatStructurePathFormatter, + store_name="mutant_store", + delivery_destination=DeliveryDestination.FOHM, + delivery_structure=DeliveryStructure.FLAT, + ), + DeliveryServiceScenario( + app_tag="VWGDPTR001", + data_analysis=Workflow.MUTANT, + delivery_type=DataDelivery.ANALYSIS_FILES, + expected_tag_fetcher=SampleAndCaseDeliveryTagsFetcher, + expected_file_fetcher=AnalysisDeliveryFileFetcher, + expected_file_mover=BaseDestinationFilesMover, + expected_sample_file_formatter=MutantFileFormatter, + expected_path_name_formatter=FlatStructurePathFormatter, + store_name="mutant_store", + delivery_destination=DeliveryDestination.BASE, + delivery_structure=DeliveryStructure.FLAT, ), ], - ids=["microbial-fastq", "SARS-COV2", "Targeted"], + ids=["microbial-fastq", "SARS-COV2", "Targeted", "FOHM Upload", "base"], ) def test_build_delivery_service(scenario: DeliveryServiceScenario, request: FixtureRequest): # GIVEN a delivery service builder with mocked store and hk_api builder = DeliveryServiceFactory( + lims_api=MagicMock(), store=request.getfixturevalue(scenario.store_name), hk_api=MagicMock(), rsync_service=MagicMock(), @@ -96,7 +155,11 @@ def test_build_delivery_service(scenario: DeliveryServiceScenario, request: Fixt ] # WHEN building a delivery service - delivery_service: DeliverFilesService = builder.build_delivery_service(case=case_mock) + delivery_service: DeliverFilesService = builder.build_delivery_service( + case=case_mock, + delivery_destination=scenario.delivery_destination, + delivery_structure=scenario.delivery_structure, + ) # THEN the correct file formatter and file fetcher services are used assert isinstance(delivery_service.file_manager.tags_fetcher, scenario.expected_tag_fetcher) @@ -106,3 +169,12 @@ def test_build_delivery_service(scenario: DeliveryServiceScenario, request: Fixt delivery_service.file_formatter.sample_file_formatter, scenario.expected_sample_file_formatter, ) + if not isinstance(delivery_service.file_formatter.sample_file_formatter, MutantFileFormatter): + assert isinstance( + delivery_service.file_formatter.sample_file_formatter.path_name_formatter, + scenario.expected_path_name_formatter, + ) + assert isinstance( + delivery_service.file_formatter.case_file_formatter.path_name_formatter, + scenario.expected_path_name_formatter, + ) diff --git a/tests/services/file_delivery/file_fetcher/test_file_fetching_service.py b/tests/services/file_delivery/file_fetcher/test_file_fetching_service.py index 50b770bcfc..2974d6aa66 100644 --- a/tests/services/file_delivery/file_fetcher/test_file_fetching_service.py +++ b/tests/services/file_delivery/file_fetcher/test_file_fetching_service.py @@ -8,16 +8,19 @@ @pytest.mark.parametrize( - "expected_delivery_files,delivery_file_service", + "expected_delivery_files,delivery_file_service,sample_id_to_fetch", [ - ("expected_fastq_delivery_files", "raw_data_delivery_service"), - ("expected_analysis_delivery_files", "analysis_delivery_service"), - ("expected_bam_delivery_files", "bam_data_delivery_service"), + ("expected_fohm_delivery_files", "fohm_data_delivery_service", "empty_sample"), + ("expected_fastq_delivery_files", "raw_data_delivery_service", "empty_sample"), + ("expected_analysis_delivery_files", "analysis_delivery_service", "empty_sample"), + ("expected_bam_delivery_files", "bam_data_delivery_service", "empty_sample"), + ("expected_bam_delivery_files_single_sample", "bam_data_delivery_service", "sample_id"), ], ) def test_get_files_to_deliver( expected_delivery_files: DeliveryFiles, delivery_file_service: FetchDeliveryFilesService, + sample_id_to_fetch: str | None, case_id: str, request, ): @@ -25,9 +28,12 @@ def test_get_files_to_deliver( # GIVEN a case id, samples that are present in Housekeeper and a delivery service delivery_file_service = request.getfixturevalue(delivery_file_service) expected_delivery_files = request.getfixturevalue(expected_delivery_files) + sample_id: str | None = request.getfixturevalue(sample_id_to_fetch) # WHEN getting the files to deliver - delivery_files: DeliveryFiles = delivery_file_service.get_files_to_deliver(case_id) + delivery_files: DeliveryFiles = delivery_file_service.get_files_to_deliver( + case_id=case_id, sample_id=sample_id + ) # THEN assert that the files to deliver are fetched assert delivery_files == expected_delivery_files diff --git a/tests/services/file_delivery/file_filter/test_sample_filter_service.py b/tests/services/file_delivery/file_filter/test_sample_filter_service.py deleted file mode 100644 index 200f43c0ad..0000000000 --- a/tests/services/file_delivery/file_filter/test_sample_filter_service.py +++ /dev/null @@ -1,22 +0,0 @@ -from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_filter.sample_service import SampleFileFilter - - -def test_filter_delivery_files(expected_fastq_delivery_files: DeliveryFiles, sample_id: str): - """Test to filter delivery files.""" - - # GIVEN a delivery files object with multiple sample ids and a filter delivery files service - filter_service = SampleFileFilter() - samples_ids: list[str] = [ - sample.sample_id for sample in expected_fastq_delivery_files.sample_files - ] - assert len(set(samples_ids)) > 1 - - # WHEN filtering the delivery files - filtered_delivery_files = filter_service.filter_delivery_files( - expected_fastq_delivery_files, sample_id - ) - - # THEN assert that the delivery files only contains the sample with the given sample id - for sample_file in filtered_delivery_files.sample_files: - assert sample_file.sample_id == sample_id diff --git a/tests/services/file_delivery/file_formatter/__init__.py b/tests/services/file_delivery/file_formatter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/file_delivery/file_formatter/destination/__init__.py b/tests/services/file_delivery/file_formatter/destination/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/file_delivery/file_formatter/test_formatting_service.py b/tests/services/file_delivery/file_formatter/destination/test_formatting_service.py similarity index 87% rename from tests/services/file_delivery/file_formatter/test_formatting_service.py rename to tests/services/file_delivery/file_formatter/destination/test_formatting_service.py index d7a012842a..218ad69c58 100644 --- a/tests/services/file_delivery/file_formatter/test_formatting_service.py +++ b/tests/services/file_delivery/file_formatter/destination/test_formatting_service.py @@ -8,12 +8,12 @@ CaseFile, DeliveryMetaData, ) -from cg.services.deliver_files.file_formatter.abstract import ( - DeliveryFileFormattingService, +from cg.services.deliver_files.file_formatter.destination.abstract import ( + DeliveryDestinationFormatter, ) import pytest -from cg.services.deliver_files.file_formatter.models import ( +from cg.services.deliver_files.file_formatter.destination.models import ( FormattedFiles, FormattedFile, ) @@ -39,7 +39,7 @@ ], ) def test_reformat_files( - formatter_service: DeliveryFileFormattingService, + formatter_service: DeliveryDestinationFormatter, formatted_case_files: list[FormattedFile], formatted_sample_files: list[FormattedFile], case_files: list[CaseFile], @@ -70,10 +70,10 @@ def test_reformat_files( expected_formatted_files = FormattedFiles(files=files) with mock.patch( - "cg.services.deliver_files.file_formatter.utils.sample_service.SampleFileFormatter.format_files", + "cg.services.deliver_files.file_formatter.files.sample_service.SampleFileFormatter.format_files", return_value=formatted_sample_files, ), mock.patch( - "cg.services.deliver_files.file_formatter.utils.case_service.CaseFileFormatter.format_files", + "cg.services.deliver_files.file_formatter.files.case_service.CaseFileFormatter.format_files", return_value=formatted_case_files, ): # WHEN reformatting the delivery files diff --git a/tests/services/file_delivery/file_formatter/files/__init__.py b/tests/services/file_delivery/file_formatter/files/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/file_delivery/file_formatter/files/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/files/test_formatter_utils.py new file mode 100644 index 0000000000..ce440e10d7 --- /dev/null +++ b/tests/services/file_delivery/file_formatter/files/test_formatter_utils.py @@ -0,0 +1,139 @@ +import os +from unittest.mock import Mock +import pytest +from pathlib import Path + +from cg.services.deliver_files.file_formatter.files.abstract import FileFormatter +from cg.services.deliver_files.file_formatter.files.mutant_service import ( + MutantFileFormatter, +) +from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( + FastqConcatenationService, +) +from cg.services.deliver_files.file_fetcher.models import ( + CaseFile, + SampleFile, +) +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile +from cg.services.deliver_files.file_formatter.files.case_service import ( + CaseFileFormatter, +) +from cg.services.deliver_files.file_formatter.files.concatenation_service import ( + SampleFileConcatenationFormatter, +) +from cg.services.deliver_files.file_formatter.files.sample_service import ( + SampleFileFormatter, + FileManager, +) +from cg.services.deliver_files.file_formatter.path_name.flat_structure import ( + FlatStructurePathFormatter, +) +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, +) + + +@pytest.mark.parametrize( + "moved_files,expected_formatted_files,file_formatter", + [ + ( + "expected_moved_analysis_case_delivery_files", + "expected_formatted_analysis_case_files", + CaseFileFormatter( + file_manager=FileManager(), path_name_formatter=NestedStructurePathFormatter() + ), + ), + ( + "expected_moved_analysis_sample_delivery_files", + "expected_formatted_analysis_sample_files", + SampleFileFormatter( + file_manager=FileManager(), path_name_formatter=NestedStructurePathFormatter() + ), + ), + ( + "fastq_concatenation_sample_files", + "expected_concatenated_fastq_formatted_files", + SampleFileConcatenationFormatter( + file_manager=FileManager(), + path_name_formatter=NestedStructurePathFormatter(), + concatenation_service=FastqConcatenationService(), + ), + ), + ( + "fastq_concatenation_sample_files_flat", + "expected_concatenated_fastq_flat_formatted_files", + SampleFileConcatenationFormatter( + file_manager=FileManager(), + path_name_formatter=FlatStructurePathFormatter(), + concatenation_service=FastqConcatenationService(), + ), + ), + ], +) +def test_file_formatters( + moved_files: list[CaseFile | SampleFile], + expected_formatted_files: list[FormattedFile], + file_formatter: FileFormatter, + request, +): + # GIVEN existing case files, a case file formatter and a ticket directory path and a customer inbox + moved_files: list[CaseFile | SampleFile] = request.getfixturevalue(moved_files) + expected_formatted_files: list[FormattedFile] = request.getfixturevalue( + expected_formatted_files + ) + delivery_path: Path = moved_files[0].file_path.parent + + os.makedirs(delivery_path, exist_ok=True) + + for moved_file in moved_files: + moved_file.file_path.touch() + + # WHEN formatting the case files + formatted_files: list[FormattedFile] = file_formatter.format_files( + moved_files=moved_files, + delivery_path=delivery_path, + ) + + # THEN the case files should be formatted + assert formatted_files == expected_formatted_files + for file in formatted_files: + assert file.formatted_path.exists() + assert not file.original_path.exists() + + +def test_mutant_file_formatter( + mutant_moved_files: list[SampleFile], + expected_mutant_formatted_files: list[FormattedFile], + lims_naming_metadata: str, +): + # GIVEN existing ticket directory path and a customer inbox + ticket_dir_path: Path = mutant_moved_files[0].file_path.parent + + os.makedirs(ticket_dir_path, exist_ok=True) + + for moved_file in mutant_moved_files: + moved_file.file_path.touch() + + lims_mock = Mock() + lims_mock.get_sample_region_and_lab_code.return_value = lims_naming_metadata + file_formatter = MutantFileFormatter( + file_manager=FileManager(), + file_formatter=SampleFileConcatenationFormatter( + file_manager=FileManager(), + path_name_formatter=NestedStructurePathFormatter(), + concatenation_service=FastqConcatenationService(), + ), + lims_api=lims_mock, + ) + + # WHEN formatting the files + formatted_files: list[FormattedFile] = file_formatter.format_files( + moved_files=mutant_moved_files, + delivery_path=ticket_dir_path, + ) + + # THEN the files should be formatted + assert formatted_files == expected_mutant_formatted_files + for file in formatted_files: + assert file.formatted_path.exists() + assert not file.original_path.exists() diff --git a/tests/services/file_delivery/file_formatter/path_name_formatters/__init__.py b/tests/services/file_delivery/file_formatter/path_name_formatters/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/file_delivery/file_formatter/path_name_formatters/test_path_name_formatters.py b/tests/services/file_delivery/file_formatter/path_name_formatters/test_path_name_formatters.py new file mode 100644 index 0000000000..c43e3aa9f7 --- /dev/null +++ b/tests/services/file_delivery/file_formatter/path_name_formatters/test_path_name_formatters.py @@ -0,0 +1,54 @@ +import pytest + +from cg.services.deliver_files.file_fetcher.models import SampleFile +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile +from cg.services.deliver_files.file_formatter.path_name.flat_structure import ( + FlatStructurePathFormatter, +) +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, +) + + +@pytest.mark.parametrize( + "sample_files,expected_formatted_files,path_name_formatter", + [ + ( + "expected_moved_analysis_sample_delivery_files", + "expected_formatted_analysis_sample_files", + NestedStructurePathFormatter(), + ), + ( + "expected_moved_analysis_sample_delivery_files", + "expected_flat_formatted_analysis_sample_files", + FlatStructurePathFormatter(), + ), + ], +) +def test_path_name_formatters( + sample_files: list[SampleFile], + expected_formatted_files: list[FormattedFile], + path_name_formatter, + request, +): + # GIVEN existing sample files and a sample file formatter + sample_files: list[SampleFile] = request.getfixturevalue(sample_files) + expected_formatted_files: list[FormattedFile] = request.getfixturevalue( + expected_formatted_files + ) + + # WHEN formatting the sample files + formatted_files: list[FormattedFile] = [ + FormattedFile( + formatted_path=path_name_formatter.format_file_path( + file_path=sample_file.file_path, + provided_name=sample_file.sample_name, + provided_id=sample_file.sample_id, + ), + original_path=sample_file.file_path, + ) + for sample_file in sample_files + ] + + # THEN the sample files should be formatted + assert formatted_files == expected_formatted_files diff --git a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py deleted file mode 100644 index 2245fb7f78..0000000000 --- a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py +++ /dev/null @@ -1,80 +0,0 @@ -import os -import pytest -from pathlib import Path - -from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( - FastqConcatenationService, -) -from cg.services.deliver_files.file_fetcher.models import ( - CaseFile, - SampleFile, -) -from cg.services.deliver_files.file_formatter.models import FormattedFile -from cg.services.deliver_files.file_formatter.utils.case_service import ( - CaseFileFormatter, -) -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( - SampleFileConcatenationFormatter, -) -from cg.services.deliver_files.file_formatter.utils.sample_service import ( - SampleFileFormatter, - FileManagingService, - SampleFileNameFormatter, -) - - -@pytest.mark.parametrize( - "moved_files,expected_formatted_files,file_formatter", - [ - ( - "expected_moved_analysis_case_delivery_files", - "expected_formatted_analysis_case_files", - CaseFileFormatter(), - ), - ( - "expected_moved_analysis_sample_delivery_files", - "expected_formatted_analysis_sample_files", - SampleFileFormatter( - file_manager=FileManagingService(), file_name_formatter=SampleFileNameFormatter() - ), - ), - ( - "fastq_concatenation_sample_files", - "expected_concatenated_fastq_formatted_files", - SampleFileConcatenationFormatter( - file_manager=FileManagingService(), - file_formatter=SampleFileNameFormatter(), - concatenation_service=FastqConcatenationService(), - ), - ), - ], -) -def test_file_formatter_utils( - moved_files: list[CaseFile | SampleFile], - expected_formatted_files: list[FormattedFile], - file_formatter: CaseFileFormatter | SampleFileFormatter | SampleFileConcatenationFormatter, - request, -): - # GIVEN existing case files, a case file formatter and a ticket directory path and a customer inbox - moved_files: list[CaseFile | SampleFile] = request.getfixturevalue(moved_files) - expected_formatted_files: list[FormattedFile] = request.getfixturevalue( - expected_formatted_files - ) - ticket_dir_path: Path = moved_files[0].file_path.parent - - os.makedirs(ticket_dir_path, exist_ok=True) - - for moved_file in moved_files: - moved_file.file_path.touch() - - # WHEN formatting the case files - formatted_files: list[FormattedFile] = file_formatter.format_files( - moved_files=moved_files, - ticket_dir_path=ticket_dir_path, - ) - - # THEN the case files should be formatted - assert formatted_files == expected_formatted_files - for file in formatted_files: - assert file.formatted_path.exists() - assert not file.original_path.exists() diff --git a/tests/services/file_delivery/file_mover/test_file_mover_service.py b/tests/services/file_delivery/file_mover/test_file_mover_service.py index 068a771835..4b8ab40c93 100644 --- a/tests/services/file_delivery/file_mover/test_file_mover_service.py +++ b/tests/services/file_delivery/file_mover/test_file_mover_service.py @@ -3,21 +3,37 @@ import pytest from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_mover.service import ( - DeliveryFilesMover, +from cg.services.deliver_files.file_mover.customer_inbox_service import ( + CustomerInboxDestinationFilesMover, ) +from cg.services.deliver_files.file_mover.base_service import BaseDestinationFilesMover +from cg.services.deliver_files.utils import FileMover, FileManager @pytest.mark.parametrize( - "expected_moved_delivery_files,delivery_files", + "expected_moved_delivery_files,delivery_files,move_files_service", [ - ("expected_moved_fastq_delivery_files", "expected_fastq_delivery_files"), - ("expected_moved_analysis_delivery_files", "expected_analysis_delivery_files"), + ( + "expected_moved_fastq_delivery_files", + "expected_fastq_delivery_files", + CustomerInboxDestinationFilesMover(FileMover(FileManager())), + ), + ( + "expected_moved_analysis_delivery_files", + "expected_analysis_delivery_files", + CustomerInboxDestinationFilesMover(FileMover(FileManager())), + ), + ( + "expected_moved_upload_files", + "expected_upload_files", + BaseDestinationFilesMover(FileMover(FileManager())), + ), ], ) def test_move_files( expected_moved_delivery_files: DeliveryFiles, delivery_files: DeliveryFiles, + move_files_service: CustomerInboxDestinationFilesMover, tmp_path, request, ): @@ -28,7 +44,6 @@ def test_move_files( delivery_files: DeliveryFiles = request.getfixturevalue(delivery_files) # WHEN moving the delivery files - move_files_service = DeliveryFilesMover() moved_delivery_files: DeliveryFiles = move_files_service.move_files( delivery_files=delivery_files, delivery_base_path=tmp_path ) diff --git a/tests/services/file_delivery/tag_fetcher/test_tag_service.py b/tests/services/file_delivery/tag_fetcher/test_tag_service.py index 6e54fdc73f..e1b541b15f 100644 --- a/tests/services/file_delivery/tag_fetcher/test_tag_service.py +++ b/tests/services/file_delivery/tag_fetcher/test_tag_service.py @@ -10,6 +10,7 @@ from cg.services.deliver_files.tag_fetcher.exc import ( FetchDeliveryFileTagsError, ) +from cg.services.deliver_files.tag_fetcher.fohm_upload_service import FOHMUploadTagsFetcher from cg.services.deliver_files.tag_fetcher.models import DeliveryFileTags from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( SampleAndCaseDeliveryTagsFetcher, @@ -64,3 +65,15 @@ def test_bam_delivery_tags_fetcher(): # THEN assert that the tags are fetched assert tags.case_tags is None assert tags.sample_tags == [{"bam"}] + + +def test_fohm_upload_tags_fetcher(): + # GIVEN a tag fetcher + test_fetcher = FOHMUploadTagsFetcher() + + # WHEN fetching the tags for the files to deliver + tags: DeliveryFileTags = test_fetcher.fetch_tags(Workflow.MUTANT) + + # THEN assert that the tags are fetched + assert tags.case_tags is None + assert tags.sample_tags == [{"consensus-sample"}, {"vcf-report"}] diff --git a/tests/store/crud/conftest.py b/tests/store/crud/conftest.py index 7cc7c61389..d884fc947d 100644 --- a/tests/store/crud/conftest.py +++ b/tests/store/crud/conftest.py @@ -501,7 +501,6 @@ def order_balsamic(helpers: StoreHelpers, store: Store) -> Order: customer_id=2, ticket_id=3, order_date=datetime.now(), - workflow=Workflow.BALSAMIC, ) order.cases.append(case) return order diff --git a/tests/store_helpers.py b/tests/store_helpers.py index 16fc12df91..19ac29ddff 100644 --- a/tests/store_helpers.py +++ b/tests/store_helpers.py @@ -515,7 +515,6 @@ def add_order( customer_id: int, ticket_id: int, order_date: datetime = datetime(year=2023, month=12, day=24), - workflow: Workflow = Workflow.MIP_DNA, ) -> Order: order = Order( customer_id=customer_id,