Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upload samples through spreadsheet single page #1520

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
f3b3afb
Added the roo and roo-xls gems
May 5, 2023
8126b2a
Working on uploading the samples via spreadsheet
May 8, 2023
61a0058
Added functionality to read excel spreadsheets of source samples table
Jun 16, 2023
60ed3ec
Changed form to html button that calls an ajax function
Jun 16, 2023
f8128b3
Added a modal form for the overview of the upload actions
Jun 16, 2023
bcd7762
Added `project` and changed `db_samples` to match the samples from th…
Jun 26, 2023
2cdc97b
Implemented javascript functions to submit samples to update and samp…
Jun 26, 2023
bcd9f56
Changed the sample upload modal:
Jun 26, 2023
9382256
Added error message to the bottom of the form.
Jun 26, 2023
d8c4444
Change `can_manage?` to `can_edit?` and removed the template from the…
Jun 27, 2023
f6b9b1b
Added the roo and roo-xls gems
May 5, 2023
74e401f
Working on uploading the samples via spreadsheet
May 8, 2023
0db4b89
Added functionality to read excel spreadsheets of source samples table
Jun 16, 2023
9f34bd8
Changed form to html button that calls an ajax function
Jun 16, 2023
d851c61
Added a modal form for the overview of the upload actions
Jun 16, 2023
4db4ab2
Added `project` and changed `db_samples` to match the samples from th…
Jun 26, 2023
d1efb55
Implemented javascript functions to submit samples to update and samp…
Jun 26, 2023
ddaba00
Changed the sample upload modal:
Jun 26, 2023
5ed7138
Added error message to the bottom of the form.
Jun 26, 2023
84f01e6
Change `can_manage?` to `can_edit?` and removed the template from the…
Jun 27, 2023
1e760ad
Merge branch 'upload_samples_through_spreadsheet_single_page' of gith…
Jun 27, 2023
b87ee3b
Merge branch 'main' of github.com:seek4science/seek into upload_sampl…
Jul 4, 2023
b9c40e8
Remove duplicated gems
Jul 4, 2023
961bbdf
Updated the sample upload view:
Jul 6, 2023
74742f7
Extrapolate upload functionality to other dynamic tables:
Jul 6, 2023
d0598ce
Fixed notice when error occurs
Jul 7, 2023
546c861
Update counter when a sample is removed.
Jul 7, 2023
4c4c8c0
Modified controller to handle multi-sample inputs
Jul 7, 2023
e447c1f
Format upload summary
Jul 7, 2023
c7fa567
Make table in modal form render multiple inputs as badges
Jul 7, 2023
c19521f
Moving the assay information down to avoid problems when uploading it…
Jul 7, 2023
3b3ed8e
Made changes to cope with assay data
Jul 7, 2023
78cc293
Making sure the user selects a file to upload
Jul 7, 2023
c130c2a
Validation on the Excel spreadsheet:
Jul 10, 2023
4d40b72
Written a test for uploading a spreadsheet
Jul 10, 2023
f62d773
Refactored Single Pages tests
Jul 10, 2023
ac27f25
clean up the controller
Jul 13, 2023
870719c
Tests for upload functionality in different Sample Types
Jul 13, 2023
a250478
Merge branch 'main' of github.com:seek4science/seek into upload_sampl…
Jul 13, 2023
865aaf3
Cleaned up the upload area in the UI
Jul 14, 2023
06a210c
Implement the SpreadsheetExplorerRepresentation instead of roo for sp…
Jul 14, 2023
20c8035
Fixes for multiinput tables
Aug 7, 2023
e0dd9e2
Removed roo from Gemfile.lock
Aug 7, 2023
b3c0cb4
Fix failing test
Aug 7, 2023
92a4edc
Added regex to exclude '=>' in values.
Aug 8, 2023
f2c9fd0
Modified the upload preview table to:
Aug 9, 2023
f0990f4
Fixed failing test
Aug 9, 2023
6b108fb
Fixing the upload summary popup
Aug 11, 2023
635d726
Clean up the uploadAjaxCall
Aug 16, 2023
31a01b0
Fixing the empty cells issue for MS Excel spreadsheets
Aug 16, 2023
451af95
Fixed typo
Aug 16, 2023
079a5a5
Simplify the relative path of the testing files
Sep 18, 2023
b221684
Remove spaces in comment lines of ERB files and add btn bootstrap class
Sep 18, 2023
c58afdb
Refactor the upload_samples function
Sep 19, 2023
8e5947a
Refactor the sample_upload_content view
Sep 19, 2023
9406e03
Add static backdrop to the modal form
Sep 19, 2023
430dcda
Make modal form stay untill Ajax call is finished
Sep 19, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/assets/javascripts/single_page/index.js.erb
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,25 @@ async function batchUpdateSample(sampleTypes) {
}
}

