diff --git a/app/assets/javascripts/single_page/dynamic_table.js.erb b/app/assets/javascripts/single_page/dynamic_table.js.erb index 6ee1597be0..74674863b8 100644 --- a/app/assets/javascripts/single_page/dynamic_table.js.erb +++ b/app/assets/javascripts/single_page/dynamic_table.js.erb @@ -48,7 +48,7 @@ function sanitizeData(data) { const objectInputTemp = '' + ''; const typeaheadSamplesUrl = "<%= typeahead_samples_path(linked_sample_type_id: '_LINKED_') %>"; @@ -416,6 +416,7 @@ const handleSelect = (e) => { this.options.callback(); } if (disableLoading) disableLoading(); + location.reload(); }, headers: function () { return this.table diff --git a/app/assets/javascripts/single_page/index.js.erb b/app/assets/javascripts/single_page/index.js.erb index 0225c1e949..ad5a95c8ad 100644 --- a/app/assets/javascripts/single_page/index.js.erb +++ b/app/assets/javascripts/single_page/index.js.erb @@ -168,6 +168,7 @@ async function fetchTerms(elem, cvId) { async function batchCreateSample(sampleTypes, projectDefaultPolicy) { try { let data = []; + let sample_type_id = sampleTypes[0].sampleTypeId; sampleTypes.forEach((s) => { s.samples.forEach((sa, k) => { data.push( @@ -179,7 +180,7 @@ async function batchCreateSample(sampleTypes, projectDefaultPolicy) { if (data.length == 0) { return; } - return ajaxCall("<%= batch_create_samples_path %>", "POST", { data: JSON.stringify({ data }) }); + return ajaxCall("<%= batch_create_samples_path %>", "POST", { data: JSON.stringify({ sample_type_id, data }) }); } catch (e) { console.log(e); } @@ -188,16 +189,17 @@ async function batchCreateSample(sampleTypes, projectDefaultPolicy) { async function batchDeleteSample(sampleTypes) { try { let data = []; + let sample_type_id = sampleTypes[0].sampleTypeId; sampleTypes.forEach((s) => { s.samples.forEach((sa, k) => { - data.push(batchSampleDeleteStruct(sa.exId, sa.id)); + data.push(batchSampleDeleteStruct(sa.exId, sa.id, s)); }); }); if (data.length == 0) { return; } - return ajaxCall("<%= batch_delete_samples_path %>", "DELETE", { data: JSON.stringify({ data }) }); + return ajaxCall("<%= batch_delete_samples_path %>", "DELETE", { data: JSON.stringify({ sample_type_id, data }) }); } catch (e) { console.log(e); } @@ -215,7 +217,7 @@ async function exportToExcel(tableName, studyId, assayId, sampleTypeId) { // Checks whether the dynamic table has errors // The excel export will be aborted as long as the dynamic table has errors - hasErrorcells = $j(`table[id=${tableName}]`).find('select.select2__error').size() > 0 + const hasErrorcells = $j(`table[id=${tableName}]`).find('select.select2__error').size() > 0; if (hasErrorcells) { alert('It appears this sample table has some errors. Please correct the errors in the current sample table and try downloading again.'); return; @@ -273,8 +275,7 @@ async function exportToExcel(tableName, studyId, assayId, sampleTypeId) { } }) .done( function(response){ - downloadUrl = `<%= download_samples_excel_single_pages_path() %>?uuid=${response.uuid}`; - window.location.href = downloadUrl; + window.location.href = `<%= download_samples_excel_single_pages_path() %>?uuid=${response.uuid}`; }) .fail( function(response){ alert(`Failed to export through excel!\nStatus: ${response.status}\nError: ${JSON.stringify(response.error().statusText)}`); @@ -287,6 +288,7 @@ async function exportToExcel(tableName, studyId, assayId, sampleTypeId) { async function batchUpdateSample(sampleTypes) { try { let data = []; + let sample_type_id = sampleTypes[0].sampleTypeId; sampleTypes.forEach((s) => { s.samples.forEach((sa, k) => { data.push(batchSampleUpdateStruct(sa.exId, sa.data, sa.id)); @@ -296,7 +298,7 @@ async function batchUpdateSample(sampleTypes) { if (data.length == 0) { return; } - return ajaxCall("<%= batch_update_samples_path %>", "PUT", { data: JSON.stringify({ data }) }); + return ajaxCall("<%= batch_update_samples_path %>", "PUT", { data: JSON.stringify({ sample_type_id, data }) }); } catch (e) { console.log(e); } @@ -305,7 +307,7 @@ async function batchUpdateSample(sampleTypes) { async function handleUploadSubmit(formData){ $j.ajax({ type: 'POST', - url: "<%= upload_samples_single_pages_path %>", + url: "<%= preview_upload_samples_single_pages_path %>", data: formData, dataType: 'html', processData: false, diff --git a/app/assets/stylesheets/styles.scss b/app/assets/stylesheets/styles.scss index 63757601dd..746f595809 100644 --- a/app/assets/stylesheets/styles.scss +++ b/app/assets/stylesheets/styles.scss @@ -803,3 +803,7 @@ div#super_tag_cloud { max-width: 100%; border: 1px solid #ddd; } + +[inert] { + opacity: 0.5; +} \ No newline at end of file diff --git a/app/controllers/samples_controller.rb b/app/controllers/samples_controller.rb index 254c879f95..04ad94799d 100644 --- a/app/controllers/samples_controller.rb +++ b/app/controllers/samples_controller.rb @@ -150,62 +150,141 @@ def filter def batch_create errors = [] results = [] - param_converter = Seek::Api::ParameterConverter.new("samples") - Sample.transaction do - params[:data].each do |par| - converted_params = param_converter.convert(par) - sample_type = SampleType.find_by_id(converted_params.dig(:sample, :sample_type_id)) - sample = Sample.new(sample_type: sample_type) - sample = update_sample_with_params(converted_params, sample) - if sample.save - results.push({ ex_id: par[:ex_id], id: sample.id }) - else - errors.push({ ex_id: par[:ex_id], error: sample.errors.messages }) - end - end - raise ActiveRecord::Rollback if errors.any? + sample_type_id = params.permit(:sample_type_id).dig(:sample_type_id) + parameters = batch_upload_sample_params(params, sample_type_id) + sample_type = SampleType.find(sample_type_id) + + if sample_type.nil? + err_message = "Sample Type with ID '#{sample_type_id}' not found." + errors.push err_message + raise err_message + end + + if sample_type.locked? + err_message = 'Batch upload not allowed. Sample Type is currently locked! Wait until the lock is removed and try again.' + errors.push err_message + raise err_message end - status = errors.empty? ? :ok : :unprocessable_entity - render json: { status: status, errors: errors, results: results }, status: :ok + + if parameters.count < 100 + batch_create_processor = Samples::SampleBatchProcessor.new(sample_type_id: sample_type_id, + batch_process_params: parameters, + user: @current_user) + batch_create_processor.create! + results.concat(batch_create_processor.results) + errors.concat(batch_create_processor.errors) + raise "The following errors occurred: #{errors.join("\n")}" unless errors.empty? + else + SamplesBatchCreateJob.perform_later(sample_type_id, parameters, @current_user, true) + results = ['A background job has been launched.'] + end + status = :ok + rescue StandardError => e + flash[:error] = e.message + status = :unprocessable_entity + ensure + render json: { + errors: errors, + results: results, + status: status + }, + status: status end def batch_update errors = [] - param_converter = Seek::Api::ParameterConverter.new("samples") - Sample.transaction do - params[:data].each do |par| - begin - converted_params = param_converter.convert(par) - sample = Sample.find(par[:id]) - raise 'shouldnt get this far without manage rights' unless sample.can_manage? - sample = update_sample_with_params(converted_params, sample) - saved = sample.save - errors.push({ ex_id: par[:ex_id], error: sample.errors.messages }) unless saved - rescue - errors.push({ ex_id: par[:ex_id], error: "Can not be updated." }) - end - end - raise ActiveRecord::Rollback if errors.any? + results = [] + sample_type_id = params.permit(:sample_type_id).dig(:sample_type_id) + parameters = batch_upload_sample_params(params, sample_type_id) + sample_type = SampleType.find(sample_type_id) + + if sample_type.nil? + err_message = "Sample Type with ID '#{sample_type_id}' not found." + errors.push err_message + raise err_message + end + + if sample_type.locked? + err_message = 'Batch upload not allowed. Sample Type is currently locked! Wait until the lock is removed and try again.' + errors.push err_message + raise err_message + end + + if sample_type.batch_upload_in_progress? + err_message = 'Batch upload not allowed. There is already a background job in progress for this Sample Type. Please wait and try again later.' + errors.push err_message + raise err_message + end + + if parameters.count < 100 + batch_update_processor = Samples::SampleBatchProcessor.new(sample_type_id: sample_type_id, + batch_process_params: parameters, + user: @current_user) + batch_update_processor.update! + errors.concat(batch_update_processor.errors) + results.concat(batch_update_processor.results) + raise "The following errors occurred: #{errors.join("\n")}" unless errors.empty? + else + SamplesBatchUpdateJob.perform_later(sample_type_id, parameters, @current_user, true) + results = ['A background job has been launched. This Sample Type will now lock itself as long as the background job is in progress.'] end - status = errors.empty? ? :ok : :unprocessable_entity - render json: { status: status, errors: errors }, status: :ok + status = :ok + rescue StandardError => e + flash[:error] = e.message + status = :unprocessable_entity + ensure + render json: { + errors: errors, + results: results, + status: status + }, + status: status end def batch_delete errors = [] - Sample.transaction do - params[:data].each do |par| - begin - sample = Sample.find(par[:id]) - errors.push({ ex_id: par[:ex_id], error: "Can not be deleted." }) if !(sample.can_delete? && sample.destroy) - rescue - errors.push({ ex_id: par[:ex_id], error: sample.errors.messages }) - end - end - raise ActiveRecord::Rollback if errors.any? + results = [] + sample_type_id = params.permit(:sample_type_id).dig(:sample_type_id) + parameters = batch_delete_sample_params + sample_type = SampleType.find(sample_type_id) + + if sample_type.nil? + err_message = "Sample Type with ID '#{sample_type_id}' not found." + errors.push err_message + raise err_message + end + + if sample_type.locked? + err_message = 'Batch upload not allowed. Sample Type is currently locked! Wait until the lock is removed and try again.' + errors.push err_message + raise err_message + end + + if sample_type.batch_upload_in_progress? + err_message = 'Batch upload not allowed. There is already a background job in progress for this Sample Type. Please wait and try again later.' + errors.push err_message + raise err_message end - status = errors.empty? ? :ok : :unprocessable_entity - render json: { status: status, errors: errors }, status: :ok + + batch_update_processor = Samples::SampleBatchProcessor.new(sample_type_id: sample_type_id, + batch_process_params: parameters, + user: @current_user) + batch_update_processor.delete! + errors.concat(batch_update_processor.errors) + results.concat(batch_update_processor.results) + raise "The following errors occurred: #{errors.join("\n")}" unless errors.empty? + + status = :ok + rescue StandardError => e + flash[:error] = e.message + status = :unprocessable_entity + ensure + render json: { + errors: errors, + results: results, + status: status + }, + status: status end def typeahead @@ -283,6 +362,31 @@ def query_form private + def batch_delete_sample_params(parameters = params) + batch_params = [] + parameters[:data].each do |par| + batch_params << par.permit(:id, :ex_id) + end + batch_params + end + + def batch_upload_sample_params(parameters = params, sample_type_id) + param_converter = Seek::Api::ParameterConverter.new("samples") + batch_params = [] + parameters[:data].each do |par| + converted_params = param_converter.convert(par) + ex_id = par[:ex_id] + sample_type = SampleType.find_by_id(sample_type_id) + conv_par = sample_params(sample_type, converted_params).merge(ex_id: ex_id) unless ex_id.blank? + if parameters[:action] == 'batch_update' + sample_id = par[:id] + conv_par.merge!(id: sample_id) + end + batch_params << conv_par + end + batch_params + end + def sample_params(sample_type = nil, parameters = params) sample_type_param_keys = [] @@ -304,6 +408,7 @@ def sample_params(sample_type = nil, parameters = params) discussion_links_attributes:[:id, :url, :label, :_destroy]) end + def update_sample_with_params(parameters = params, sample = @sample) sample.assign_attributes(sample_params(sample.sample_type, parameters)) update_sharing_policies(sample, parameters) @@ -352,6 +457,7 @@ def filter_linked_samples(samples, link, options, template_attribute) end end end + def templates_enabled? unless Seek::Config.isa_json_compliance_enabled flash[:error] = 'Not available' diff --git a/app/controllers/single_pages_controller.rb b/app/controllers/single_pages_controller.rb index ef75dd94fb..75f0e22519 100644 --- a/app/controllers/single_pages_controller.rb +++ b/app/controllers/single_pages_controller.rb @@ -10,7 +10,6 @@ class SinglePagesController < ApplicationController before_action :check_user_logged_in, only: %i[batch_sharing_permission_preview batch_change_permission_for_selected_items] respond_to :html, :js - def show @project = Project.find(params[:id]) @folders = project_folders @@ -118,7 +117,7 @@ def export_to_excel end end - def upload_samples + def preview_upload_samples uploaded_file = params[:file] project_id = params[:project_id] @project = Project.find(project_id) diff --git a/app/jobs/samples_batch_create_job.rb b/app/jobs/samples_batch_create_job.rb new file mode 100644 index 0000000000..21ddbbc2c3 --- /dev/null +++ b/app/jobs/samples_batch_create_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class SamplesBatchCreateJob < ApplicationJob + queue_with_priority 1 + queue_as QueueNames::SAMPLES + + def perform(sample_type_id, parameters, user, send_email) + processor = Samples::SampleBatchProcessor.new(sample_type_id:, batch_process_params: parameters, user:, send_email:) + processor.create! + end +end diff --git a/app/jobs/samples_batch_update_job.rb b/app/jobs/samples_batch_update_job.rb new file mode 100644 index 0000000000..3b6f445a93 --- /dev/null +++ b/app/jobs/samples_batch_update_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class SamplesBatchUpdateJob < TaskJob + queue_with_priority 1 + queue_as QueueNames::SAMPLES + + def perform(sample_type_id, parameters, user, send_email) + processor = Samples::SampleBatchProcessor.new(sample_type_id:, batch_process_params: parameters, user:, send_email:) + processor.update! + end + + def task + sample_type = SampleType.find(arguments[0]) + sample_type.sample_batch_upload_task + end +end diff --git a/app/models/mailer.rb b/app/models/mailer.rb index 3f92484ccb..694f04363b 100644 --- a/app/models/mailer.rb +++ b/app/models/mailer.rb @@ -360,6 +360,24 @@ def notify_admins_project_creation_rejected(responder, requester, project_name, subject: subject) end + def notify_user_after_spreadsheet_extraction(user, project, item_type, item_id, results, errors) + @user = user + @project = project + @item_type = item_type + @item_id = item_id + @results = results + @errors = errors + subject = if errors.empty? + "Spreadsheet upload completed successfully" + else + "Spreadsheet upload failed" + end + mail(from: Seek::Config.noreply_sender, + to: user.email_with_name, + subject: subject, + template_name: :notify_user_after_spreadsheet_extraction) + end + private def admin_emails diff --git a/app/models/sample_type.rb b/app/models/sample_type.rb index 30c5f4a3cb..2629bc2169 100644 --- a/app/models/sample_type.rb +++ b/app/models/sample_type.rb @@ -65,6 +65,8 @@ class SampleType < ApplicationRecord has_annotation_type :sample_type_tag, method_name: :tags has_task :sample_metadata_update + has_task :sample_batch_upload + def investigations return [] if studies.empty? && assays.empty? @@ -113,7 +115,11 @@ def is_isa_json_compliant? end def locked? - sample_metadata_update_task&.in_progress? + sample_metadata_update_task.in_progress? + end + + def batch_upload_in_progress? + sample_batch_upload_task.in_progress? end def validate_value?(attribute_name, value) diff --git a/app/services/samples/sample_batch_processor.rb b/app/services/samples/sample_batch_processor.rb new file mode 100644 index 0000000000..0d2d8bfe03 --- /dev/null +++ b/app/services/samples/sample_batch_processor.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true +module Samples + class SampleBatchProcessor + + attr_reader :errors + attr_reader :results + def initialize(sample_type_id:, batch_process_params: [], user:, send_email: false) + @sample_type = SampleType.find(sample_type_id) + @projects = @sample_type.projects + @batch_process_params = batch_process_params + @user = user + @send_email = send_email + @results = [] + @errors = [] + + validate! + end + + def process! + create_samples + update_samples + send_email if @send_email && Seek::Config::email_enabled && !@user.nil? + end + + def create! + create_samples + send_email if @send_email && Seek::Config::email_enabled && !@user.nil? + end + + def update! + update_samples + send_email if @send_email && Seek::Config::email_enabled && !@user.nil? + end + + def delete! + delete_samples + end + + private + + def validate! + raise "Missing sample_type_id or sample type not found" if @sample_type.nil? + raise "No projects associated with this Sample Type" if @projects.empty? + raise "Missing new_sample_params" if @batch_process_params.nil? + raise "Missing user" if @user.nil? + end + + def create_samples + User.with_current_user(@user) do + Sample.transaction do + @batch_process_params.each do |par| + ex_id = par.delete(:ex_id) + sample = Sample.new(sample_type: @sample_type, policy: @sample_type.policy, projects: @projects) + sample.assign_attributes(par) + if sample.save + result = { ex_id: ex_id, message: "Sample '#{sample.title}' successfully created." } + @results << result + Rails.logger.info result + else + error = { ex_id: ex_id, message: "Sample '#{sample.title}' could not be created. Please correct these errors:\n#{sample.errors.full_messages.to_sentence}." } + @errors << error + Rails.logger.info error + raise ActiveRecord::Rollback + end + end + end + end + end + + def update_samples + User.with_current_user(@user) do + Sample.transaction do + @batch_process_params.each do |par| + ex_id = par.delete(:ex_id) + sample_id = par[:id] + sample = Sample.find(sample_id) + + if sample.nil? + @errors << { ex_id: ex_id, message: "Sample with id '#{sample_id}' not found." } + next + end + + unless sample.can_edit?(@user) + @errors << { ex_id: ex_id, message: "Not permitted to update this sample." } + next + end + + sample.assign_attributes(par) + if sample.save + result = { ex_id: ex_id, message: "Sample '[ID: #{sample.id}] #{sample.title}' successfully updated." } + @results << result + Rails.logger.info result + else + error = { ex_id: ex_id, message: "Sample '[ID: #{sample.id}] #{sample.title}' could not be updated. Please correct these errors:\n#{sample.errors.full_messages.to_sentence}." } + @errors << error + Rails.logger.info error + raise ActiveRecord::Rollback + end + end + end + end + end + + def delete_samples + User.with_current_user(@user) do + Sample.transaction do + @batch_process_params.each do |par| + ex_id = par.delete(:ex_id) + sample_id = par[:id] + sample = Sample.find(sample_id) + + if sample.nil? + @errors << { ex_id: ex_id, message: "Sample with id '#{sample_id}' not found." } + next + end + + unless sample.can_delete? + @errors << { ex_id: ex_id, message: "Not permitted to delete this sample." } + next + end + + if sample.destroy + @results << { ex_id: ex_id, message: "Sample '[ID: #{sample.id}] #{sample.title}' successfully deleted." } + else + error = { ex_id: ex_id, error: sample.errors.full_messages.to_sentence } + @errors << error + Rails.logger.info error + raise ActiveRecord::Rollback + end + end + end + end + end + + def send_email + if @sample_type.assays.empty? && @sample_type.studies.any? + item_type = 'study' + item_id = @sample_type.studies.first + elsif @sample_type.assays.any? && @sample_type.studies.empty? + item_type = 'assay' + item_id = @sample_type.assays.first + else + item_type = 'sample_type' + item_id = @sample_type.id + end + + Mailer.notify_user_after_spreadsheet_extraction(@user, @projects, item_type, item_id, @results, @errors).deliver_now + end + end +end diff --git a/app/views/isa_assays/_assay_design.html.erb b/app/views/isa_assays/_assay_design.html.erb index 04fd8d9ae2..dc86383c0e 100644 --- a/app/views/isa_assays/_assay_design.html.erb +++ b/app/views/isa_assays/_assay_design.html.erb @@ -5,6 +5,7 @@ assay_protocol_action = displaying_single_page? ? "highlightTreeviewItem('assay_protocol')" : "loadDynamicTableFromDefaultView('assay_protocol')" assay_samples_table_action = displaying_single_page? ? "highlightTreeviewItem('assay_samples_table')" : "loadDynamicTableFromDefaultView('assay_samples_table')" assay_experiment_overview_action = displaying_single_page? ? "highlightTreeviewItem('assay_experiment_overview')" : "loadDynamicTableFromDefaultView('assay_experiment_overview')" + assay_sample_type_table_is_readonly = [:batch_upload_in_progress?, :locked?].any? { |method| assay&.sample_type.send(method) } %> <% if valid_study && valid_assay %> @@ -26,7 +27,15 @@ <%= render :partial=>"isa_studies/sop", locals: { sops: assay&.sops} -%>
This table is currently locked because of a background job in progress.
+Please try again later!
+This table is currently locked because of a background job in progress.
+Please try again later!
+This table is currently locked because of a background job in progress.
+Please try again later!
+