From 1fd2e425038f566c9a88fb4309f07442f34e593a Mon Sep 17 00:00:00 2001 From: PiTrem Date: Wed, 8 Jan 2025 15:37:11 +0100 Subject: [PATCH] feat: split cell lines (#2276) - add split cell lines functionality - add split menu item for cell lines - calculate short label based on children - notification message after copy/split - create ancestry relationship - add ancestry gem functionality to cell lines - add ancestry column to cell lines - use-case for splitting cell lines - root container if not present after creation refactor: moved message parameter to utils af51e8e..a58a21f refs: #1971 #1878 --- app/api/chemotion/cell_line_api.rb | 102 +++++++-------- .../helpers/cell_line_api_params_helpers.rb | 64 +++++++++ .../components/contextActions/CreateButton.js | 19 +++ .../contextActions/SplitElementButton.js | 6 + .../src/fetchers/CellLinesFetcher.js | 89 ++++++++----- .../src/stores/alt/actions/ElementActions.js | 25 ++++ .../src/stores/alt/stores/ElementStore.js | 11 ++ app/javascript/src/utilities/CellLineUtils.js | 105 ++++++++++----- app/models/cellline_sample.rb | 9 ++ app/usecases/cell_lines/copy.rb | 55 ++++++++ app/usecases/cell_lines/split.rb | 42 ++++++ ...1112321_add_ancestry_to_cellline_sample.rb | 6 + spec/api/chemotion/cell_line_api_spec.rb | 121 +++++++++++++++++- spec/factories/cell_line_sample.rb | 4 + spec/usecases/cell_lines/copy_spec.rb | 55 ++++++++ 15 files changed, 598 insertions(+), 115 deletions(-) create mode 100644 app/api/helpers/cell_line_api_params_helpers.rb create mode 100644 app/usecases/cell_lines/copy.rb create mode 100644 app/usecases/cell_lines/split.rb create mode 100644 db/migrate/20240531112321_add_ancestry_to_cellline_sample.rb create mode 100644 spec/usecases/cell_lines/copy_spec.rb diff --git a/app/api/chemotion/cell_line_api.rb b/app/api/chemotion/cell_line_api.rb index 47953f297d..6ec3ba8c07 100644 --- a/app/api/chemotion/cell_line_api.rb +++ b/app/api/chemotion/cell_line_api.rb @@ -5,6 +5,7 @@ class CellLineAPI < Grape::API include Grape::Kaminari helpers ParamsHelpers helpers ContainerHelpers + helpers CellLineApiParamsHelpers rescue_from ActiveRecord::RecordNotFound do error!('Ressource not found', 401) @@ -12,16 +13,13 @@ class CellLineAPI < Grape::API resource :cell_lines do desc 'return cell lines of a collection' params do - optional :collection_id, type: Integer, desc: 'Collection id' - optional :sync_collection_id, type: Integer, desc: 'SyncCollectionsUser id' - optional :filter_created_at, type: Boolean, desc: 'filter by created at or updated at' - optional :from_date, type: Integer, desc: 'created_date from in ms' - optional :to_date, type: Integer, desc: 'created_date to in ms' + use :cell_line_get_params end paginate per_page: 5, offset: 0 before do params[:per_page].to_i > 50 && (params[:per_page] = 50) end + get do scope = if params[:collection_id] begin @@ -82,29 +80,7 @@ class CellLineAPI < Grape::API desc 'Create a new Cell line sample' params do - optional :organism, type: String, desc: 'name of the donor organism of the cell' - optional :tissue, type: String, desc: 'tissue from which the cell originates' - requires :amount, type: Integer, desc: 'amount of cells' - requires :unit, type: String, desc: 'unit of cell amount' - requires :passage, type: Integer, desc: 'passage of cells' - optional :disease, type: String, desc: 'deasease of cells' - requires :material_names, type: String, desc: 'names of cell line e.g. name1;name2' - requires :collection_id, type: Integer, desc: 'Collection of the cell line sample' - optional :cell_type, type: String, desc: 'type of cells' - optional :biosafety_level, type: String, desc: 'biosafety_level of cells' - optional :growth_medium, type: String, desc: 'growth medium of cells' - optional :variant, type: String, desc: 'variant of cells' - optional :optimal_growth_temp, type: Float, desc: 'optimal_growth_temp of cells' - optional :cryo_pres_medium, type: String, desc: 'cryo preservation medium of cells' - optional :gender, type: String, desc: 'gender of donor organism' - optional :material_description, type: String, desc: 'description of cell line concept' - optional :contamination, type: String, desc: 'contamination of a cell line sample' - requires :source, type: String, desc: 'source of a cell line sample' - optional :name, type: String, desc: 'name of a cell line sample' - optional :mutation, type: String, desc: 'mutation of a cell line' - optional :description, type: String, desc: 'description of a cell line sample' - optional :short_label, type: String, desc: 'short label of a cell line sample' - requires :container, type: Hash, desc: 'root Container of element' + use :cell_line_creation_params end post do error!('401 Unauthorized', 401) unless current_user.collections.find(params[:collection_id]) @@ -116,28 +92,7 @@ class CellLineAPI < Grape::API end desc 'Update a Cell line sample' params do - requires :cell_line_sample_id, type: String, desc: 'id of the cell line to update' - optional :organism, type: String, desc: 'name of the donor organism of the cell' - optional :mutation, type: String, desc: 'mutation of a cell line' - optional :tissue, type: String, desc: 'tissue from which the cell originates' - requires :amount, type: Integer, desc: 'amount of cells' - requires :unit, type: String, desc: 'unit of amount of cells' - optional :passage, type: Integer, desc: 'passage of cells' - optional :disease, type: String, desc: 'deasease of cells' - optional :material_names, type: String, desc: 'names of cell line e.g. name1;name2' - optional :collection_id, type: Integer, desc: 'Collection of the cell line sample' - optional :cell_type, type: String, desc: 'type of cells' - optional :biosafety_level, type: String, desc: 'biosafety_level of cells' - optional :variant, type: String, desc: 'variant of cells' - optional :optimal_growth_temp, type: Float, desc: 'optimal_growth_temp of cells' - optional :cryo_pres_medium, type: String, desc: 'cryo preservation medium of cells' - optional :gender, type: String, desc: 'gender of donor organism' - optional :material_description, type: String, desc: 'description of cell line concept' - optional :contamination, type: String, desc: 'contamination of a cell line sample' - optional :source, type: String, desc: 'source of a cell line sample' - optional :name, type: String, desc: 'name of a cell line sample' - optional :description, type: String, desc: 'description of a cell line sample' - requires :container, type: Hash, desc: 'root Container of element' + use :cell_line_update_params end put do use_case = Usecases::CellLines::Update.new(params, current_user) @@ -146,12 +101,59 @@ class CellLineAPI < Grape::API return present cell_line_sample, with: Entities::CellLineSampleEntity end + desc 'Copy a cell line' + params do + requires :id, type: Integer, desc: 'id of cell line sample to copy' + requires :collection_id, type: Integer, desc: 'id of collection of copied cell line sample' + requires :container, type: Hash, desc: 'root container of element' + end + namespace :copy do + post do + cell_line_to_copy = @current_user.cellline_samples.where(id: [params[:id]]).reorder('id') + + error!('401 Unauthorized', 401) unless ElementsPolicy.new(@current_user, cell_line_to_copy).update? + + begin + use_case = Usecases::CellLines::Copy.new(cell_line_to_copy.first, @current_user, params[:collection_id]) + copied_cell_line_sample = use_case.execute! + copied_cell_line_sample.container = update_datamodel(params[:container]) + rescue StandardError => e + error!(e, 400) + end + return present copied_cell_line_sample, with: Entities::CellLineSampleEntity + end + end + + desc 'Splits a cell line' + params do + requires :id, type: Integer, desc: 'id of cell line sample to copy' + requires :collection_id, type: Integer, desc: 'id of collection of copied cell line sample' + optional :container, type: Hash, desc: 'root container of element' + end + namespace :split do + post do + cell_line_to_copy = @current_user.cellline_samples.where(id: [params[:id]]).reorder('id') + + error!('401 Unauthorized', 401) unless ElementsPolicy.new(@current_user, cell_line_to_copy).update? + + begin + use_case = Usecases::CellLines::Split.new(cell_line_to_copy.first, @current_user, params[:collection_id]) + splitted_cell_line_sample = use_case.execute! + splitted_cell_line_sample.container = update_datamodel(params[:container]) if @params.key?('container') + rescue StandardError => e + error!(e, 400) + end + return present splitted_cell_line_sample, with: Entities::CellLineSampleEntity + end + end + resource :names do desc 'Returns all accessable cell line material names and their id' get 'all' do return present CelllineMaterial.all, with: Entities::CellLineMaterialNameEntity end end + resource :material do params do requires :id, type: Integer, desc: 'id of cell line material to load' diff --git a/app/api/helpers/cell_line_api_params_helpers.rb b/app/api/helpers/cell_line_api_params_helpers.rb new file mode 100644 index 0000000000..662c834c30 --- /dev/null +++ b/app/api/helpers/cell_line_api_params_helpers.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module CellLineApiParamsHelpers + extend Grape::API::Helpers + + params :cell_line_get_params do + optional :collection_id, type: Integer, desc: 'Collection id' + optional :sync_collection_id, type: Integer, desc: 'SyncCollectionsUser id' + optional :filter_created_at, type: Boolean, desc: 'filter by created at or updated at' + optional :from_date, type: Integer, desc: 'created_date from in ms' + optional :to_date, type: Integer, desc: 'created_date to in ms' + end + + params :cell_line_creation_params do + optional :organism, type: String, desc: 'name of the donor organism of the cell' + optional :tissue, type: String, desc: 'tissue from which the cell originates' + requires :amount, type: Integer, desc: 'amount of cells' + requires :unit, type: String, desc: 'unit of cell amount' + requires :passage, type: Integer, desc: 'passage of cells' + optional :disease, type: String, desc: 'deasease of cells' + requires :material_names, type: String, desc: 'names of cell line e.g. name1;name2' + requires :collection_id, type: Integer, desc: 'Collection of the cell line sample' + optional :cell_type, type: String, desc: 'type of cells' + optional :biosafety_level, type: String, desc: 'biosafety_level of cells' + optional :growth_medium, type: String, desc: 'growth medium of cells' + optional :variant, type: String, desc: 'variant of cells' + optional :optimal_growth_temp, type: Float, desc: 'optimal_growth_temp of cells' + optional :cryo_pres_medium, type: String, desc: 'cryo preservation medium of cells' + optional :gender, type: String, desc: 'gender of donor organism' + optional :material_description, type: String, desc: 'description of cell line concept' + optional :contamination, type: String, desc: 'contamination of a cell line sample' + requires :source, type: String, desc: 'source of a cell line sample' + optional :name, type: String, desc: 'name of a cell line sample' + optional :mutation, type: String, desc: 'mutation of a cell line' + optional :description, type: String, desc: 'description of a cell line sample' + optional :short_label, type: String, desc: 'short label of a cell line sample' + requires :container, type: Hash, desc: 'root Container of element' + end + + params :cell_line_update_params do + requires :cell_line_sample_id, type: String, desc: 'id of the cell line to update' + optional :organism, type: String, desc: 'name of the donor organism of the cell' + optional :mutation, type: String, desc: 'mutation of a cell line' + optional :tissue, type: String, desc: 'tissue from which the cell originates' + requires :amount, type: Integer, desc: 'amount of cells' + requires :unit, type: String, desc: 'unit of amount of cells' + optional :passage, type: Integer, desc: 'passage of cells' + optional :disease, type: String, desc: 'deasease of cells' + optional :material_names, type: String, desc: 'names of cell line e.g. name1;name2' + optional :collection_id, type: Integer, desc: 'Collection of the cell line sample' + optional :cell_type, type: String, desc: 'type of cells' + optional :biosafety_level, type: String, desc: 'biosafety_level of cells' + optional :variant, type: String, desc: 'variant of cells' + optional :optimal_growth_temp, type: Float, desc: 'optimal_growth_temp of cells' + optional :cryo_pres_medium, type: String, desc: 'cryo preservation medium of cells' + optional :gender, type: String, desc: 'gender of donor organism' + optional :material_description, type: String, desc: 'description of cell line concept' + optional :contamination, type: String, desc: 'contamination of a cell line sample' + optional :source, type: String, desc: 'source of a cell line sample' + optional :name, type: String, desc: 'name of a cell line sample' + optional :description, type: String, desc: 'description of a cell line sample' + requires :container, type: Hash, desc: 'root Container of element' + end +end diff --git a/app/javascript/src/components/contextActions/CreateButton.js b/app/javascript/src/components/contextActions/CreateButton.js index 99e5f1ab3a..8785c1b577 100644 --- a/app/javascript/src/components/contextActions/CreateButton.js +++ b/app/javascript/src/components/contextActions/CreateButton.js @@ -75,6 +75,11 @@ export default class CreateButton extends React.Component { return uiState.reaction.checkedIds.first(); } + getCellLineId(){ + let uiState = UIStore.getState(); + return uiState.cell_line.checkedIds.first(); + } + isCopySampleDisabled() { let sampleFilter = this.getSampleFilter(); return !sampleFilter.all && sampleFilter.included_ids.size == 0; @@ -102,6 +107,17 @@ export default class CreateButton extends React.Component { ElementActions.copyReactionFromId(reactionId); } + isCopyCellLineDisabled() { + let cellLineId = this.getCellLineId(); + return !cellLineId; + } + + copyCellLine() { + let uiState = UIStore.getState(); + let cellLineId = this.getCellLineId(); + ElementActions.copyCellLineFromId(parseInt(cellLineId),uiState.currentCollection.id); + } + createWellplateFromSamples() { let uiState = UIStore.getState(); let sampleFilter = this.filterParamsFromUIStateByElementType(uiState, "sample"); @@ -290,6 +306,9 @@ export default class CreateButton extends React.Component { this.copyReaction()} disabled={this.isCopyReactionDisabled()}> Copy Reaction + this.copyCellLine()} disabled={this.isCopyCellLineDisabled()}> + Copy Cell line + ); } diff --git a/app/javascript/src/components/contextActions/SplitElementButton.js b/app/javascript/src/components/contextActions/SplitElementButton.js index 93a22f855c..d872ee5587 100644 --- a/app/javascript/src/components/contextActions/SplitElementButton.js +++ b/app/javascript/src/components/contextActions/SplitElementButton.js @@ -91,6 +91,12 @@ export default class SplitElementButton extends React.Component { > Split Wellplate + ElementActions.splitAsSubCellLines(UIStore.getState())} + disabled={this.noSelected('cell_line') || this.isAllCollection()} + > + Split Cell line + {sortedGenericEls.map((el) => ( CellLine.createFromRestResponse(params.collection_id, json)) .then((cellLineItem) => { NotificationActions.add(successfullyCreatedParameter); - user.cell_lines_count = user.cell_lines_count +1; + user.cell_lines_count += 1; return cellLineItem; }) .catch((errorMessage) => { @@ -125,6 +102,29 @@ export default class CellLinesFetcher { }).then((response) => response.json()); } + static copyCellLine(cellLineId, collectionId) { + const params = { + id: cellLineId, + collection_id: collectionId, + container: Container.init() + }; + + return fetch('/api/v1/cell_lines/copy/', { + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + method: 'POST', + body: JSON.stringify(params) + }).then((response) => response.json()) + + .then((json) => { + NotificationActions.add(successfullyCopiedParameter); + return CellLine.createFromRestResponse(collectionId, json); + }); + } + static update(cellLineItem) { const params = extractApiParameter(cellLineItem); const promise = CellLinesFetcher.uploadAttachments(cellLineItem) @@ -138,8 +138,8 @@ export default class CellLinesFetcher { body: JSON.stringify(params) })) .then((response) => response.json()) - .then(() => {BaseFetcher.updateAnnotationsInContainer(cellLineItem)}) - .then(()=> CellLinesFetcher.fetchById(cellLineItem.id)) + .then(() => { BaseFetcher.updateAnnotationsInContainer(cellLineItem); }) + .then(() => CellLinesFetcher.fetchById(cellLineItem.id)) .then((loadedCellLineSample) => { NotificationActions.add(successfullyUpdatedParameter); return loadedCellLineSample; @@ -151,4 +151,25 @@ export default class CellLinesFetcher { }); return promise; } + + // Here better as parameter list of ids + static splitAsSubCellLines(ids, collectionId) { + const promises = []; + + ids.forEach((id) => { + const params = { id, collection_id: collectionId }; + promises.push(fetch('/api/v1/cell_lines/split', { + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + method: 'POST', + body: JSON.stringify(params) + })); + }); + + return Promise.all(promises) + .then(() => { NotificationActions.add(successfullySplittedParameter); }); + } } diff --git a/app/javascript/src/stores/alt/actions/ElementActions.js b/app/javascript/src/stores/alt/actions/ElementActions.js index 181839607e..ebf2d47713 100644 --- a/app/javascript/src/stores/alt/actions/ElementActions.js +++ b/app/javascript/src/stores/alt/actions/ElementActions.js @@ -480,6 +480,17 @@ class ElementActions { return cellLineSample; } + copyCellLineFromId(id,collectionId ) { + return (dispatch) => { + CellLinesFetcher.copyCellLine(id,collectionId) + .then((result) => { + dispatch(result); + }).catch((errorMessage) => { + console.log(errorMessage); + }); + }; + } + splitAsSubsamples(ui_state) { return (dispatch) => { SamplesFetcher.splitAsSubsamples(ui_state) @@ -676,6 +687,20 @@ class ElementActions { }; } + splitAsSubCellLines(ui_state) { + return (dispatch) => { + const ids = ui_state["cell_line"].checkedIds.toArray(); + const collection_id = ui_state.currentCollection.id; + + CellLinesFetcher.splitAsSubCellLines(ids,collection_id) + .then((result) => { + dispatch(ui_state); + }).catch((errorMessage) => { + console.log(errorMessage); + }); + }; + } + bulkCreateWellplatesFromSamples(params) { let { collection_id, samples, wellplateCount } = params; diff --git a/app/javascript/src/stores/alt/stores/ElementStore.js b/app/javascript/src/stores/alt/stores/ElementStore.js index 1c53562f62..454a7fd7e4 100644 --- a/app/javascript/src/stores/alt/stores/ElementStore.js +++ b/app/javascript/src/stores/alt/stores/ElementStore.js @@ -189,6 +189,7 @@ class ElementStore { handleCopyReaction: ElementActions.copyReaction, handleCopyResearchPlan: ElementActions.copyResearchPlan, handleCopyElement: ElementActions.copyElement, + handleCopyCellLine: ElementActions.copyCellLineFromId, handleOpenReactionDetails: ElementActions.openReactionDetails, handleBulkCreateWellplatesFromSamples: @@ -244,6 +245,7 @@ class ElementStore { handleSplitAsSubsamples: ElementActions.splitAsSubsamples, handleSplitElements: ElementActions.splitElements, handleSplitAsSubwellplates: ElementActions.splitAsSubwellplates, + handleSplitAsSubCellLines: ElementActions.splitAsSubCellLines, // formerly from DetailStore handleSelect: DetailActions.select, handleClose: DetailActions.close, @@ -750,6 +752,10 @@ class ElementStore { ); } + handleSplitAsSubCellLines(ui_state) { + ElementActions.fetchCellLinesByCollectionId(ui_state.currentCollection.id); + } + // Molecules handleFetchMoleculeByMolfile(result) { // Attention: This is intended to update SampleDetails @@ -1007,6 +1013,11 @@ class ElementStore { Aviator.navigate(`/collection/${result.colId}/${result.element.type}/copy`); } + handleCopyCellLine(result){ + UserActions.fetchCurrentUser(); //Needed to update the cell line counter in frontend + Aviator.navigate(`/collection/${result.collectionId}/cell_line/${result.id}`); + } + handleOpenReactionDetails(reaction) { this.changeCurrentElement(reaction); this.handleRefreshElements('sample') diff --git a/app/javascript/src/utilities/CellLineUtils.js b/app/javascript/src/utilities/CellLineUtils.js index a546fc538a..f2eaf434fc 100644 --- a/app/javascript/src/utilities/CellLineUtils.js +++ b/app/javascript/src/utilities/CellLineUtils.js @@ -1,32 +1,79 @@ -const extractApiParameter = (cellLine) => { - return { - "cell_line_sample_id":cellLine.id, - "organism":cellLine.organism, - "tissue":cellLine.tissue, - "amount":cellLine.amount, - "passage":cellLine.passage, - "disease":cellLine.disease, - "growth_medium":cellLine.growthMedium, - "material_names":cellLine.cellLineName, - "collection_id":cellLine.collectionId, - "cell_type":cellLine.cellType, - "mutation":cellLine.mutation, - "unit":cellLine.unit, - "biosafety_level":cellLine.bioSafetyLevel, - "variant":cellLine.variant, - "optimal_growth_temp":cellLine.optimal_growth_temp, - "cryo_pres_medium":cellLine.cryopreservationMedium, - "gender":cellLine.gender, - "material_description":cellLine.materialDescription, - "contamination":cellLine.contamination, - "source":cellLine.source, - "name":cellLine.itemName, - "description":cellLine.itemDescription, - "short_label":cellLine.short_label, - "container":cellLine.container +const extractApiParameter = (cellLine) => ({ + cell_line_sample_id: cellLine.id, + organism: cellLine.organism, + tissue: cellLine.tissue, + amount: cellLine.amount, + passage: cellLine.passage, + disease: cellLine.disease, + growth_medium: cellLine.growthMedium, + material_names: cellLine.cellLineName, + collection_id: cellLine.collectionId, + cell_type: cellLine.cellType, + mutation: cellLine.mutation, + unit: cellLine.unit, + biosafety_level: cellLine.bioSafetyLevel, + variant: cellLine.variant, + optimal_growth_temp: cellLine.optimal_growth_temp, + cryo_pres_medium: cellLine.cryopreservationMedium, + gender: cellLine.gender, + material_description: cellLine.materialDescription, + contamination: cellLine.contamination, + source: cellLine.source, + name: cellLine.itemName, + description: cellLine.itemDescription, + short_label: cellLine.short_label, + container: cellLine.container +}); + +const successfullyCreatedParameter = { + title: 'Element created', + message: 'Cell line sample successfully added', + level: 'info', + dismissible: 'button', + autoDismiss: 10, + position: 'tr' +}; + +const successfullyCopiedParameter = { + title: 'Element copied', + message: 'Cell line sample successfully copied', + level: 'info', + dismissible: 'button', + autoDismiss: 10, + position: 'tr' +}; + +const successfullyUpdatedParameter = { + title: 'Element updated', + message: 'Cell line sample successfully updated', + level: 'info', + dismissible: 'button', + autoDismiss: 10, + position: 'tr' +}; +const successfullySplittedParameter = { + title: 'Element splitted', + message: 'Cell line sample successfully splitted', + level: 'info', + dismissible: 'button', + autoDismiss: 10, + position: 'tr' +}; + +const errorMessageParameter = { + title: 'Error', + message: 'Unfortunately, the last action failed. Please try again or contact your admin.', + level: 'error', + dismissible: 'button', + autoDismiss: 30, + position: 'tr' }; -} export { - extractApiParameter -}; \ No newline at end of file + extractApiParameter, + successfullyCreatedParameter, + successfullyCopiedParameter, + successfullyUpdatedParameter, + errorMessageParameter, + successfullySplittedParameter +}; diff --git a/app/models/cellline_sample.rb b/app/models/cellline_sample.rb index 30a98daf92..ce5e2f2417 100644 --- a/app/models/cellline_sample.rb +++ b/app/models/cellline_sample.rb @@ -22,6 +22,7 @@ # rubocop:disable Rails/InverseOf, Rails/HasManyOrHasOneDependent class CelllineSample < ApplicationRecord acts_as_paranoid + has_ancestry include ElementUIStateScopes include Taggable @@ -35,6 +36,10 @@ class CelllineSample < ApplicationRecord belongs_to :cellline_material belongs_to :creator, class_name: 'User', foreign_key: 'user_id' + has_many :sync_collections_users, through: :collections + + after_create :create_root_container + scope :by_sample_name, lambda { |query, collection_id| joins(:collections).where(collections: { id: collection_id }) .where('name ILIKE ?', "%#{sanitize_sql_like(query)}%") @@ -46,5 +51,9 @@ class CelllineSample < ApplicationRecord .where('collections.id=?', collection_id) .where('cellline_materials.name ILIKE ?', "%#{sanitize_sql_like(query)}%") } + + def create_root_container + self.container = Container.create_root_container if container.nil? + end end # rubocop:enable Rails/InverseOf, Rails/HasManyOrHasOneDependent diff --git a/app/usecases/cell_lines/copy.rb b/app/usecases/cell_lines/copy.rb new file mode 100644 index 0000000000..049036d6b3 --- /dev/null +++ b/app/usecases/cell_lines/copy.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Usecases + module CellLines + class Copy + def initialize(cell_line_sample_to_copy, current_user, collection_id) + @current_user = current_user + @cell_line_sample_to_copy = cell_line_sample_to_copy + @collection_id = collection_id + end + + def execute! + copied_cell_line_sample = copy_cellline_sample + create_collection_links(copied_cell_line_sample.id) + @current_user.increment_counter('celllines') # rubocop: disable Rails/SkipsModelValidations + copied_cell_line_sample + end + + private + + def copy_cellline_sample + CelllineSample.create( + cellline_material: @cell_line_sample_to_copy.cellline_material, + creator: @current_user, + amount: @cell_line_sample_to_copy[:amount], + unit: @cell_line_sample_to_copy[:unit], + passage: @cell_line_sample_to_copy[:passage], + contamination: @cell_line_sample_to_copy[:contamination], + name: @cell_line_sample_to_copy[:name], + description: @cell_line_sample_to_copy[:description], + short_label: create_short_label, + ) + end + + def create_short_label + "#{@current_user.name_abbreviation}-C#{@current_user.counters['celllines']}" + end + + def create_collection_links(id) + CollectionsCellline.create( + collection: Collection.find(@collection_id), + cellline_sample_id: id, + ) + CollectionsCellline.create( + collection: all_collection_of_current_user, + cellline_sample_id: id, + ) + end + + def all_collection_of_current_user + Collection.get_all_collection_for_user(@current_user.id) + end + end + end +end diff --git a/app/usecases/cell_lines/split.rb b/app/usecases/cell_lines/split.rb new file mode 100644 index 0000000000..b2c9f1b4a9 --- /dev/null +++ b/app/usecases/cell_lines/split.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Usecases + module CellLines + class Split + def initialize(cell_line_sample_to_copy, current_user, collection_id) + @current_user = current_user + @cell_line_sample_to_copy = cell_line_sample_to_copy + @collection_id = collection_id + end + + def execute! + @copied_cell_line_sample = Usecases::CellLines::Copy.new(@cell_line_sample_to_copy, @current_user, + @collection_id).execute! + decrease_cell_line_counter + + create_ancestry_relationship + + create_short_label + + @copied_cell_line_sample + end + + private + + def create_ancestry_relationship + @copied_cell_line_sample.parent = @cell_line_sample_to_copy + @copied_cell_line_sample.save + end + + def create_short_label + next_child_index = @cell_line_sample_to_copy.children.count.to_s + @copied_cell_line_sample.short_label = "#{@cell_line_sample_to_copy.short_label}-#{next_child_index}" + @copied_cell_line_sample.save + end + + def decrease_cell_line_counter + @current_user.counters['celllines'] = (@current_user.counters['celllines'].to_i - 1).to_s + end + end + end +end diff --git a/db/migrate/20240531112321_add_ancestry_to_cellline_sample.rb b/db/migrate/20240531112321_add_ancestry_to_cellline_sample.rb new file mode 100644 index 0000000000..784d39046d --- /dev/null +++ b/db/migrate/20240531112321_add_ancestry_to_cellline_sample.rb @@ -0,0 +1,6 @@ +class AddAncestryToCelllineSample < ActiveRecord::Migration[6.1] + def change + add_column :cellline_samples, :ancestry, :string + add_index :cellline_samples, :ancestry + end +end diff --git a/spec/api/chemotion/cell_line_api_spec.rb b/spec/api/chemotion/cell_line_api_spec.rb index f46b2246c9..c4d77c66b0 100644 --- a/spec/api/chemotion/cell_line_api_spec.rb +++ b/spec/api/chemotion/cell_line_api_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# rubocop:disable RSpec/LetSetup, RSpec/NestedGroups +# rubocop:disable RSpec/LetSetup, RSpec/NestedGroups, RSpec/AnyInstance require 'rails_helper' @@ -249,5 +249,122 @@ end end end + + describe 'POST /api/v1/cell_lines/copy' do + let(:collection) { create(:collection, label: 'other collection') } + let!(:user) { create(:user, collections: [collection]) } + let!(:cell_line) { create(:cellline_sample, collections: [collection]) } + let(:allow_creation) { true } + let(:container_param) do + { 'name' => 'new', + 'children' => +[{ 'name' => 'new', + 'children' => [], + 'attachments' => [], + 'is_deleted' => false, + 'description' => '', + 'extended_metadata' => { 'report' => true }, + 'container_type' => 'analyses', + 'id' => '656936a0-0627-11ef-b812-d3c35856aafa', + 'is_new' => true, + '_checksum' => '6901ba2b29f8464ede2dce839d1dfba710dbbfa6d2c4ad80f6bd3a933e792028' }], + 'attachments' => [], + 'is_deleted' => false, + 'description' => '', + 'extended_metadata' => { 'report' => true }, + 'container_type' => 'root', + 'id' => '65690f90-0627-11ef-b812-d3c35856aafa', + 'is_new' => true, + '_checksum' => '7a0f02ddb8c73d674640466b84ce50e53465a7d91265aa0e8ece271f099d04f5' } + end + let(:params) do + { + id: cell_line.id, + collection_id: collection.id, + container: container_param, + } + end + + before do + allow_any_instance_of(ElementsPolicy).to receive(:update?).and_return(allow_creation) + post '/api/v1/cell_lines/copy', params: params + end + + context 'when cell line not accessable' do + let(:params) { { id: '-1', collection_id: collection.id, container: container_param } } + + it 'returns correct response code 400' do + expect(response).to have_http_status :bad_request + end + end + + context 'when user only has read access' do + let(:allow_creation) { false } + + before do + allow_any_instance_of(ElementPolicy).to receive(:update?).and_return(false) + post '/api/v1/cell_lines/copy', params: params + end + + it 'returns correct response code 401' do + expect(response).to have_http_status :unauthorized + end + end + + context 'when user has write access' do + it 'returns correct response code' do + expect(response).to have_http_status :created + end + + it 'copied cell line sample was created' do + loaded_cell_line_sample = CelllineSample.find(parsed_json_response['id']) + expect(loaded_cell_line_sample).not_to be_nil + end + + it 'copied cell line added to all and original collection' do + loaded_cell_line_sample = CelllineSample.find(parsed_json_response['id']) + collection_labels = loaded_cell_line_sample.collections.order(:label).pluck(:label) + expect(collection_labels).to eq ['All', 'other collection'] + end + end + end + + describe 'POST /api/v1/cell_lines/split' do + let(:collection) { create(:collection, label: 'other collection') } + let!(:user) { create(:user, collections: [collection]) } + let!(:cell_line) { create(:cellline_sample, collections: [collection]) } + let(:allow_creation) { true } + let(:params) do + { + id: cell_line.id, + collection_id: collection.id, + } + end + + context 'when user has write access' do + before do + allow_any_instance_of(ElementsPolicy).to receive(:update?).and_return(allow_creation) + post '/api/v1/cell_lines/split', params: params + end + + it 'returns correct response code' do + expect(response).to have_http_status :created + end + + it 'splitted cell_line_sample was created' do + expect(parsed_json_response['id']).not_to be cell_line.id + end + + it 'splitted cell_line short label is correct' do + expect(parsed_json_response['short_label']).to eq "#{cell_line.short_label}-1" + end + + it 'check if ancestry relationship is correct' do + splitted_cellline = CelllineSample.find(parsed_json_response['id']) + expect(splitted_cellline.parent.id).to be cell_line.id + expect(cell_line.reload.children.first.id).to be splitted_cellline.id + end + end + end end -# rubocop:enable RSpec/LetSetup, RSpec/NestedGroups +# rubocop:enable RSpec/LetSetup, RSpec/NestedGroups, RSpec/AnyInstance diff --git a/spec/factories/cell_line_sample.rb b/spec/factories/cell_line_sample.rb index fef6633012..33f7e8d2ea 100644 --- a/spec/factories/cell_line_sample.rb +++ b/spec/factories/cell_line_sample.rb @@ -9,6 +9,10 @@ amount { 999 } passage { 10 } unit { 'g' } + description { 'description' } + contamination { 'contamination' } + name { 'name' } + sequence(:short_label) { |i| "C#{i}" } container { FactoryBot.create(:container, :with_analysis) } end diff --git a/spec/usecases/cell_lines/copy_spec.rb b/spec/usecases/cell_lines/copy_spec.rb new file mode 100644 index 0000000000..deaba168ff --- /dev/null +++ b/spec/usecases/cell_lines/copy_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/MultipleExpectations + +require 'spec_helper' + +RSpec.describe Usecases::CellLines::Copy do + let(:user) { create(:user) } + let(:collection) { create(:collection) } + let(:cell_line_sample_to_copy) { create(:cellline_sample) } + let(:use_case) { described_class.new(cell_line_sample_to_copy, user, collection.id) } + + describe 'execute!' do + let(:cell_line_sample_copied) { use_case.execute! } + let(:loaded_cell_line_sample_copied) { CelllineSample.find(cell_line_sample_copied.id) } + + context 'when cell line is copyable' do + it 'cell line sample was copied' do + expect(loaded_cell_line_sample_copied).not_to be_nil + end + + it 'cell line sample match' do # rubocop:disable RSpec/MultipleExpectations + expect(loaded_cell_line_sample_copied.amount).to be cell_line_sample_to_copy.amount + expect(loaded_cell_line_sample_copied.passage).to be cell_line_sample_to_copy.passage + expect(loaded_cell_line_sample_copied.contamination).to eq(cell_line_sample_to_copy.contamination) + expect(loaded_cell_line_sample_copied.name).to eq(cell_line_sample_to_copy.name) + expect(loaded_cell_line_sample_copied.description).to eq(cell_line_sample_to_copy.description) + + expect(loaded_cell_line_sample_copied.cellline_material.id).to be cell_line_sample_to_copy.cellline_material.id + end + + it 'attributes are deep copied' do + expected_amount = cell_line_sample_to_copy.amount + expected_name = cell_line_sample_to_copy.name + expect(loaded_cell_line_sample_copied.amount).to be expected_amount + expect(loaded_cell_line_sample_copied.name).to eq(expected_name) + cell_line_sample_to_copy.amount = 100 + cell_line_sample_to_copy.name = 'new' + expect(loaded_cell_line_sample_copied.amount).to be expected_amount + expect(loaded_cell_line_sample_copied.name).to eq(expected_name) + end + + it 'cell line sample label was created' do + expect(loaded_cell_line_sample_copied.short_label).not_to be cell_line_sample_to_copy.short_label + end + + it 'user cell line amount was changed' do + loaded_cell_line_sample_copied + old_value = user.counters['celllines'] + expect(user.reload.counters['celllines']).not_to be old_value + end + end + end +end +# rubocop:enable RSpec/MultipleExpectations