labimotion 2.1.0.rc17 → 2.2.0.rc2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fe076ca6f32c99ea8a3fed2728d3f3f35e7d6ca48692a350ae07eddef5d7453
4
- data.tar.gz: aa98768282d9e3962f35444d97ce4c8b4165b864cef4d5a8c49d0d5e7fdd0222
3
+ metadata.gz: 201e0597dc0d1a272ec2a74ffd387add0788c96cf31605c32ab1105c8c88fe40
4
+ data.tar.gz: 0bb95660dda6cb62c0449b80c0f73b1cae37e21490c671519dd53065c74b968e
5
5
  SHA512:
6
- metadata.gz: c7422991be491176c1bfee14269f7f3a473d0c002a5339e74ca17a3fa5157441cca59d772c6792584c5ec9fdc6f729296b7bf8aabd5af37549fc88fc33c8913c
7
- data.tar.gz: 8d3accbf6aa727559b7725bb99b14687f06a258c720732dc28278c8c305f1a0aaf48c1b828f81d55b6c96f04fab79de06b71726393bc602057e02dcb42f17c53
6
+ metadata.gz: 3cab6e2a1d8405d9dc53e7fe352c1bcd28ac7c2af742a1deec806e15895440d502921e15426d85f181835a241fb84e3c17605637046aae428d19b5e6e65b5905
7
+ data.tar.gz: 66aad0cd9770ff0db4b6d17b6224b997ed67726517416f100c24edeb5bbe989d79ca0b54028909529e9eca24dc88ce81a967f452c7198dbaa6cc725de1d57e1c
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labimotion/version'
4
+
5
+ module Labimotion
6
+ # Dose Response Request API
7
+ class DoseRespRequestAPI < Grape::API
8
+ helpers Labimotion::ParamHelpers
9
+
10
+ resource :dose_resp_requests do
11
+ desc 'Get all dose response requests for current user'
12
+ params do
13
+ optional :element_id, type: Integer, desc: 'Filter by element ID'
14
+ optional :state, type: Integer, desc: 'Filter by state (-1, 0, 1, 2)'
15
+ optional :page, type: Integer, desc: 'Page number', default: 1
16
+ optional :per_page, type: Integer, desc: 'Items per page', default: 20
17
+ end
18
+ get do
19
+ requests = Labimotion::DoseRespRequest.where(created_by: current_user.id)
20
+
21
+ # Apply filters
22
+ requests = requests.where(element_id: params[:element_id]) if params[:element_id]
23
+ requests = requests.where(state: params[:state]) if params[:state]
24
+
25
+ # Pagination
26
+ page = params[:page] || 1
27
+ per_page = [params[:per_page] || 20, 100].min # Max 100 per page
28
+
29
+ total = requests.count
30
+ requests = requests.order(created_at: :desc)
31
+ .offset((page - 1) * per_page)
32
+ .limit(per_page)
33
+
34
+ {
35
+ requests: requests.map do |req|
36
+ {
37
+ id: req.id,
38
+ request_id: req.request_id,
39
+ element_id: req.element_id,
40
+ state: req.state,
41
+ state_label: state_label(req.state),
42
+ expires_at: req.expires_at,
43
+ created_at: req.created_at,
44
+ updated_at: req.updated_at,
45
+ first_accessed_at: req.first_accessed_at,
46
+ last_accessed_at: req.last_accessed_at,
47
+ access_count: req.access_count,
48
+ resp_message: req.resp_message,
49
+ active: req.active?
50
+ }
51
+ end,
52
+ pagination: {
53
+ page: page,
54
+ per_page: per_page,
55
+ total: total,
56
+ total_pages: (total.to_f / per_page).ceil
57
+ }
58
+ }
59
+ rescue StandardError => e
60
+ error!("Error: #{e.message}", 500)
61
+ end
62
+
63
+ desc 'Get a dose response request by ID'
64
+ params do
65
+ requires :id, type: Integer, desc: 'Request ID'
66
+ end
67
+ get ':id' do
68
+ request = Labimotion::DoseRespRequest.find_by(id: params[:id])
69
+ error!('Request not found', 404) unless request
70
+
71
+ # Check authorization
72
+ error!('Unauthorized', 403) unless request.created_by == current_user.id
73
+
74
+ {
75
+ id: request.id,
76
+ request_id: request.request_id,
77
+ element_id: request.element_id,
78
+ state: request.state,
79
+ state_label: state_label(request.state),
80
+ wellplates_metadata: request.wellplates_metadata,
81
+ input_metadata: request.input_metadata,
82
+ expires_at: request.expires_at,
83
+ revoked_at: request.revoked_at,
84
+ created_at: request.created_at,
85
+ updated_at: request.updated_at,
86
+ first_accessed_at: request.first_accessed_at,
87
+ last_accessed_at: request.last_accessed_at,
88
+ access_count: request.access_count,
89
+ resp_message: request.resp_message,
90
+ active: request.active?,
91
+ expired: request.expired?,
92
+ revoked: request.revoked?
93
+ }
94
+ rescue StandardError => e
95
+ error!("Error: #{e.message}", 500)
96
+ end
97
+
98
+ desc 'Update a dose response request'
99
+ params do
100
+ requires :id, type: Integer, desc: 'Request ID'
101
+ optional :state, type: Integer, desc: 'State', values: [-1, 0, 1, 2]
102
+ optional :resp_message, type: String, desc: 'Response message'
103
+ optional :wellplates_metadata, type: Hash, desc: 'Wellplates metadata'
104
+ end
105
+ put ':id' do
106
+ request = Labimotion::DoseRespRequest.find_by(id: params[:id])
107
+ error!('Request not found', 404) unless request
108
+
109
+ # Check authorization
110
+ error!('Unauthorized', 403) unless request.created_by == current_user.id
111
+
112
+ update_params = {}
113
+ update_params[:state] = params[:state] if params[:state]
114
+ update_params[:resp_message] = params[:resp_message] if params[:resp_message]
115
+ update_params[:wellplates_metadata] = params[:wellplates_metadata] if params[:wellplates_metadata]
116
+
117
+ request.update!(update_params)
118
+
119
+ {
120
+ success: true,
121
+ message: 'Request updated successfully',
122
+ request: {
123
+ id: request.id,
124
+ request_id: request.request_id,
125
+ state: request.state,
126
+ state_label: state_label(request.state),
127
+ resp_message: request.resp_message,
128
+ updated_at: request.updated_at
129
+ }
130
+ }
131
+ rescue ActiveRecord::RecordInvalid => e
132
+ error!("Validation error: #{e.message}", 422)
133
+ rescue StandardError => e
134
+ error!("Error: #{e.message}", 500)
135
+ end
136
+
137
+ desc 'Revoke a dose response request'
138
+ params do
139
+ requires :id, type: Integer, desc: 'Request ID'
140
+ end
141
+ post ':id/revoke' do
142
+ request = Labimotion::DoseRespRequest.find_by(id: params[:id])
143
+ error!('Request not found', 404) unless request
144
+
145
+ # Check authorization
146
+ error!('Unauthorized', 403) unless request.created_by == current_user.id
147
+
148
+ request.revoke!
149
+
150
+ {
151
+ success: true,
152
+ message: 'Request revoked successfully',
153
+ request: {
154
+ id: request.id,
155
+ request_id: request.request_id,
156
+ state: request.state,
157
+ revoked_at: request.revoked_at,
158
+ active: request.active?
159
+ }
160
+ }
161
+ rescue StandardError => e
162
+ error!("Error: #{e.message}", 500)
163
+ end
164
+
165
+ desc 'Delete a dose response request'
166
+ params do
167
+ requires :id, type: Integer, desc: 'Request ID'
168
+ end
169
+ delete ':id' do
170
+ request = Labimotion::DoseRespRequest.find_by(id: params[:id])
171
+ error!('Request not found', 404) unless request
172
+
173
+ # Check authorization
174
+ error!('Unauthorized', 403) unless request.created_by == current_user.id
175
+
176
+ # Soft delete if acts_as_paranoid is enabled
177
+ request.destroy
178
+
179
+ {
180
+ success: true,
181
+ message: 'Request deleted successfully'
182
+ }
183
+ rescue StandardError => e
184
+ error!("Error: #{e.message}", 500)
185
+ end
186
+
187
+ desc 'Get dose response requests by element ID'
188
+ params do
189
+ requires :element_id, type: Integer, desc: 'Element ID'
190
+ end
191
+ get 'by_element/:element_id' do
192
+ element = Labimotion::Element.find_by(id: params[:element_id])
193
+ error!('Element not found', 404) unless element
194
+
195
+ # Check if user has access to element
196
+ policy = ElementPolicy.new(current_user, element)
197
+ error!('Unauthorized', 403) unless policy.read?
198
+
199
+ requests = Labimotion::DoseRespRequest.where(element_id: params[:element_id])
200
+ .where(created_by: current_user.id)
201
+ .order(created_at: :desc)
202
+
203
+ {
204
+ element_id: element.id,
205
+ element_name: element.name,
206
+ requests: requests.map do |req|
207
+ {
208
+ id: req.id,
209
+ request_id: req.request_id,
210
+ state: req.state,
211
+ state_label: state_label(req.state),
212
+ expires_at: req.expires_at,
213
+ created_at: req.created_at,
214
+ access_count: req.access_count,
215
+ active: req.active?
216
+ }
217
+ end
218
+ }
219
+ rescue StandardError => e
220
+ error!("Error: #{e.message}", 500)
221
+ end
222
+ end
223
+
224
+ helpers do
225
+ def state_label(state)
226
+ case state
227
+ when Labimotion::DoseRespRequest::STATE_ERROR
228
+ 'error'
229
+ when Labimotion::DoseRespRequest::STATE_INITIAL
230
+ 'initial'
231
+ when Labimotion::DoseRespRequest::STATE_PROCESSING
232
+ 'processing'
233
+ when Labimotion::DoseRespRequest::STATE_COMPLETED
234
+ 'completed'
235
+ else
236
+ 'unknown'
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -12,5 +12,7 @@ module Labimotion
12
12
  mount Labimotion::LabimotionHubAPI