async function handleUploadSubmit(formData){
$j.ajax({
type: 'POST',
url: "<%= upload_samples_single_pages_path %>",
data: formData,
dataType: 'html',
processData: false,
contentType: false,
enctype: 'multipart/form-data',
success: function(response){
$j('#upload-excel-modal').modal({backdrop: 'static', keyboard: false}, 'show').focus();
$j('#upload-excel').html(response);
},
error: function(err){
location.reload(); // Page needs reloading for the notice message to appear
}
});
}

function isMobile() {
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
}
272 changes: 250 additions & 22 deletions app/controllers/single_pages_controller.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
require 'isatab_converter'

# Controller for the Single Page view
class SinglePagesController < ApplicationController
include Seek::AssetsCommon
include Seek::Sharing::SharingCommon
include Seek::Data::SpreadsheetExplorerRepresentation

before_action :set_up_instance_variable
before_action :project_single_page_enabled?
Expand All @@ -14,9 +16,7 @@ class SinglePagesController < ApplicationController
def show
@project = Project.find(params[:id])
@folders = project_folders
respond_to do |format|
format.html
end
respond_to(&:html)
end

def index; end
Expand Down Expand Up @@ -61,39 +61,41 @@ def export_isa
end

def download_samples_excel

sample_ids, sample_type_id, study_id, assay_id = Rails.cache.read(params[:uuid]).values_at(:sample_ids, :sample_type_id,
:study_id, :assay_id)
:study_id, :assay_id)

@study = Study.find(study_id)
@assay = Assay.find(assay_id) unless assay_id.nil?
@project = @study.projects.first
@samples = Sample.where(id: sample_ids)&.authorized_for(:view).sort_by(&:id)
@samples = Sample.where(id: sample_ids)&.authorized_for(:view)&.sort_by(&:id)

raise "Nothing to export to Excel." if @samples.nil? || @samples == [] || sample_type_id.nil?
raise 'Nothing to export to Excel.' if @samples.nil? || @samples == [] || sample_type_id.nil?

@sample_type = SampleType.find(sample_type_id)

sample_attributes = @sample_type.sample_attributes.map do |sa|
obj = if (sa.sample_controlled_vocab_id.nil?)
obj = if sa.sample_controlled_vocab_id.nil?
{ sa_cv_title: sa.title, sa_cv_id: nil }
else
{ sa_cv_title: sa.title, sa_cv_id: sa.sample_controlled_vocab_id }
end
obj.merge({ required: sa.required })
end

@sa_cv_terms = [{ "name" => "id", "has_cv" => false, "data" => nil, "allows_custom_input" => nil, "required" => nil },
{ "name" => "uuid", "has_cv" => false, "data" => nil, "allows_custom_input" => nil, "required" => nil }]
@sa_cv_terms = [{ 'name' => 'id', 'has_cv' => false, 'data' => nil, 'allows_custom_input' => nil, 'required' => nil },
{ 'name' => 'uuid', 'has_cv' => false, 'data' => nil, 'allows_custom_input' => nil,
'required' => nil }]

sample_attributes.map do |sa|
if sa[:sa_cv_id].nil?
@sa_cv_terms.push({ "name" => sa[:sa_cv_title], "has_cv" => false, "data" => nil, "allows_custom_input" => nil, "required" => sa[:required] })
else
allows_custom_input = SampleControlledVocab.find(sa[:sa_cv_id])&.custom_input
sa_terms = SampleControlledVocabTerm.where(sample_controlled_vocab_id: sa[:sa_cv_id]).map(&:label)
@sa_cv_terms.push({ "name" => sa[:sa_cv_title], "has_cv" => true, "data" => sa_terms, "allows_custom_input" => allows_custom_input, "required" => sa[:required] })
end
if sa[:sa_cv_id].nil?
@sa_cv_terms.push({ 'name' => sa[:sa_cv_title], 'has_cv' => false, 'data' => nil,
'allows_custom_input' => nil, 'required' => sa[:required] })
else
allows_custom_input = SampleControlledVocab.find(sa[:sa_cv_id])&.custom_input
sa_terms = SampleControlledVocabTerm.where(sample_controlled_vocab_id: sa[:sa_cv_id]).map(&:label)
@sa_cv_terms.push({ 'name' => sa[:sa_cv_title], 'has_cv' => true, 'data' => sa_terms,
'allows_custom_input' => allows_custom_input, 'required' => sa[:required] })
end
end
@template = Template.find(@sample_type.template_id)

