labimotion 2.2.0.rc8 → 2.2.0.rc10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labimotion/version'
4
+
5
+ module Labimotion
6
+ # User API
7
+ class UserAPI < Grape::API
8
+ helpers Labimotion::ParamHelpers
9
+ helpers Labimotion::GenericHelpers
10
+
11
+ resource :limo do
12
+ resource :users do
13
+ namespace :list do
14
+ desc 'List users by keyword'
15
+ params do
16
+ requires :name, type: String, desc: 'Keyword'
17
+ optional :limit, type: Integer, default: 5, desc: 'Limit (max 10)'
18
+ optional :type, type: String, default: 'Person', desc: 'User type'
19
+ end
20
+ get do
21
+ error!('401 Unauthorized', 401) unless current_user
22
+ return { mc: 'ss00', msg: 'Entered name too short', data: [] } if params[:name].to_s.length < 3
23
+
24
+ query = 'first_name ILIKE :q OR last_name ILIKE :q OR name ILIKE :q OR name_abbreviation ILIKE :q'
25
+ users = User.where(query, q: "%#{params[:name]}%")
26
+ .where(type: params[:type])
27
+ .limit([params[:limit].to_i, 10].min)
28
+ data = Labimotion::UserEntity.represent(users)
29
+ { mc: 'ss00', data: data }
30
+ rescue StandardError => e
31
+ Labimotion.log_exception(e, current_user)
32
+ { mc: 'se00', msg: e.message, data: [] }
33
+ end
34
+ end
35
+
36
+ desc 'Get user info by id'
37
+ params do
38
+ requires :id, type: Integer, desc: 'User id'
39
+ end
40
+ route_param :id do
41
+ get do
42
+ error!('401 Unauthorized', 401) unless current_user
43
+ user = User.find(params[:id])
44
+ data = Labimotion::UserEntity.represent(user)
45
+ { mc: 'ss00', data: data }
46
+ rescue ActiveRecord::RecordNotFound
47
+ error!('404 Not Found', 404)
48
+ rescue StandardError => e
49
+ Labimotion.log_exception(e, current_user)
50
+ { mc: 'se00', msg: e.message, data: {} }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -23,6 +23,7 @@ module Labimotion
23
23
  expose! :uuid
24
24
  expose! :user_labels
25
25
  expose! :preview_attachment # align with eln change
26
+ expose! :variations_count
26
27
  end
27
28
 
28
29
  with_options(anonymize_below: 10) do
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labimotion/entities/application_entity'
4
+
5
+ module Labimotion
6
+ class ElementVariationEntity < Labimotion::ApplicationEntity
7
+ expose :id
8
+ expose :element_id, as: :elementId
9
+ expose :variations
10
+ expose :layout
11
+
12
+ def variations
13
+ rows = object.variations_hash
14
+ rows.transform_values do |row|
15
+ next row unless row.is_a?(Hash)
16
+
17
+ row.symbolize_keys.slice(:uuid, :name, :properties, :metadata, :segments).tap do |slim|
18
+ slim[:properties] = (slim[:properties] || {})
19
+ slim[:metadata] = (slim[:metadata] || {}).slice('notes', 'analyses', 'group', :notes, :analyses, :group)
20
+ slim[:segments] = (slim[:segments] || {})
21
+ end
22
+ end
23
+ end
24
+
25
+ def layout
26
+ object.layout_hash
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labimotion/entities/application_entity'
4
+ module Labimotion
5
+ # User entity
6
+ class UserEntity < Labimotion::ApplicationEntity
7
+ expose :id, :email, :first_name, :last_name, :name, :name_abbreviation
8
+ end
9
+ end
@@ -200,7 +200,7 @@ module Labimotion
200
200
  def element_revisions(params)
201
201
  klass = Labimotion::Element.find(params[:id])
202
202
  list = klass.elements_revisions unless klass.nil?
203
- list&.order(created_at: :desc)&.limit(10)
203
+ list&.order(created_at: :desc)&.limit(params[:limit])
204
204
  rescue StandardError => e
205
205
  Labimotion.log_exception(e, current_user)
206
206
  raise e