13
13
  mount Labimotion::StandardLayerAPI
14
14
  mount Labimotion::VocabularyAPI
15
+ mount Labimotion::MttAPI
16
+ mount Labimotion::DoseRespRequestAPI
15
17
  end
16
18
  end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labimotion/version'
4
+ require 'labimotion/libs/export_element'
5
+ require 'labimotion/helpers/mtt_helpers'
6
+ require 'labimotion/models/dose_resp_output'
7
+
8
+ module Labimotion
9
+ # Generic Element API
10
+ class MttAPI < Grape::API
11
+ helpers Labimotion::ParamHelpers
12
+ helpers Labimotion::MttHelpers
13
+
14
+ namespace :public do
15
+ resource :mtt_apps do
16
+ route_param :token do
17
+ desc 'Download data from MTT app (GET endpoint)'
18
+ get do
19
+ download_json_to_external_app
20
+ end
21
+
22
+ desc 'Upload modified data from MTT app (POST endpoint)'
23
+ post do
24
+ upload_json_from_external_app
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ resource :mtt do
31
+ namespace :requests do
32
+ desc 'Get MTT requests for current user'
33
+ get do
34
+ # Get all requests created by current user
35
+ requests = Labimotion::DoseRespRequest
36
+ .includes(:dose_resp_outputs)
37
+ .where(created_by: current_user.id)
38
+ .order(created_at: :desc)
39
+
40
+ # Return formatted response
41
+ requests.map do |req|
42
+ {
43
+ id: req.id,
44
+ request_id: req.request_id,
45
+ element_id: req.element_id,
46
+ state: req.state,
47
+ state_name: case req.state
48
+ when Labimotion::DoseRespRequest::STATE_ERROR then 'error'
49
+ when Labimotion::DoseRespRequest::STATE_INITIAL then 'initial'
50
+ when Labimotion::DoseRespRequest::STATE_PROCESSING then 'processing'
51
+ when Labimotion::DoseRespRequest::STATE_COMPLETED then 'completed'
52
+ else 'unknown'
53
+ end,
54
+ created_at: req.created_at,
55
+ expires_at: req.expires_at,
56
+ expired: req.expired?,
57
+ revoked: req.revoked?,
58
+ active: req.active?,
59
+ resp_message: req.resp_message,
60
+ last_accessed_at: req.last_accessed_at,
61
+ access_count: req.access_count || 0,
62
+ outputs: req.dose_resp_outputs.map do |output|
63
+ {
64
+ id: output.id,
65
+ output_data: output.output_data,
66
+ notes: output.notes,
67
+ created_at: output.created_at
68
+ }
69
+ end
70
+ }
71
+ end
72
+ end
73
+ end
74
+
75
+ namespace :create_mtt_request do
76
+ desc 'Create MTT assay request'
77
+ params do
78
+ use :create_mtt_request_params
79
+ end
80
+ post do
81
+ # Find element and wellplates
82
+ element = Labimotion::Element.find_by(id: params[:id])
83
+ error!('Element not found', 404) unless element
84
+ #byebug
85
+ # Verify user has update permission
86
+ error!('Unauthorized', 403) unless ElementPolicy.new(current_user, element).update?
87
+
88
+ wellplates = Wellplate.where(id: params[:wellplate_ids])
89
+ error!('No wellplates found', 404) if wellplates.empty?
90
+
91
+ # Generate wellplates metadata
92
+ wellplates_metadata = generate_wellplates_metadata(wellplates)
93
+
94
+ # Create DoseRespRequest record with token
95
+ dose_resp_request = Labimotion::DoseRespRequest.create!(
96
+ element_id: element.id,
97
+ wellplates_metadata: { wellplates: wellplates_metadata },
98
+ input_metadata: {
99
+ wellplate_ids: params[:wellplate_ids],
100
+ element_id: element.id,
101
+ element_name: element.name,
102
+ created_at: Time.current
103
+ },
104
+ state: Labimotion::DoseRespRequest::STATE_INITIAL,
105
+ created_by: current_user&.id,
106
+ expires_at: TPA_EXPIRATION.from_now
107
+ )
108
+
109
+ # Generate external app URL with token
110
+ token_uri = token_url(dose_resp_request)
111
+ external_app_url = get_external_app_url
112
+
113
+ # Format: "#{@app.url}?url=#{CGI.escape(token_uri)}&type=ThirdPartyApp"
114
+ "#{external_app_url}?method=DoseResponse&url=#{CGI.escape(token_uri)}"
115
+ rescue ActiveRecord::RecordInvalid => e
116
+ error!("Validation error: #{e.message}", 422)
117
+ rescue ActiveRecord::RecordNotFound => e
118
+ error!("Not found: #{e.message}", 404)
119
+ rescue StandardError => e
120
+ error!("Error: #{e.message}", 500)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -13,9 +13,7 @@ module Labimotion
13
13
  private