Expand All @@ -102,13 +104,15 @@ def download_samples_excel
flash[:error] = e.message
respond_to do |format|
format.html { redirect_to single_page_path(@project.id) }
format.json { render json: { parameters: { sample_ids: sample_ids, sample_type_id: sample_type_id, study_id: study_id } } }
format.json do
render json: { parameters: { sample_ids:, sample_type_id:, study_id: } }
end
end
end

def export_to_excel
cache_uuid = UUID.new.generate
id_label = "#{Seek::Config::instance_name} id"
id_label = "#{Seek::Config.instance_name} id"
sample_ids = JSON.parse(params[:sample_data]).map { |sample| sample[id_label] unless sample[id_label] == '#HIDDEN' }
sample_type_id = JSON.parse(params[:sample_type_id])
study_id = JSON.parse(params[:study_id])
Expand All @@ -122,8 +126,232 @@ def export_to_excel
end
end

def upload_samples
kdp-cloud marked this conversation as resolved.
Show resolved Hide resolved
uploaded_file = params[:file]
project_id = params[:project_id]
@project = Project.find(project_id)

# Check file type if is xls or xlsx
case uploaded_file.content_type
when 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
spreadsheet_xml = spreadsheet_to_xml(uploaded_file.path, Seek::Config.jvm_memory_allocation)
wb = parse_spreadsheet_xml(spreadsheet_xml)
metadata_sheet = wb.sheet('Metadata')
samples_sheet = wb.sheet('Samples')
else
raise "Please upload a valid spreadsheet file with extension '.xlsx'"
end

sample_type_id_ui = params[:sample_type_id].to_i

unless valid_workbook?(wb)
raise 'Invalid workbook! Cannot process this spreadsheet. Consider first exporting the table as a spreadsheet for the proper format.'
end

# Extract Samples metadata from spreadsheet
study_id = metadata_sheet.cell(2, 2).value.to_i
@study = Study.find(study_id)
sample_type_id = metadata_sheet.cell(5, 2).value.to_i
@sample_type = SampleType.find(sample_type_id)
is_assay = @sample_type.assays.any?
@assay = @sample_type.assays.first

# Sample Type validation rules
unless sample_type_id_ui == @sample_type&.id
raise "Sample Type #{@sample_type&.id} from spreadsheet doesn't match Sample Type #{sample_type_id_ui} from the table. Please upload in the correct table."
end
unless @study.sample_types.include?(@sample_type) || is_assay
raise "Sample Type '#{@sample_type.id}' doesn't belong to Study #{@study.id}. Sample Upload aborted."
end
unless (@assay&.sample_type == @sample_type) || !is_assay
raise "Sample Type '#{@sample_type.id}' doesn't belong to Assay #{@assay.id}. Sample Upload aborted."
end

@multiple_input_fields = @sample_type.sample_attributes.map do |sa_attr|
sa_attr.title if sa_attr.sample_attribute_type.base_type == 'SeekSampleMulti'
end

sample_fields, samples_data = get_spreadsheet_data(samples_sheet)

# Compare Excel header row to Sample Type Sample Attributes
# Should raise an error if they don't match
sample_type_attributes = %w[id uuid].concat(@sample_type.sample_attributes.map(&:title))
has_unmapped_sample_attributes = sample_type_attributes.map { |sa| sample_fields.include?(sa) }.include?(false)
if has_unmapped_sample_attributes
raise "The Sample Attributes from the excel sheet don't match those of the Sample Type in the database. Sample upload was aborted!"
end

# Construct Samples objects from Excel data
excel_samples = generate_excel_samples(samples_data, sample_fields)

existing_excel_samples = excel_samples.map { |sample| sample unless sample['id'].nil? }.compact
new_excel_samples = excel_samples.map { |sample| sample if sample['id'].nil? }.compact

# Retrieve all samples of the Sample Type, also the unauthorized ones
@db_samples = sample_type_samples(@sample_type)
# Retrieve the Sample Types samples wich are authorized for editing
@authorized_db_samples = sample_type_samples(@sample_type, :edit)

# Determine whether samples have been modified or not,
# and checking whether the user is permitted to edit them
@unauthorized_samples, @update_samples = separate_unauthorized_samples(existing_excel_samples, @db_samples,
@authorized_db_samples)

# Determine if the new samples are no duplicates of existing ones,
# based on the attribute values
@possible_duplicates, @new_samples = separate_possible_duplicates(new_excel_samples, @db_samples)