@@ -95,7 +95,7 @@ module Labimotion
95
95
  def list_klass_revisions(params)
96
96
  klass = "Labimotion::#{params[:klass]}".constantize.find_by(id: params[:id])
97
97
  list = klass.send("#{params[:klass].underscore}es_revisions") unless klass.nil?
98
- list&.order(released_at: :desc)&.limit(10)
98
+ list&.order(released_at: :desc)&.limit(params[:limit])
99
99
  rescue StandardError => e
100
100
  Labimotion.log_exception(e, current_user)
101
101
  raise e
@@ -0,0 +1,428 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'grape'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'zip'
7
+
8
+ module Labimotion
9
+ ## MTT Helpers
10
+ module MttHelpers
11
+ extend Grape::API::Helpers
12
+
13
+ TPA_EXPIRATION = 72.hours
14
+
15
+ def token_url(dose_resp_request)
16
+ # Build the callback URL with token in path
17
+ api_base_url = ENV['PUBLIC_URL'] || 'http://172.28.156.100:3000'
18
+ callback_path = "/api/v1/public/mtt_apps/#{dose_resp_request.access_token}"
19
+ callback_url = "#{api_base_url}#{callback_path}"
20
+
21
+ callback_url
22
+ end
23
+
24
+ def get_external_app_url
25
+ ENV['MTT_EXTERNAL_APP_URL'] || 'http://localhost:4050'
26
+ end
27
+
28
+ def validate_token(token)
29
+ # Find the request by access token
30
+ request = Labimotion::DoseRespRequest.find_by(access_token: token)
31
+ error!('Token not found', 404) unless request
32
+
33
+ # Check expiration
34
+ error!('Token expired', 403) if request.expired?
35
+
36
+ # Check revocation
37
+ error!('Token revoked', 403) if request.revoked?
38
+
39
+ request
40
+ end
41
+
42
+ def validate_user_access(dose_resp_request)
43
+ # Get element and user
44
+ element = dose_resp_request.element
45
+ error!('Element not found', 404) unless element
46
+
47
+ user = dose_resp_request.creator
48
+ error!('User not found', 404) unless user
49
+
50
+ # Check user has update permission on element using ElementPolicy
51
+ policy = ElementPolicy.new(user, element)
52
+ error!('Unauthorized', 403) unless policy.update?
53
+
54
+ { element: element, user: user }
55
+ end
56
+
57
+ def download_json_to_external_app
58
+ # Get token from route params
59
+ token = params[:token]
60
+ dose_resp_request = validate_token(token)
61
+
62
+ # Validate user access
63
+ validate_user_access(dose_resp_request)
64
+
65
+ # Track access and update state
66
+ # dose_resp_request.track_access!
67
+ dose_resp_request.mark_processing! if dose_resp_request.state == Labimotion::DoseRespRequest::STATE_INITIAL
68
+ # Return wellplates metadata as JSON
69
+ # Access the wellplates array from the metadata structure
70
+ wellplates_data = dose_resp_request.wellplates_metadata&.dig('wellplates') ||
71
+ dose_resp_request.wellplates_metadata&.dig(:wellplates) ||
72
+ []
73
+
74
+ response_data = {
75
+ id: dose_resp_request.id.to_s,
76
+ request_id: dose_resp_request.request_id,
77
+ element_info: extract_element_properties(dose_resp_request.element),
78
+ wellplates: wellplates_data
79
+ }
80
+
81
+ status 200
82
+ response_data
83
+ rescue => e
84
+ error!("Error: #{e.message}", 500)
85
+ end
86
+
87
+ def upload_json_from_external_app
88
+ # Get token from route params
89
+ token = params[:token]
90
+ dose_resp_request = validate_token(token)
91
+
92
+ # Validate user access
93
+ access_info = validate_user_access(dose_resp_request)
94
+ user = access_info[:user]
95
+ element = access_info[:element]
96
+ # Handle file upload
97
+ file_param = params['file'] || params[:file]
98
+ error!('No file uploaded', 400) unless file_param && file_param.is_a?(Hash) && file_param['tempfile']
99
+
100
+ tempfile = file_param['tempfile']
101
+ filename = file_param['filename'] || 'upload'
102
+ # Check if it's a zip file
103
+ if filename.end_with?('.zip')
104
+ # Process zip file
105
+ wellplates_data, csv_data = process_zip_file(tempfile)
106
+
107
+ error!('Missing JSON data in zip file', 400) unless wellplates_data
108
+
109
+ # Create analysis container and dataset with CSV if present
110
+ # if csv_data
111
+ # create_analysis_with_csv(element, user, csv_data, dose_resp_request, wellplates_data)
112
+ # end
113
+ # else
114
+ # # Handle single JSON file
115
+ # file_content = tempfile.read
116
+ # tempfile.rewind
117
+ # json_data = JSON.parse(file_content).with_indifferent_access
118
+ # wellplates_data = json_data[:Output]
119
+
120
+ # error!('Missing wellplates data', 400) unless wellplates_data
121
+ end
122
+
123
+ dose_resp_request.track_access!
124
+
125
+ # Save output data to dose_resp_outputs table
126
+ output = dose_resp_request.dose_resp_outputs.create!(
127
+ output_data: { Output: wellplates_data }
128
+ )
129
+ if csv_data
130
+ create_analysis_with_csv(element, user, csv_data, dose_resp_request, output)
131
+ end
132
+
133
+ dose_resp_request.update!(
134
+ wellplates_metadata: { wellplates: wellplates_data },
135
+ resp_message: 'Data updated successfully'
136
+ )
137
+
138
+ # Mark as completed
139
+ dose_resp_request.mark_completed!
140
+
141
+ status 200
142
+ {
143
+ success: true,
144
+ message: 'Data updated successfully',
145
+ request_id: dose_resp_request.id
146
+ }
147
+ rescue JSON::ParserError => e
148
+ error!("Invalid JSON: #{e.message}", 400)
149
+ rescue ActiveRecord::RecordInvalid => e
150
+ dose_resp_request.mark_error!(e.message) if dose_resp_request
151
+ error!("Validation error: #{e.message}", 422)
152
+ rescue => e
153
+ dose_resp_request.mark_error!(e.message) if dose_resp_request
154
+ error!("Error: #{e.message}", 500)
155
+ end
156
+
157
+ def process_zip_file(tempfile)
158
+ wellplates_data = nil
159
+ csv_data = nil
160
+
161
+ Zip::File.open(tempfile.path) do |zip_file|
162
+ zip_file.each do |entry|
163
+ if entry.name.end_with?('.json')
164
+ # Read JSON file
165
+ json_content = entry.get_input_stream.read
166
+ json_data = JSON.parse(json_content).with_indifferent_access
167
+ wellplates_data = json_data[:Output]
168
+ elsif entry.name.end_with?('.xls', '.xlsx', '.csv')
169
+ # Read CSV/Excel file - extract just the basename without path
170
+ csv_content = entry.get_input_stream.read
171
+ csv_data = {
172
+ filename: File.basename(entry.name),
173
+ content: csv_content
174
+ }
175
+ end
176
+ end
177
+ end
178
+
179
+ [wellplates_data, csv_data]
180
+ end
181
+
182
+ def create_analysis_with_csv(element, user, csv_data, dose_resp_request, output)
183
+ analysis, dataset = create_analysis_with_dataset(
184
+ element: element,
185
+ analysis_name: "MTT Analysis #{dose_resp_request.request_id}-#{output&.id}",
186
+ dataset_name: 'new',
187
+ analysis_attributes: {}
188
+ )
189
+
190
+ # Determine content type based on file extension
191
+ content_type = case File.extname(csv_data[:filename]).downcase
192
+ when '.xlsx'
193
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
194
+ when '.xls'
195
+ 'application/vnd.ms-excel'
196
+ when '.csv'
197
+ 'text/csv'
198
+ else
199
+ 'application/octet-stream'
200
+ end
201
+
202
+ # Create a temporary file for the attachment
203
+ temp_file = Tempfile.new([File.basename(csv_data[:filename], '.*'), File.extname(csv_data[:filename])])
204
+ begin
205
+ temp_file.binmode
206
+ temp_file.write(csv_data[:content])
207
+ temp_file.rewind
208
+
209
+ # Create attachment for CSV/Excel file
210
+ attachment = Attachment.new(
211
+ filename: csv_data[:filename],
212
+ file_path: temp_file.path,
213
+ created_by: user.id,
214
+ created_for: user.id,
215
+ attachable_type: 'Container',
216
+ attachable_id: dataset.id,
217
+ content_type: content_type
218
+ )
219
+ attachment.save! if attachment.valid?
220
+
221
+ { analysis: analysis, dataset: dataset, attachment: attachment }
222
+ ensure
223
+ temp_file.close
224
+ temp_file.unlink
225
+ end
226
+ end
227
+
228
+ def create_analysis_with_dataset(
229
+ element:,
230
+ analysis_name: 'New Analysis',
231
+ dataset_name: 'New Dataset',
232
+ analysis_attributes: {}
233
+ )
234
+ # Ensure the element has a root container
235
+ ensure_root_container(element)
236
+
237
+ # Get or create the analyses container
238
+ analyses_container = element.container.children.find_or_create_by(container_type: 'analyses')
239
+
240
+ # Prepare default extended_metadata for analysis
241
+ default_metadata = {
242
+ 'content' => '{"ops":[{"insert":""}]}',
243
+ 'report' => true
244
+ }
245
+ extended_metadata = default_metadata.merge(analysis_attributes[:extended_metadata] || {})
246
+
247
+ # Create the analysis container
248
+ analysis_container = analyses_container.children.create(
249
+ container_type: 'analysis',
250
+ name: analysis_name,
251
+ description: analysis_attributes[:description] || '',
252
+ extended_metadata: extended_metadata
253
+ )
254
+
255
+ # Create the dataset container nested under the analysis
256
+ dataset_container = analysis_container.children.create(
257
+ container_type: 'dataset',
258
+ name: dataset_name,
259
+ description: '',
260
+ extended_metadata: {}
261
+ )
262
+
263
+ [analysis_container, dataset_container]
264
+ end
265
+
266
+ def ensure_root_container(element)
267
+ return if element.container.present?
268
+
269
+ element.container = Container.create_root_container
270
+ end
271
+ # def send_mtt_request(current_user, params)
272
+
273
+ # token_uri = token_url(current_user, params)
274
+
275
+
276
+ # uri = URI.parse(api_url)
277
+ # http = Net::HTTP.new(uri.host, uri.port)
278
+ # http.use_ssl = (uri.scheme == 'https')
279
+ # http.read_timeout = 30
280
+
281
+ # # Ensure path is not empty, default to '/' if needed
282
+ # path = uri.path.empty? ? '/' : uri.path
283
+ # request = Net::HTTP::Post.new(path, { 'Content-Type' => 'application/json' })
284
+ # request.body = json_data.to_json
285
+
286
+ # response = http.request(request)
287
+
288
+ # {
289
+ # success: response.is_a?(Net::HTTPSuccess),
290
+ # status: response.code,
291
+ # body: (JSON.parse(response.body) rescue response.body),
292
+ # message: response.message
293
+ # }
294
+ # rescue StandardError => e
295
+ # {
296
+ # success: false,
297
+ # error: e.message,
298
+ # backtrace: e.backtrace.first(5)
299
+ # }
300
+ # end
301
+
302
+ def extract_readout_titles(wellplate)
303
+ # Extract readout titles from wellplate
304
+ if wellplate.respond_to?(:readout_titles)
305
+ titles = wellplate.readout_titles
306
+ return titles if titles.is_a?(Array)
307
+ return JSON.parse(titles) if titles.is_a?(String)
308
+ end
309
+ []
310
+ end
311
+
312
+ def extract_wells(wellplate)
313
+ # Extract wells data from wellplate
314
+ wells = wellplate.wells || []
315
+ wells.map do |well|
316
+ # Position might be stored as a hash or separate fields
317
+ position = if well.respond_to?(:position) && well.position.is_a?(Hash)
318
+ well.position
319
+ elsif well.respond_to?(:position_x)
320
+ { x: well.position_x, y: well.position_y }
321
+ else
322
+ { x: 0, y: 0 }
323
+ end
324
+
325
+ well_data = {
326
+ id: well.id,
327
+ position: position
328
+ }
329
+
330
+ # Only include readouts if they have values
331
+ readouts = extract_readouts(well)
332
+ well_data[:readouts] = readouts if readouts.present?
333
+
334
+ # Only include sample if it exists
335
+ sample = extract_sample(well)
336
+ well_data[:sample] = sample if sample.present?
337
+
338
+ well_data
339
+ end
340
+ end
341
+
342
+ def extract_readouts(well)
343
+ # Extract readouts from well
344
+ # Readouts are typically stored as JSON data in the well
345
+ readouts = if well.respond_to?(:readouts) && well.readouts.is_a?(Array)
346
+ well.readouts
347
+ elsif well.respond_to?(:readouts) && well.readouts.is_a?(String)
348
+ JSON.parse(well.readouts) rescue []
349
+ elsif well.respond_to?(:readouts) && well.readouts.is_a?(Hash)
350
+ well.readouts.values rescue []
351
+ else
352
+ []
353
+ end
354
+
355
+ # Filter out empty readouts (both unit and value are blank)
356
+ readouts.select do |readout|
357
+ readout.is_a?(Hash) &&
358
+ (readout['unit'].to_s.present? || readout['value'].to_s.present? ||
359
+ readout[:unit].to_s.present? || readout[:value].to_s.present?)
360
+ end
361
+ end
362
+
363
+ def extract_sample(well)
364
+ # Extract sample information from well
365
+ if well.respond_to?(:sample) && well.sample.present?
366
+ sample = well.sample
367
+ return {
368
+ id: sample.id,
369
+ short_label: sample.short_label,
370
+ conc: sample.try(:molarity_value) || 0
371
+ }
372
+ end
373
+ nil
374
+ end
375
+
376
+ def generate_wellplates_metadata(wellplates)
377
+ wellplates.map do |wellplate|
378
+ {
379
+ id: wellplate.id.to_s,
380
+ readoutTitles: extract_readout_titles(wellplate),
381
+ wells: extract_wells(wellplate)
382
+ }
383
+ end
384
+ end
385
+
386
+ def extract_element_properties(element)
387
+ props = {
388
+ id: element.id.to_s,
389
+ name: element.name
390
+ }
391
+
392
+ layers = element.properties.dig('layers', 'general_information', 'fields') ||
393
+ element.properties.dig(:layers, :general_information, :fields) || []
394
+
395
+ endpoint_field = layers.find { |f| f['field'] == 'Endpoint' || f[:field] == 'Endpoint' }
396
+ props[:endpoint] = endpoint_field['value'] || endpoint_field[:value] if endpoint_field
397
+
398
+ props
399
+ end
400
+
401
+
402
+ def generate_element_metadata(element)
403
+ {
404
+ id: wellplate.id.to_s,
405
+ readoutTitles: extract_readout_titles(wellplate),
406
+ wells: extract_wells(wellplate)
407
+ }
408
+ end
409
+
410
+
411
+ def generate_json_data(wellplates)
412
+ # # Generate wellplates metadata
413
+ wellplates_metadata = generate_wellplates_metadata(wellplates)
414
+
415
+ # Generate JSON structure
416
+ json_data = {
417
+ id: element.id.to_s,
418
+ request_id: dose_resp_request.id.to_s,
419
+ wellplates: wellplates_metadata
420
+ }
421
+ # Save to JSON file
422
+ filename = "mtt_request_#{element.id}_#{dose_resp_request.id}.json"
423
+ filepath = Rails.root.join('tmp', filename)
424
+ File.write(filepath, JSON.pretty_generate(json_data))
425
+ json_data
426
+ end
427
+ end
428
+ end
@@ -146,5 +146,11 @@ module Labimotion
146
146
  requires :layer_id, type: String, desc: 'layer identifier'
147
147
  requires :field_id, type: String, desc: 'field identifier'
148
148
  end
149
+
150
+
151
+ params :create_mtt_request_params do
152
+ requires :id, type: Integer, desc: 'Element ID'
153
+ requires :wellplate_ids, type: Array, desc: 'Selected Wellplates'
154
+ end
149
155
  end
150
156
  end