14
14
 
15
15
  def process_layers
16
- return unless object&.properties.is_a?(Hash)
17
-
18
- (object.properties[Labimotion::Prop::LAYERS]&.keys || []).each do |key|
16
+ (object&.properties.is_a?(Hash) && (object.properties[Labimotion::Prop::LAYERS]&.keys || [])).each do |key|
19
17
  yield(key, object.properties[Labimotion::Prop::LAYERS][key])
20
18
  end
21
19
  end
@@ -0,0 +1,403 @@
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
+ wellplates: wellplates_data
78
+ }
79
+
80
+ status 200
81
+ response_data
82
+ rescue => e
83
+ error!("Error: #{e.message}", 500)
84
+ end
85
+
86
+ def upload_json_from_external_app
87
+ # Get token from route params
88
+ token = params[:token]
89
+ dose_resp_request = validate_token(token)
90
+
91
+ # Validate user access
92
+ access_info = validate_user_access(dose_resp_request)
93
+ user = access_info[:user]
94
+ element = access_info[:element]
95
+ # Handle file upload
96
+ file_param = params['file'] || params[:file]
97
+ error!('No file uploaded', 400) unless file_param && file_param.is_a?(Hash) && file_param['tempfile']
98
+
99
+ tempfile = file_param['tempfile']
100
+ filename = file_param['filename'] || 'upload'
101
+ # Check if it's a zip file
102
+ if filename.end_with?('.zip')
103
+ # Process zip file
104
+ wellplates_data, csv_data = process_zip_file(tempfile)
105
+
106
+ error!('Missing JSON data in zip file', 400) unless wellplates_data
107
+
108
+ # Create analysis container and dataset with CSV if present
109
+ # if csv_data
110
+ # create_analysis_with_csv(element, user, csv_data, dose_resp_request, wellplates_data)
111
+ # end
112
+ # else
113
+ # # Handle single JSON file
114
+ # file_content = tempfile.read
115
+ # tempfile.rewind
116
+ # json_data = JSON.parse(file_content).with_indifferent_access
117
+ # wellplates_data = json_data[:Output]
118
+
119
+ # error!('Missing wellplates data', 400) unless wellplates_data
120
+ end
121
+
122
+ dose_resp_request.track_access!
123
+
124
+ # Save output data to dose_resp_outputs table
125
+ output = dose_resp_request.dose_resp_outputs.create!(
126
+ output_data: { Output: wellplates_data }
127
+ )
128
+ if csv_data
129
+ create_analysis_with_csv(element, user, csv_data, dose_resp_request, output)
130
+ end
131
+
132
+ dose_resp_request.update!(
133
+ wellplates_metadata: { wellplates: wellplates_data },
134
+ resp_message: 'Data updated successfully'
135
+ )
136
+
137
+ # Mark as completed
138
+ dose_resp_request.mark_completed!
139
+
140
+ status 200
141
+ {
142
+ success: true,
143
+ message: 'Data updated successfully',
144
+ request_id: dose_resp_request.id
145
+ }
146
+ rescue JSON::ParserError => e
147
+ error!("Invalid JSON: #{e.message}", 400)
148
+ rescue ActiveRecord::RecordInvalid => e
149
+ dose_resp_request.mark_error!(e.message) if dose_resp_request
150
+ error!("Validation error: #{e.message}", 422)
151
+ rescue => e
152
+ dose_resp_request.mark_error!(e.message) if dose_resp_request
153
+ error!("Error: #{e.message}", 500)
154
+ end
155
+
156
+ def process_zip_file(tempfile)
157
+ wellplates_data = nil
158
+ csv_data = nil
159
+
160
+ Zip::File.open(tempfile.path) do |zip_file|
161
+ zip_file.each do |entry|
162
+ if entry.name.end_with?('.json')
163
+ # Read JSON file
164
+ json_content = entry.get_input_stream.read
165
+ json_data = JSON.parse(json_content).with_indifferent_access
166
+ wellplates_data = json_data[:Output]
167
+ elsif entry.name.end_with?('.xls', '.xlsx', '.csv')
168
+ # Read CSV/Excel file - extract just the basename without path
169
+ csv_content = entry.get_input_stream.read
170
+ csv_data = {
171
+ filename: File.basename(entry.name),
172
+ content: csv_content
173
+ }
174
+ end
175
+ end
176
+ end
177
+
178
+ [wellplates_data, csv_data]
179
+ end
180
+
181
+ def create_analysis_with_csv(element, user, csv_data, dose_resp_request, output)
182
+ analysis, dataset = create_analysis_with_dataset(
183
+ element: element,
184
+ analysis_name: "MTT Analysis #{dose_resp_request.request_id}-#{output&.id}",
185
+ dataset_name: 'new',
186
+ analysis_attributes: {}
187
+ )
188
+
189
+ # Determine content type based on file extension
190
+ content_type = case File.extname(csv_data[:filename]).downcase
191
+ when '.xlsx'
192
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
193
+ when '.xls'
194
+ 'application/vnd.ms-excel'
195
+ when '.csv'
196
+ 'text/csv'
197
+ else
198
+ 'application/octet-stream'
199
+ end
200
+
201
+ # Create a temporary file for the attachment
202
+ temp_file = Tempfile.new([File.basename(csv_data[:filename], '.*'), File.extname(csv_data[:filename])])
203
+ begin
204
+ temp_file.binmode
205
+ temp_file.write(csv_data[:content])
206
+ temp_file.rewind
207
+
208
+ # Create attachment for CSV/Excel file
209
+ attachment = Attachment.new(
210
+ filename: csv_data[:filename],
211
+ file_path: temp_file.path,
212
+ created_by: user.id,
213
+ created_for: user.id,
214
+ attachable_type: 'Container',
215
+ attachable_id: dataset.id,
216
+ content_type: content_type
217
+ )
218
+ attachment.save! if attachment.valid?
219
+
220
+ { analysis: analysis, dataset: dataset, attachment: attachment }
221
+ ensure
222
+ temp_file.close
223
+ temp_file.unlink
224
+ end
225
+ end
226
+
227
+ def create_analysis_with_dataset(
228
+ element:,
229
+ analysis_name: 'New Analysis',
230
+ dataset_name: 'New Dataset',
231
+ analysis_attributes: {}
232
+ )
233
+ # Ensure the element has a root container
234
+ ensure_root_container(element)
235
+
236
+ # Get or create the analyses container
237
+ analyses_container = element.container.children.find_or_create_by(container_type: 'analyses')
238
+
239
+ # Prepare default extended_metadata for analysis
240
+ default_metadata = {
241
+ 'content' => '{"ops":[{"insert":""}]}',
242
+ 'report' => true
243
+ }
244
+ extended_metadata = default_metadata.merge(analysis_attributes[:extended_metadata] || {})
245
+
246
+ # Create the analysis container
247
+ analysis_container = analyses_container.children.create(
248
+ container_type: 'analysis',
249
+ name: analysis_name,
250
+ description: analysis_attributes[:description] || '',
251
+ extended_metadata: extended_metadata
252
+ )
253
+
254
+ # Create the dataset container nested under the analysis
255
+ dataset_container = analysis_container.children.create(
256
+ container_type: 'dataset',
257
+ name: dataset_name,
258
+ description: '',
259
+ extended_metadata: {}
260
+ )
261
+
262
+ [analysis_container, dataset_container]
263
+ end
264
+
265
+ def ensure_root_container(element)
266
+ return if element.container.present?
267
+
268
+ element.container = Container.create_root_container
269
+ end
270
+ # def send_mtt_request(current_user, params)
271
+
272
+ # token_uri = token_url(current_user, params)
273
+
274
+
275
+ # uri = URI.parse(api_url)
276
+ # http = Net::HTTP.new(uri.host, uri.port)
277
+ # http.use_ssl = (uri.scheme == 'https')
278
+ # http.read_timeout = 30
279
+
280
+ # # Ensure path is not empty, default to '/' if needed
281
+ # path = uri.path.empty? ? '/' : uri.path
282
+ # request = Net::HTTP::Post.new(path, { 'Content-Type' => 'application/json' })
283
+ # request.body = json_data.to_json
284
+
285
+ # response = http.request(request)
286
+
287
+ # {
288
+ # success: response.is_a?(Net::HTTPSuccess),
289
+ # status: response.code,
290
+ # body: (JSON.parse(response.body) rescue response.body),
291
+ # message: response.message
292
+ # }
293
+ # rescue StandardError => e
294
+ # {
295
+ # success: false,
296
+ # error: e.message,
297
+ # backtrace: e.backtrace.first(5)
298
+ # }
299
+ # end
300
+
301
+ def extract_readout_titles(wellplate)
302
+ # Extract readout titles from wellplate
303
+ if wellplate.respond_to?(:readout_titles)
304
+ titles = wellplate.readout_titles
305
+ return titles if titles.is_a?(Array)
306
+ return JSON.parse(titles) if titles.is_a?(String)
307
+ end
308
+ []
309
+ end
310
+
311
+ def extract_wells(wellplate)
312
+ # Extract wells data from wellplate
313
+ wells = wellplate.wells || []
314
+ wells.map do |well|
315
+ # Position might be stored as a hash or separate fields
316
+ position = if well.respond_to?(:position) && well.position.is_a?(Hash)
317
+ well.position
318
+ elsif well.respond_to?(:position_x)
319
+ { x: well.position_x, y: well.position_y }
320
+ else
321
+ { x: 0, y: 0 }
322
+ end
323
+
324
+ well_data = {
325
+ id: well.id,
326
+ position: position
327
+ }
328
+
329
+ # Only include readouts if they have values
330
+ readouts = extract_readouts(well)
331
+ well_data[:readouts] = readouts if readouts.present?
332
+
333
+ # Only include sample if it exists
334
+ sample = extract_sample(well)
335
+ well_data[:sample] = sample if sample.present?
336
+
337
+ well_data
338
+ end
339
+ end
340
+
341
+ def extract_readouts(well)
342
+ # Extract readouts from well
343
+ # Readouts are typically stored as JSON data in the well
344
+ readouts = if well.respond_to?(:readouts) && well.readouts.is_a?(Array)
345
+ well.readouts
346
+ elsif well.respond_to?(:readouts) && well.readouts.is_a?(String)
347
+ JSON.parse(well.readouts) rescue []
348
+ elsif well.respond_to?(:readouts) && well.readouts.is_a?(Hash)
349
+ well.readouts.values rescue []
350
+ else
351
+ []
352
+ end
353
+
354
+ # Filter out empty readouts (both unit and value are blank)
355
+ readouts.select do |readout|
356
+ readout.is_a?(Hash) &&
357
+ (readout['unit'].to_s.present? || readout['value'].to_s.present? ||
358
+ readout[:unit].to_s.present? || readout[:value].to_s.present?)
359
+ end
360
+ end
361
+
362
+ def extract_sample(well)
363
+ # Extract sample information from well
364
+ if well.respond_to?(:sample) && well.sample.present?
365
+ sample = well.sample
366
+ return {
367
+ id: sample.id,
368
+ short_label: sample.short_label,
369
+ conc: sample.try(:molarity_value) || 0
370
+ }
371
+ end
372
+ nil
373
+ end
374
+
375
+ def generate_wellplates_metadata(wellplates)
376
+ wellplates.map do |wellplate|
377
+ {
378
+ id: wellplate.id.to_s,
379
+ readoutTitles: extract_readout_titles(wellplate),
380
+ wells: extract_wells(wellplate)
381
+ }
382
+ end
383
+ end
384
+
385
+
386
+ def generate_json_data(wellplates)
387
+ # # Generate wellplates metadata
388
+ wellplates_metadata = generate_wellplates_metadata(wellplates)
389
+
390
+ # Generate JSON structure
391
+ json_data = {
392
+ id: element.id.to_s,
393
+ request_id: dose_resp_request.id.to_s,
394
+ wellplates: wellplates_metadata
395
+ }
396
+ # Save to JSON file
397
+ filename = "mtt_request_#{element.id}_#{dose_resp_request.id}.json"
398
+ filepath = Rails.root.join('tmp', filename)
399
+ File.write(filepath, JSON.pretty_generate(json_data))
400
+ json_data
401
+ end
402
+ end
403
+ 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
@@ -154,13 +154,12 @@ module Labimotion
154
154
  ofile = Rails.root.join(data[:f], data[:a].filename)