upload_data = { study: @study,
assay: @assay,
sampleType: @sample_type,
excel_samples:,
existingExcelSamples: existing_excel_samples,
newExcelSamples: new_excel_samples,
updateSamples: @update_samples,
newSamples: @new_samples,
possibleDuplicates: @possible_duplicates,
dbSamples: @db_samples,
authorized_db_samples: @authorized_db_samples,
unauthorized_samples: @unauthorized_samples }

respond_to do |format|
format.json { render json: { uploadData: upload_data } }
format.html { render 'single_pages/sample_upload_content', { layout: false } }
end
rescue StandardError => e
flash[:error] = e.message
respond_to do |format|
format.html { redirect_to single_page_path(@project), status: :bad_request }
format.json { render json: { error: e }, status: :bad_request }
end
end

private

def get_spreadsheet_data(samples_sheet)
sample_fields = samples_sheet.row(1).actual_cells.map { |field| field&.value&.sub(' *', '') }.compact
samples_data = (2..samples_sheet.actual_rows.size).map do |i|
sample_cells = samples_sheet.row(i).cells
next if sample_cells.all? { |cell| (cell&.value == '' || cell&.value.nil?) }

sample_cells.map do |cell|
cell&.value
end.drop(1)
end.compact

[sample_fields, samples_data]
end

def generate_excel_samples(samples_data, sample_fields)
samples_data.map do |excel_sample|
obj = {}
(0..sample_fields.size - 1).map do |i|
if @multiple_input_fields.include?(sample_fields[i])
parsed_excel_input_samples = JSON.parse(excel_sample[i].gsub(/"=>/x, '":')).map do |subsample|
# Uploader should at least have viewing permissions for the inputs he's using
unless Sample.find(subsample['id'])&.authorized_for_view?
raise "Unauthorized Sample was detected in spreadsheet: #{subsample.inspect}"
end

subsample
end
obj.merge!(sample_fields[i] => parsed_excel_input_samples)
elsif sample_fields[i] == 'id'
if excel_sample[i] == ''
obj.merge!(sample_fields[i] => nil)
else
obj.merge!(sample_fields[i] => excel_sample[i]&.to_i)
end
else
obj.merge!(sample_fields[i] => excel_sample[i])
end
end
obj
end
end

def sample_type_samples(sample_type, authorization_method = nil)
if authorization_method
sample_type.samples&.authorized_for(authorization_method)&.map do |sample|
attributes = JSON.parse(sample[:json_metadata])
{ 'id' => sample.id,
'uuid' => sample.uuid }.merge(attributes)
end
else
sample_type.samples&.map do |sample|
attributes = JSON.parse(sample[:json_metadata])
{ 'id' => sample.id,
'uuid' => sample.uuid }.merge(attributes)
end
end
end

def separate_unauthorized_samples(existing_excel_samples, db_samples, authorized_db_samples)
update_samples = []
unauthorized_samples = []
existing_excel_samples.map do |ees|
db_sample = db_samples.select { |s| s['id'] == ees['id'] }.first

# An exception is raised if the ID of an existing Sample cannot be found in the DB
raise "Sample with id '#{ees['id']}' does not exist in the database. Sample upload was aborted!" if db_sample.nil?

is_authorized_for_update = authorized_db_samples.select { |s| s['id'] == ees['id'] }.any?

is_changed = false

db_sample.map do |k, v|
unless ees[k] == v
is_changed = true
break
end
end

if is_changed
if is_authorized_for_update
update_samples.append(ees)
else
unauthorized_samples.append(ees)
end
end
end
[unauthorized_samples, update_samples]
end

def separate_possible_duplicates(new_excel_samples, db_samples)
possible_duplicates = []
new_samples = []
new_excel_samples.map do |nes|
is_duplicate = true

db_samples.map do |dbs|
dbs.map do |k, v|
unless %w[id uuid].include?(k)
is_duplicate = (nes[k] == v)
break unless is_duplicate
end
end

if is_duplicate
possible_duplicates.append(nes.merge({ 'duplicate' => dbs }))
break
end
end

if db_samples.none?
new_samples.append(nes)
else
new_samples.append(nes) unless is_duplicate
end
end
[possible_duplicates, new_samples]
end

def valid_workbook?(workbook)
!((workbook.sheet_names.map do |sheet|
%w[Metadata Samples cv_ontology].include? sheet
end.include? false) && (workbook.sheets.size != 3))
end

def set_up_instance_variable
@single_page = true
end
Expand All @@ -134,8 +362,8 @@ def find_authorized_investigation
end

def check_user_logged_in
unless current_user
render json: { status: :unprocessable_entity, error: 'You must be logged in to access batch sharing permission.' }
end
return if current_user

render json: { status: :unprocessable_entity, error: 'You must be logged in to access batch sharing permission.' }
end
end
Loading
Loading