labimotion 2.0.0 → 2.1.0.rc11
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 +4 -4
- data/lib/labimotion/apis/exporter_api.rb +271 -0
- data/lib/labimotion/apis/generic_dataset_api.rb +37 -3
- data/lib/labimotion/apis/generic_element_api.rb +106 -4
- data/lib/labimotion/apis/generic_klass_api.rb +42 -2
- data/lib/labimotion/apis/labimotion_api.rb +1 -0
- data/lib/labimotion/apis/segment_api.rb +5 -3
- data/lib/labimotion/entities/application_entity.rb +7 -80
- data/lib/labimotion/entities/dataset_entity.rb +8 -3
- data/lib/labimotion/entities/dataset_klass_entity.rb +3 -2
- data/lib/labimotion/entities/element_entity.rb +1 -1
- data/lib/labimotion/entities/element_klass_entity.rb +2 -2
- data/lib/labimotion/entities/element_revision_entity.rb +3 -1
- data/lib/labimotion/entities/eln_element_entity.rb +4 -2
- data/lib/labimotion/entities/generic_klass_entity.rb +8 -9
- data/lib/labimotion/entities/generic_public_entity.rb +5 -3
- data/lib/labimotion/entities/klass_revision_entity.rb +2 -1
- data/lib/labimotion/entities/properties_entity.rb +5 -0
- data/lib/labimotion/entities/segment_entity.rb +5 -2
- data/lib/labimotion/entities/segment_revision_entity.rb +4 -2
- data/lib/labimotion/entities/vocabulary_entity.rb +2 -2
- data/lib/labimotion/helpers/converter_helpers.rb +16 -3
- data/lib/labimotion/helpers/dataset_helpers.rb +31 -6
- data/lib/labimotion/helpers/element_helpers.rb +7 -4
- data/lib/labimotion/helpers/exporter_helpers.rb +139 -0
- data/lib/labimotion/helpers/param_helpers.rb +6 -0
- data/lib/labimotion/helpers/segment_helpers.rb +2 -2
- data/lib/labimotion/libs/converter.rb +14 -0
- data/lib/labimotion/libs/data/layer/StdDataset.json +212 -0
- data/lib/labimotion/libs/data/mapper/Chemwiki.json +2 -2
- data/lib/labimotion/libs/dataset_builder.rb +2 -1
- data/lib/labimotion/libs/export_element.rb +11 -2
- data/lib/labimotion/libs/nmr_mapper.rb +13 -0
- data/lib/labimotion/libs/properties_handler.rb +12 -2
- data/lib/labimotion/libs/sample_association.rb +7 -4
- data/lib/labimotion/libs/template_matcher.rb +39 -0
- data/lib/labimotion/libs/vocabulary_handler.rb +8 -6
- data/lib/labimotion/libs/xlsx_exporter.rb +285 -0
- data/lib/labimotion/models/concerns/datasetable.rb +3 -3
- data/lib/labimotion/models/concerns/element_fetchable.rb +53 -0
- data/lib/labimotion/models/concerns/generic_klass.rb +16 -0
- data/lib/labimotion/models/concerns/segmentable.rb +44 -7
- data/lib/labimotion/models/dataset_klass.rb +30 -2
- data/lib/labimotion/models/device_description.rb +36 -0
- data/lib/labimotion/models/element.rb +37 -1
- data/lib/labimotion/models/element_klass.rb +12 -1
- data/lib/labimotion/models/reaction.rb +36 -0
- data/lib/labimotion/models/research_plan.rb +40 -0
- data/lib/labimotion/models/sample.rb +36 -0
- data/lib/labimotion/models/screen.rb +36 -0
- data/lib/labimotion/models/segment_klass.rb +12 -4
- data/lib/labimotion/models/wellplate.rb +40 -0
- data/lib/labimotion/utils/serializer.rb +2 -0
- data/lib/labimotion/utils/units.rb +609 -468
- data/lib/labimotion/utils/utils.rb +42 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +12 -0
- metadata +45 -8
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'caxlsx'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
## XlsxExporter
|
|
7
|
+
# A common utility class for exporting data to Excel (.xlsx) format using the caxlsx gem
|
|
8
|
+
#
|
|
9
|
+
# Usage Example:
|
|
10
|
+
# exporter = Labimotion::XlsxExporter.new('MyReport')
|
|
11
|
+
# exporter.add_worksheet('Sheet1') do |sheet|
|
|
12
|
+
# sheet.add_header(['Name', 'Age', 'Email'])
|
|
13
|
+
# sheet.add_row(['Jane Smith', 25, 'jane@example.com'])
|
|
14
|
+
# end
|
|
15
|
+
# exporter.save_to_file('output.xlsx')
|
|
16
|
+
# # or get the stream: exporter.to_stream
|
|
17
|
+
class XlsxExporter
|
|
18
|
+
attr_reader :package, :workbook
|
|
19
|
+
|
|
20
|
+
# Initialize a new XlsxExporter
|
|
21
|
+
# @param filename [String] optional filename (without extension)
|
|
22
|
+
def initialize(filename = nil)
|
|
23
|
+
@filename = filename
|
|
24
|
+
@package = Axlsx::Package.new
|
|
25
|
+
@workbook = @package.workbook
|
|
26
|
+
@workbook.styles.fonts.first.name = 'Calibri'
|
|
27
|
+
@current_sheet = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Add a new worksheet to the workbook
|
|
31
|
+
# @param sheet_name [String] name of the worksheet
|
|
32
|
+
# @param options [Hash] additional options for the worksheet
|
|
33
|
+
# @yield [sheet] gives the SheetBuilder to the block
|
|
34
|
+
# @return [SheetBuilder] the created sheet builder
|
|
35
|
+
def add_worksheet(sheet_name, _options = {}, &block)
|
|
36
|
+
sheet = SheetBuilder.new(@workbook.add_worksheet(name: sheet_name), @workbook)
|
|
37
|
+
sheet.instance_eval(&block) if block_given?
|
|
38
|
+
sheet
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Get the Excel file as a stream
|
|
42
|
+
# @return [String] the Excel file stream
|
|
43
|
+
def to_stream
|
|
44
|
+
@package.to_stream
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Read the Excel file content
|
|
48
|
+
# @return [String] the Excel file binary content
|
|
49
|
+
def read
|
|
50
|
+
@package.to_stream.read
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Save the Excel file to disk
|
|
54
|
+
# @param filepath [String] path where to save the file
|
|
55
|
+
# @return [Boolean] true if saved successfully
|
|
56
|
+
def save_to_file(filepath)
|
|
57
|
+
@package.serialize(filepath)
|
|
58
|
+
true
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
Labimotion.log_exception(e)
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get suggested filename with .xlsx extension
|
|
65
|
+
# @return [String] filename with extension
|
|
66
|
+
def filename
|
|
67
|
+
name = @filename || "export_#{Time.now.strftime('%Y%m%d_%H%M%S')}"
|
|
68
|
+
"#{name}.xlsx"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
## SheetBuilder
|
|
72
|
+
# Helper class to build worksheet content with convenient methods
|
|
73
|
+
class SheetBuilder
|
|
74
|
+
attr_reader :sheet, :workbook, :styles
|
|
75
|
+
|
|
76
|
+
def initialize(sheet, workbook)
|
|
77
|
+
@sheet = sheet
|
|
78
|
+
@workbook = workbook
|
|
79
|
+
@styles = StyleManager.new(workbook)
|
|
80
|
+
@current_row = 0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Add a header row with bold styling
|
|
84
|
+
# @param data [Array] array of header values
|
|
85
|
+
# @param options [Hash] style options (color, bg_color, bold, etc.)
|
|
86
|
+
# @return [Integer] row index
|
|
87
|
+
def add_header(data, options = {})
|
|
88
|
+
style_options = {
|
|
89
|
+
bold: true,
|
|
90
|
+
fg_color: '000000', # Black text
|
|
91
|
+
bg_color: 'DDDDDD',
|
|
92
|
+
border: { style: :thin, color: '000000' }
|
|
93
|
+
}.merge(options)
|
|
94
|
+
|
|
95
|
+
style = @styles.create_style(style_options)
|
|
96
|
+
add_row(data, style: style)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Add a data row
|
|
100
|
+
# @param data [Array] array of cell values
|
|
101
|
+
# @param options [Hash] options including :style, :height, :types
|
|
102
|
+
# @return [Integer] row index
|
|
103
|
+
def add_row(data, options = {})
|
|
104
|
+
row_options = {}
|
|
105
|
+
row_options[:style] = options[:style] if options[:style]
|
|
106
|
+
row_options[:height] = options[:height] if options[:height]
|
|
107
|
+
row_options[:types] = options[:types] if options[:types]
|
|
108
|
+
|
|
109
|
+
@sheet.add_row(data, row_options)
|
|
110
|
+
@current_row += 1
|
|
111
|
+
@current_row - 1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Add multiple rows at once
|
|
115
|
+
# @param rows [Array<Array>] array of row data
|
|
116
|
+
# @param options [Hash] options for all rows
|
|
117
|
+
# @return [Integer] number of rows added
|
|
118
|
+
def add_rows(rows, options = {})
|
|
119
|
+
rows.each { |row| add_row(row, options) }
|
|
120
|
+
rows.length
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Add an empty row (for spacing)
|
|
124
|
+
# @return [Integer] row index
|
|
125
|
+
def add_blank_row
|
|
126
|
+
add_row([])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Set column widths
|
|
130
|
+
# @param widths [Array<Numeric>] array of column widths
|
|
131
|
+
def set_column_widths(*widths)
|
|
132
|
+
@sheet.column_widths(*widths)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Auto-fit column widths based on content
|
|
136
|
+
# @param columns [Array<Integer>] column indices to auto-fit (nil for all)
|
|
137
|
+
def auto_fit_columns(columns = nil)
|
|
138
|
+
cols = columns || (0...(@sheet.rows.first&.cells&.length || 0)).to_a
|
|
139
|
+
cols.each do |col_index|
|
|
140
|
+
max_width = @sheet.rows.map { |row| row.cells[col_index]&.value.to_s.length || 0 }.max
|
|
141
|
+
@sheet.column_info[col_index].width = [max_width + 2, 100].min if max_width
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Merge cells in a range
|
|
146
|
+
# @param start_cell [String] starting cell (e.g., 'A1')
|
|
147
|
+
# @param end_cell [String] ending cell (e.g., 'C1')
|
|
148
|
+
def merge_cells(start_cell, end_cell)
|
|
149
|
+
@sheet.merge_cells("#{start_cell}:#{end_cell}")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Add a titled section (title + data rows)
|
|
153
|
+
# @param title [String] section title
|
|
154
|
+
# @param data [Array<Array>] data rows
|
|
155
|
+
# @param options [Hash] options for the section
|
|
156
|
+
def add_section(title, data, options = {})
|
|
157
|
+
add_blank_row if @current_row.positive?
|
|
158
|
+
|
|
159
|
+
# Add title
|
|
160
|
+
title_style = @styles.create_style(
|
|
161
|
+
bold: true,
|
|
162
|
+
font_size: 14,
|
|
163
|
+
bg_color: options[:title_bg_color] || 'CCCCCC'
|
|
164
|
+
)
|
|
165
|
+
add_row([title], style: title_style)
|
|
166
|
+
|
|
167
|
+
# Add data
|
|
168
|
+
add_rows(data, options)
|
|
169
|
+
|
|
170
|
+
add_blank_row
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Freeze panes (typically for freezing header rows)
|
|
174
|
+
# @param row [Integer] row number to freeze at
|
|
175
|
+
# @param column [Integer] column number to freeze at
|
|
176
|
+
def freeze_panes(row = 1, column = 0)
|
|
177
|
+
@sheet.sheet_view.pane do |pane|
|
|
178
|
+
pane.top_left_cell = Axlsx.cell_r(column, row)
|
|
179
|
+
pane.state = :frozen
|
|
180
|
+
pane.y_split = row
|
|
181
|
+
pane.x_split = column
|
|
182
|
+
pane.active_pane = :bottom_right
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Apply auto-filter to a range
|
|
187
|
+
# @param range [String] range to apply filter (e.g., 'A1:D1')
|
|
188
|
+
def add_auto_filter(range = nil)
|
|
189
|
+
if range
|
|
190
|
+
@sheet.auto_filter = range
|
|
191
|
+
else
|
|
192
|
+
# Auto-detect range from first row
|
|
193
|
+
last_col = (@sheet.rows.first&.cells&.length || 1) - 1
|
|
194
|
+
@sheet.auto_filter = "A1:#{Axlsx.col_ref(last_col)}1"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Add a hyperlink to a specific cell
|
|
199
|
+
# @param row_index [Integer] 0-based row index
|
|
200
|
+
# @param col_index [Integer] 0-based column index
|
|
201
|
+
# @param url [String] the URL for the hyperlink
|
|
202
|
+
# @param display_text [String] optional display text (defaults to cell value)
|
|
203
|
+
def add_hyperlink(row_index, col_index, url, display_text = nil)
|
|
204
|
+
cell = @sheet.rows[row_index].cells[col_index]
|
|
205
|
+
cell.value = display_text if display_text
|
|
206
|
+
@sheet.add_hyperlink location: url, ref: cell
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
## StyleManager
|
|
211
|
+
# Helper class to manage and create cell styles
|
|
212
|
+
class StyleManager
|
|
213
|
+
def initialize(workbook)
|
|
214
|
+
@workbook = workbook
|
|
215
|
+
@style_cache = {}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Create or retrieve a cached style
|
|
219
|
+
# @param options [Hash] style options
|
|
220
|
+
# @option options [Boolean] :bold
|
|
221
|
+
# @option options [Boolean] :italic
|
|
222
|
+
# @option options [String] :font_name
|
|
223
|
+
# @option options [Integer] :font_size
|
|
224
|
+
# @option options [String] :fg_color foreground/text color
|
|
225
|
+
# @option options [String] :bg_color background color
|
|
226
|
+
# @option options [Symbol] :alignment (:left, :center, :right)
|
|
227
|
+
# @option options [Hash] :border border options
|
|
228
|
+
# @option options [String] :format_code number format
|
|
229
|
+
# @return [Axlsx::Style] the created or cached style
|
|
230
|
+
def create_style(options = {})
|
|
231
|
+
cache_key = options.hash
|
|
232
|
+
return @style_cache[cache_key] if @style_cache[cache_key]
|
|
233
|
+
|
|
234
|
+
style_options = {}
|
|
235
|
+
|
|
236
|
+
# Font options
|
|
237
|
+
font_options = {}
|
|
238
|
+
font_options[:b] = options[:bold] if options.key?(:bold)
|
|
239
|
+
font_options[:i] = options[:italic] if options.key?(:italic)
|
|
240
|
+
font_options[:name] = options[:font_name] if options[:font_name]
|
|
241
|
+
font_options[:sz] = options[:font_size] if options[:font_size]
|
|
242
|
+
# font_options[:color] = { rgb: options[:fg_color] } if options[:fg_color]
|
|
243
|
+
style_options[:font] = font_options if font_options.any?
|
|
244
|
+
|
|
245
|
+
# Fill/background color
|
|
246
|
+
if options[:bg_color]
|
|
247
|
+
style_options[:bg_color] = options[:bg_color]
|
|
248
|
+
style_options[:fg_color] = options[:fg_color]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Alignment
|
|
252
|
+
style_options[:alignment] = { horizontal: options[:alignment] } if options[:alignment]
|
|
253
|
+
|
|
254
|
+
# Border
|
|
255
|
+
style_options[:border] = options[:border] if options[:border]
|
|
256
|
+
|
|
257
|
+
# Number format
|
|
258
|
+
style_options[:format_code] = options[:format_code] if options[:format_code]
|
|
259
|
+
|
|
260
|
+
@style_cache[cache_key] = @workbook.styles.add_style(style_options)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Common predefined styles
|
|
264
|
+
def header_style
|
|
265
|
+
create_style(bold: true, bg_color: 'DDDDDD', alignment: :center)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def currency_style
|
|
269
|
+
create_style(format_code: '$#,##0.00')
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def percentage_style
|
|
273
|
+
create_style(format_code: '0.00%')
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def date_style
|
|
277
|
+
create_style(format_code: 'yyyy-mm-dd')
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def datetime_style
|
|
281
|
+
create_style(format_code: 'yyyy-mm-dd hh:mm:ss')
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -42,15 +42,15 @@ module Labimotion
|
|
|
42
42
|
props['identifier'] = klass.identifier if klass.identifier.present?
|
|
43
43
|
props['uuid'] = uuid
|
|
44
44
|
props['klass'] = 'Dataset'
|
|
45
|
+
props['klass_uuid'] = klass.uuid
|
|
45
46
|
props = Labimotion::VocabularyHandler.update_vocabularies(props, args[:current_user], args[:element])
|
|
46
47
|
|
|
47
48
|
ds = Labimotion::Dataset.find_by(element_type: self.class.name, element_id: id)
|
|
48
|
-
if ds.present? && (ds.klass_uuid !=
|
|
49
|
-
ds.update!(properties_release: klass.properties_release, uuid: uuid, dataset_klass_id: args[:dataset_klass_id], properties: props, klass_uuid:
|
|
49
|
+
if ds.present? && (ds.klass_uuid != klass.uuid || ds.properties != props)
|
|
50
|
+
ds.update!(properties_release: klass.properties_release, uuid: uuid, dataset_klass_id: args[:dataset_klass_id], properties: props, klass_uuid: klass.uuid)
|
|
50
51
|
end
|
|
51
52
|
return if ds.present?
|
|
52
53
|
|
|
53
|
-
props['klass_uuid'] = klass.uuid
|
|
54
54
|
Labimotion::Dataset.create!(properties_release: klass.properties_release, uuid: uuid, dataset_klass_id: args[:dataset_klass_id], element_type: self.class.name, element_id: id, properties: props, klass_uuid: klass.uuid)
|
|
55
55
|
end
|
|
56
56
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Labimotion
|
|
4
|
+
module ElementFetchable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
def element_klass
|
|
9
|
+
Labimotion::ElementKlass.find_by(name: element_klass_name)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def fetch_for_user(user_id, name: nil, short_label: nil, limit: 20)
|
|
13
|
+
# Prevent abuse by capping and validating limit
|
|
14
|
+
limit = [limit.to_i, 100].min
|
|
15
|
+
limit = 20 if limit <= 0
|
|
16
|
+
|
|
17
|
+
# Build base scope with common filters
|
|
18
|
+
apply_filters = lambda do |scope|
|
|
19
|
+
scope = scope.where("#{table_name}.name ILIKE ?", "%#{sanitize_sql_like(name)}%") if name.present?
|
|
20
|
+
scope = scope.where("#{table_name}.short_label ILIKE ?", "%#{sanitize_sql_like(short_label)}%") if short_label.present? && column_names.include?('short_label')
|
|
21
|
+
scope
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Owned records
|
|
25
|
+
owned = apply_filters.call(
|
|
26
|
+
joins(collections: :user).where(collections: { user_id: user_id })
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Shared (synced) records
|
|
30
|
+
shared = apply_filters.call(
|
|
31
|
+
joins(collections: :sync_collections_users).where(sync_collections_users: { user_id: user_id })
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Combine (remove duplicates), order, and limit
|
|
35
|
+
order_column = column_names.include?('short_label') ? :short_label : :name
|
|
36
|
+
from("(#{owned.to_sql} UNION #{shared.to_sql}) AS #{table_name}")
|
|
37
|
+
.order(order_column => :desc)
|
|
38
|
+
.limit(limit)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def element_klass_name
|
|
44
|
+
raise NotImplementedError, "Subclass must define element_klass_name"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Instance method to get element_klass for a record
|
|
49
|
+
def element_klass
|
|
50
|
+
self.class.element_klass
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Labimotion
|
|
4
|
+
## Generic Klass Helpers
|
|
5
|
+
module GenericKlass
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
# Scope for active and released templates
|
|
10
|
+
scope :active_and_released, -> { for_list_display.where(is_active: true).where.not(released_at: nil) }
|
|
11
|
+
|
|
12
|
+
# Scope for active, released, and generic templates (primarily for ElementKlass)
|
|
13
|
+
scope :active_released_generic, -> { active_and_released.where(is_generic: true) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -46,11 +46,17 @@ module Labimotion
|
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
def touch_vocabulary(current_user)
|
|
50
|
+
touch_element_properties(current_user) if instance_of?(::Labimotion::Element)
|
|
51
|
+
touch_segments_properties(current_user)
|
|
52
|
+
touch_analyses_properties(current_user)
|
|
53
|
+
end
|
|
54
|
+
|
|
49
55
|
def save_segments(**args) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
50
|
-
|
|
56
|
+
args_segments = args[:segments] || []
|
|
51
57
|
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
current_user = User.find_by(id: args[:current_user_id])
|
|
59
|
+
args_segments.each do |seg|
|
|
54
60
|
klass = Labimotion::SegmentKlass.find_by(id: seg['segment_klass_id'])
|
|
55
61
|
uuid = SecureRandom.uuid
|
|
56
62
|
props = seg['properties']
|
|
@@ -59,21 +65,52 @@ module Labimotion
|
|
|
59
65
|
props['uuid'] = uuid
|
|
60
66
|
props['klass'] = 'Segment'
|
|
61
67
|
props = Labimotion::SampleAssociation.update_sample_association(props, args[:current_user_id])
|
|
62
|
-
|
|
63
|
-
props = Labimotion::VocabularyHandler.update_vocabularies(props, current_user, self)
|
|
68
|
+
# props = Labimotion::VocabularyHandler.update_vocabularies(props, current_user, self)
|
|
64
69
|
segment = Labimotion::Segment.where(element_type: self.class.name, element_id: self.id, segment_klass_id: seg['segment_klass_id']).order(id: :desc).first
|
|
65
70
|
if segment.present? && (segment.klass_uuid != props['klass_uuid'] || segment.properties != props)
|
|
66
71
|
segment.update!(properties_release: klass.properties_release, properties: props, uuid: uuid, klass_uuid: props['klass_uuid'])
|
|
67
|
-
segments.push(segment)
|
|
72
|
+
# segments.push(segment)
|
|
68
73
|
Labimotion::Segment.where(element_type: self.class.name, element_id: self.id, segment_klass_id: seg['segment_klass_id']).where.not(id: segment.id).destroy_all
|
|
69
74
|
end
|
|
70
75
|
next if segment.present?
|
|
71
76
|
|
|
72
77
|
props['klass_uuid'] = klass.uuid
|
|
73
78
|
segment = Labimotion::Segment.create!(properties_release: klass.properties_release, segment_klass_id: seg['segment_klass_id'], element_type: self.class.name, element_id: self.id, properties: props, created_by: args[:current_user_id], uuid: uuid, klass_uuid: klass.uuid)
|
|
74
|
-
segments.push(segment)
|
|
79
|
+
# segments.push(segment)
|
|
75
80
|
end
|
|
81
|
+
|
|
82
|
+
self.reload
|
|
83
|
+
touch_vocabulary(current_user)
|
|
84
|
+
self.reload
|
|
76
85
|
segments
|
|
77
86
|
end
|
|
87
|
+
|
|
88
|
+
def touch_element_properties(current_user)
|
|
89
|
+
touch_properties_for_object(self, current_user)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def touch_segments_properties(current_user)
|
|
93
|
+
segments.each do |segment|
|
|
94
|
+
touch_properties_for_object(segment, current_user)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def touch_analyses_properties(current_user)
|
|
99
|
+
analyses.each do |analysis|
|
|
100
|
+
analysis.children.each do |child|
|
|
101
|
+
dataset = child.dataset
|
|
102
|
+
next if dataset.nil?
|
|
103
|
+
|
|
104
|
+
touch_properties_for_object(dataset, current_user)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# NOTE: Update bypassing validations, callbacks, and timestamp updates
|
|
110
|
+
def touch_properties_for_object(object, current_user)
|
|
111
|
+
props_dup = object.properties.deep_dup
|
|
112
|
+
Labimotion::VocabularyHandler.update_vocabularies(props_dup, current_user, self)
|
|
113
|
+
object.update_column(:properties, props_dup) if props_dup != object.properties
|
|
114
|
+
end
|
|
78
115
|
end
|
|
79
116
|
end
|
|
@@ -1,25 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require 'labimotion/models/concerns/generic_klass_revisions'
|
|
4
|
+
require 'labimotion/models/concerns/generic_klass'
|
|
3
5
|
|
|
4
6
|
module Labimotion
|
|
5
7
|
class DatasetKlass < ApplicationRecord
|
|
6
8
|
acts_as_paranoid
|
|
7
9
|
self.table_name = :dataset_klasses
|
|
8
10
|
include GenericKlassRevisions
|
|
11
|
+
include GenericKlass
|
|
12
|
+
|
|
9
13
|
has_many :datasets, dependent: :destroy, class_name: 'Labimotion::Dataset'
|
|
10
14
|
has_many :dataset_klasses_revisions, dependent: :destroy, class_name: 'Labimotion::DatasetKlassesRevision'
|
|
11
15
|
|
|
16
|
+
# Scope for displayed_in_list - select only necessary columns for list view
|
|
17
|
+
scope :for_list_display, lambda {
|
|
18
|
+
select(:id, :uuid, :label, :desc, :is_active, :version, :place, :released_at,
|
|
19
|
+
:identifier, :sync_time, :created_at, :updated_at, :ols_term_id)
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
def self.init_seeds
|
|
13
23
|
seeds_path = File.join(Rails.root, 'db', 'seeds', 'json', 'dataset_klasses.json')
|
|
14
24
|
seeds = JSON.parse(File.read(seeds_path))
|
|
15
25
|
|
|
16
26
|
seeds['chmo'].each do |term|
|
|
17
|
-
next if Labimotion::DatasetKlass.where(ols_term_id: term['id']).
|
|
27
|
+
next if Labimotion::DatasetKlass.where(ols_term_id: term['id']).any?
|
|
18
28
|
|
|
19
|
-
attributes = { ols_term_id: term['id'], label: "#{term['label']} (#{term['synonym']})",
|
|
29
|
+
attributes = { ols_term_id: term['id'], label: "#{term['label']} (#{term['synonym']})",
|
|
30
|
+
desc: "#{term['label']} (#{term['synonym']})", place: term['position'],
|
|
31
|
+
created_by: Admin.first&.id || 0 }
|
|
20
32
|
Labimotion::DatasetKlass.create!(attributes)
|
|
21
33
|
end
|
|
22
34
|
true
|
|
23
35
|
end
|
|
36
|
+
|
|
37
|
+
def self.find_by_mode(ols_term_id, mode)
|
|
38
|
+
where(
|
|
39
|
+
"super_class_of @> jsonb_build_object(:id, jsonb_build_object('mode', :mode))",
|
|
40
|
+
id: ols_term_id,
|
|
41
|
+
mode: mode.to_s
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.find_assigned_templates(ols_term_id)
|
|
46
|
+
find_by_mode(ols_term_id, 'assigned')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.find_inferred_templates(ols_term_id)
|
|
50
|
+
find_by_mode(ols_term_id, 'inferred')
|
|
51
|
+
end
|
|
24
52
|
end
|
|
25
53
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This file extends the existing DeviceDescription model in the consuming app
|
|
4
|
+
# and defines a Labimotion::DeviceDescription wrapper for convenient access.
|
|
5
|
+
|
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
|
7
|
+
if defined?(::DeviceDescription)
|
|
8
|
+
::DeviceDescription.class_eval do
|
|
9
|
+
include Labimotion::ElementFetchable
|
|
10
|
+
|
|
11
|
+
def self.element_klass_name
|
|
12
|
+
'device_description'
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
else
|
|
16
|
+
warn "[Labimotion] DeviceDescription is not defined when Labimotion extension was loaded."
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Namespace wrapper to keep your preferred call style
|
|
21
|
+
module Labimotion
|
|
22
|
+
module DeviceDescription
|
|
23
|
+
# Delegate class methods to ::DeviceDescription
|
|
24
|
+
def self.method_missing(method, *args, &block)
|
|
25
|
+
if ::DeviceDescription.respond_to?(method)
|
|
26
|
+
::DeviceDescription.public_send(method, *args, &block)
|
|
27
|
+
else
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.respond_to_missing?(method, include_private = false)
|
|
33
|
+
::DeviceDescription.respond_to?(method, include_private) || super
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -31,6 +31,7 @@ module Labimotion
|
|
|
31
31
|
|
|
32
32
|
belongs_to :element_klass, class_name: 'Labimotion::ElementKlass'
|
|
33
33
|
|
|
34
|
+
# has_ancestry ancestry_format: :materialized_path2
|
|
34
35
|
has_ancestry orphan_strategy: :adopt
|
|
35
36
|
|
|
36
37
|
has_many :collections_elements, inverse_of: :element, dependent: :destroy, class_name: 'Labimotion::CollectionsElement'
|
|
@@ -49,6 +50,7 @@ module Labimotion
|
|
|
49
50
|
scope :elements_updated_time_to, ->(time) { where('elements.updated_at <= ?', time) }
|
|
50
51
|
|
|
51
52
|
belongs_to :creator, foreign_key: :created_by, class_name: 'User'
|
|
53
|
+
before_validation :set_root_ancestry_if_nil
|
|
52
54
|
validates :creator, presence: true
|
|
53
55
|
|
|
54
56
|
has_many :elements_elements, foreign_key: :parent_id, class_name: 'Labimotion::ElementsElement'
|
|
@@ -71,7 +73,7 @@ module Labimotion
|
|
|
71
73
|
end
|
|
72
74
|
|
|
73
75
|
def analyses
|
|
74
|
-
container ? container.analyses :
|
|
76
|
+
container ? container.analyses : Container.none
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
def auto_set_short_label
|
|
@@ -108,6 +110,36 @@ module Labimotion
|
|
|
108
110
|
pids
|
|
109
111
|
end
|
|
110
112
|
|
|
113
|
+
# Fetch elements for a user (owned + shared)
|
|
114
|
+
def self.fetch_for_user(user_id, name: nil, short_label: nil, klass_id: nil, limit: 20)
|
|
115
|
+
# Prevent abuse by capping and validating limit
|
|
116
|
+
limit = [limit.to_i, 100].min
|
|
117
|
+
limit = 20 if limit <= 0
|
|
118
|
+
|
|
119
|
+
# Build base scope with common filters
|
|
120
|
+
apply_filters = lambda do |scope|
|
|
121
|
+
scope = scope.where('elements.name ILIKE ?', "%#{sanitize_sql_like(name)}%") if name.present?
|
|
122
|
+
scope = scope.where('elements.short_label ILIKE ?', "%#{sanitize_sql_like(short_label)}%") if short_label.present?
|
|
123
|
+
scope = scope.by_klass_id(klass_id) if klass_id.present?
|
|
124
|
+
scope
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Owned elements
|
|
128
|
+
owned = apply_filters.call(
|
|
129
|
+
joins(collections: :user).where(collections: { user_id: user_id })
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Shared (synced) elements
|
|
133
|
+
shared = apply_filters.call(
|
|
134
|
+
joins(collections: :sync_collections_users).where(sync_collections_users: { user_id: user_id })
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Combine (remove duplicates), order, and limit
|
|
138
|
+
from("(#{owned.to_sql} UNION #{shared.to_sql}) AS elements")
|
|
139
|
+
.order(short_label: :desc)
|
|
140
|
+
.limit(limit)
|
|
141
|
+
end
|
|
142
|
+
|
|
111
143
|
def thumb_svg
|
|
112
144
|
image_atts = attachments.select(&:type_image?)
|
|
113
145
|
attachment = image_atts[0] || attachments[0]
|
|
@@ -148,5 +180,9 @@ module Labimotion
|
|
|
148
180
|
attachments.each(&:destroy!)
|
|
149
181
|
end
|
|
150
182
|
end
|
|
183
|
+
|
|
184
|
+
def set_root_ancestry_if_nil
|
|
185
|
+
self.ancestry ||= '/'
|
|
186
|
+
end
|
|
151
187
|
end
|
|
152
188
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require 'labimotion/conf'
|
|
3
4
|
require 'labimotion/models/concerns/generic_klass_revisions'
|
|
5
|
+
require 'labimotion/models/concerns/generic_klass'
|
|
4
6
|
require 'labimotion/models/concerns/workflow'
|
|
5
7
|
|
|
6
8
|
module Labimotion
|
|
@@ -8,11 +10,21 @@ module Labimotion
|
|
|
8
10
|
self.table_name = :element_klasses
|
|
9
11
|
acts_as_paranoid
|
|
10
12
|
include GenericKlassRevisions
|
|
13
|
+
include GenericKlass
|
|
11
14
|
include Workflow
|
|
12
15
|
has_many :elements, dependent: :destroy, class_name: 'Labimotion::Element'
|
|
13
16
|
has_many :segment_klasses, dependent: :destroy, class_name: 'Labimotion::SegmentKlass'
|
|
14
17
|
has_many :element_klasses_revisions, dependent: :destroy, class_name: 'Labimotion::ElementKlassesRevision'
|
|
15
18
|
|
|
19
|
+
validates :name, presence: true, uniqueness: { conditions: -> { where(deleted_at: nil) }, message: 'is already in use.' }
|
|
20
|
+
|
|
21
|
+
# Scope for displayed_in_list - select only necessary columns for list view
|
|
22
|
+
scope :for_list_display, lambda {
|
|
23
|
+
select(:id, :uuid, :label, :desc, :is_active, :version, :place, :released_at,
|
|
24
|
+
:identifier, :sync_time, :created_at, :updated_at, :name, :icon_name,
|
|
25
|
+
:klass_prefix, :is_generic)
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
def self.gen_klasses_json
|
|
17
29
|
klasses = where(is_active: true, is_generic: true).order('place')&.pluck(:name) || []
|
|
18
30
|
rescue ActiveRecord::StatementInvalid, PG::ConnectionBad, PG::UndefinedTable
|
|
@@ -24,6 +36,5 @@ module Labimotion
|
|
|
24
36
|
klasses&.to_json || []
|
|
25
37
|
)
|
|
26
38
|
end
|
|
27
|
-
|
|
28
39
|
end
|
|
29
40
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This file extends the existing Reaction model in the consuming app
|
|
4
|
+
# and defines a Labimotion::Reaction wrapper for convenient access.
|
|
5
|
+
|
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
|
7
|
+
if defined?(::Reaction)
|
|
8
|
+
::Reaction.class_eval do
|
|
9
|
+
include Labimotion::ElementFetchable
|
|
10
|
+
|
|
11
|
+
def self.element_klass_name
|
|
12
|
+
'reaction'
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
else
|
|
16
|
+
warn "[Labimotion] Reaction is not defined when Labimotion extension was loaded."
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Namespace wrapper to keep your preferred call style
|
|
21
|
+
module Labimotion
|
|
22
|
+
module Reaction
|
|
23
|
+
# Delegate class methods to ::Reaction
|
|
24
|
+
def self.method_missing(method, *args, &block)
|
|
25
|
+
if ::Reaction.respond_to?(method)
|
|
26
|
+
::Reaction.public_send(method, *args, &block)
|
|
27
|
+
else
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.respond_to_missing?(method, include_private = false)
|
|
33
|
+
::Reaction.respond_to?(method, include_private) || super
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|