155
155
  FileUtils.cp(data[:a].attachment_url, ofile)
156
156
 
157
- File.open(ofile, 'rb') do |f|
157
+ File.open(ofile, 'r') do |f|
158
158
  body = { file: f }
159
159
  response = HTTParty.post(
160
160
  uri('conversions'),
161
161
  basic_auth: auth,
162
162
  body: body,
163
- multipart: true,
164
163
  timeout: timeout,
165
164
  )
166
165
  end
@@ -307,13 +306,12 @@ module Labimotion
307
306
 
308
307
  def self.create_tables(tmpfile)
309
308
  res = {}
310
- File.open(tmpfile.path, 'rb') do |file|
309
+ File.open(tmpfile.path, 'r') do |file|
311
310
  body = { file: file }
312
311
  response = HTTParty.post(
313
312
  uri('tables'),
314
313
  basic_auth: auth,
315
314
  body: body,
316
- multipart: true,
317
315
  timeout: timeout,
318
316
  )
319
317
  res = response.parsed_response
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ class DoseRespOutput < ApplicationRecord
5
+ acts_as_paranoid
6
+ self.table_name = :dose_resp_outputs
7
+
8
+ # Associations
9
+ belongs_to :dose_resp_request, class_name: 'Labimotion::DoseRespRequest'
10
+
11
+ # Validations
12
+ validates :dose_resp_request, presence: true
13
+ validates :output_data, presence: true
14
+ validate :output_data_is_hash
15
+
16
+ # Scopes
17
+ scope :recent, -> { order(created_at: :desc) }
18
+
19
+ private
20
+
21
+ def output_data_is_hash
22
+ return if output_data.is_a?(Hash)
23
+
24
+ errors.add(:output_data, 'must be a Hash')
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ class DoseRespRequest < ApplicationRecord
5
+ acts_as_paranoid
6
+ self.table_name = :dose_resp_requests
7
+
8
+ # Token generation
9
+ has_secure_token :access_token
10
+
11
+ # Callbacks
12
+ before_create :generate_request_id
13
+
14
+ # Associations
15
+ belongs_to :element, class_name: 'Labimotion::Element'
16
+ belongs_to :creator, foreign_key: :created_by, class_name: 'User'
17
+ has_many :dose_resp_outputs, class_name: 'Labimotion::DoseRespOutput', dependent: :destroy
18
+
19
+ # Validations
20
+ validates :element, presence: true
21
+ validates :creator, presence: true
22
+ validates :expires_at, presence: true
23
+ validates :state, inclusion: { in: [-1, 0, 1, 2] }
24
+ validate :metadata_is_hash
25
+ validate :input_metadata_is_hash
26
+
27
+ # State constants
28
+ STATE_ERROR = -1
29
+ STATE_INITIAL = 0
30
+ STATE_PROCESSING = 1
31
+ STATE_COMPLETED = 2
32
+
33
+ # Scopes
34
+ scope :active, -> { where('expires_at > ?', Time.current).where(revoked_at: nil) }
35
+ scope :expired, -> { where('expires_at <= ?', Time.current) }
36
+ scope :revoked, -> { where.not(revoked_at: nil) }
37
+
38
+ # Instance methods
39
+ def expired?
40
+ expires_at.present? && expires_at < Time.current
41
+ end
42
+
43
+ def revoked?
44
+ revoked_at.present?
45
+ end
46
+
47
+ def active?
48
+ !expired? && !revoked?
49
+ end
50
+
51
+ def revoke!
52
+ update!(revoked_at: Time.current, state: STATE_ERROR)
53
+ end
54
+
55
+ def mark_processing!
56
+ update!(state: STATE_PROCESSING) if state == STATE_INITIAL
57
+ end
58
+
59
+ def mark_completed!
60
+ update!(state: STATE_COMPLETED)
61
+ end
62
+
63
+ def mark_error!(message = nil)
64
+ update!(state: STATE_ERROR, resp_message: message)
65
+ end
66
+
67
+ def track_access!
68
+ increment!(:access_count)
69
+ update_columns(
70
+ first_accessed_at: first_accessed_at || Time.current,
71
+ last_accessed_at: Time.current
72
+ )
73
+ end
74
+
75
+ private
76
+
77
+ def metadata_is_hash
78
+ return if wellplates_metadata.nil? || wellplates_metadata.is_a?(Hash)
79
+
80
+ errors.add(:wellplates_metadata, 'must be a hash')
81
+ end
82
+
83
+ def input_metadata_is_hash
84
+ return if input_metadata.nil? || input_metadata.is_a?(Hash)
85
+
86
+ errors.add(:input_metadata, 'must be a hash')
87
+ end
88
+
89
+ def generate_request_id
90
+ self.request_id = "MTT-#{Time.current.strftime('%Y%m%d-%H%M%S')}-#{SecureRandom.hex(3)}"
91
+ end
92
+ end
93
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.1.0.rc17'
5
+ VERSION = '2.2.0.rc2'
6
6
  end
data/lib/labimotion.rb CHANGED
@@ -27,6 +27,8 @@ module Labimotion
27
27
  autoload :ExporterAPI, 'labimotion/apis/exporter_api'
28
28
  autoload :StandardLayerAPI, 'labimotion/apis/standard_layer_api'
29
29
  autoload :VocabularyAPI, 'labimotion/apis/vocabulary_api'
30
+ autoload :MttAPI, 'labimotion/apis/mtt_api'
31
+ autoload :DoseRespRequestAPI, 'labimotion/apis/dose_resp_request_api'
30
32
 
31
33
  ######## Entities
32
34
  autoload :PropertiesEntity, 'labimotion/entities/properties_entity'
@@ -114,6 +116,8 @@ module Labimotion
114
116
  autoload :StdLayersRevision, 'labimotion/models/std_layers_revision'
115
117
 
116
118
  autoload :DeviceDescription, 'labimotion/models/device_description'
119
+ autoload :DoseRespRequest, 'labimotion/models/dose_resp_request'
120
+ autoload :DoseRespOutput, 'labimotion/models/dose_resp_output'
117
121
  autoload :Reaction, 'labimotion/models/reaction'
118
122
  autoload :ResearchPlan, 'labimotion/models/research_plan'
119
123
  autoload :Sample, 'labimotion/models/sample'
@@ -126,6 +130,6 @@ module Labimotion
126
130
  autoload :ElementFetchable, 'labimotion/models/concerns/element_fetchable'
127
131
  autoload :Segmentable, 'labimotion/models/concerns/segmentable'
128
132
  autoload :Datasetable, 'labimotion/models/concerns/datasetable'
129
- autoload :AttachmentConverter, 'labimotion/models/concerns/attachment_converter.rb'
133
+ autoload :AttachmentConverter, 'labimotion/models/concerns/attachment_converter'
130
134
  autoload :LinkedProperties, 'labimotion/models/concerns/linked_properties'
131
135
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: labimotion
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0.rc17
4
+ version: 2.2.0.rc2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chia-Lin Lin
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-01-23 00:00:00.000000000 Z
12
+ date: 2026-01-29 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: caxlsx
@@ -55,12 +55,14 @@ extra_rdoc_files: []
55
55
  files:
56
56
  - lib/labimotion.rb
57
57
  - lib/labimotion/apis/converter_api.rb
58
+ - lib/labimotion/apis/dose_resp_request_api.rb
58
59
  - lib/labimotion/apis/exporter_api.rb
59
60
  - lib/labimotion/apis/generic_dataset_api.rb
60
61
  - lib/labimotion/apis/generic_element_api.rb
61
62
  - lib/labimotion/apis/generic_klass_api.rb
62
63
  - lib/labimotion/apis/labimotion_api.rb
63
64
  - lib/labimotion/apis/labimotion_hub_api.rb
65
+ - lib/labimotion/apis/mtt_api.rb
64
66
  - lib/labimotion/apis/segment_api.rb
65
67
  - lib/labimotion/apis/standard_api.rb
66
68
  - lib/labimotion/apis/standard_layer_api.rb
@@ -90,6 +92,7 @@ files:
90
92
  - lib/labimotion/helpers/element_helpers.rb
91
93
  - lib/labimotion/helpers/exporter_helpers.rb
92
94
  - lib/labimotion/helpers/generic_helpers.rb
95
+ - lib/labimotion/helpers/mtt_helpers.rb
93
96
  - lib/labimotion/helpers/param_helpers.rb
94
97
  - lib/labimotion/helpers/repository_helpers.rb
95
98
  - lib/labimotion/helpers/sample_association_helpers.rb
@@ -130,6 +133,8 @@ files:
130
133
  - lib/labimotion/models/dataset_klasses_revision.rb
131
134
  - lib/labimotion/models/datasets_revision.rb
132
135
  - lib/labimotion/models/device_description.rb
136
+ - lib/labimotion/models/dose_resp_output.rb
137
+ - lib/labimotion/models/dose_resp_request.rb
133
138
  - lib/labimotion/models/element.rb
134
139
  - lib/labimotion/models/element_klass.rb
135
140
  - lib/labimotion/models/element_klasses_revision.rb