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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/lib/labimotion/apis/exporter_api.rb +271 -0
  3. data/lib/labimotion/apis/generic_dataset_api.rb +37 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +106 -4
  5. data/lib/labimotion/apis/generic_klass_api.rb +42 -2
  6. data/lib/labimotion/apis/labimotion_api.rb +1 -0
  7. data/lib/labimotion/apis/segment_api.rb +5 -3
  8. data/lib/labimotion/entities/application_entity.rb +7 -80
  9. data/lib/labimotion/entities/dataset_entity.rb +8 -3
  10. data/lib/labimotion/entities/dataset_klass_entity.rb +3 -2
  11. data/lib/labimotion/entities/element_entity.rb +1 -1
  12. data/lib/labimotion/entities/element_klass_entity.rb +2 -2
  13. data/lib/labimotion/entities/element_revision_entity.rb +3 -1
  14. data/lib/labimotion/entities/eln_element_entity.rb +4 -2
  15. data/lib/labimotion/entities/generic_klass_entity.rb +8 -9
  16. data/lib/labimotion/entities/generic_public_entity.rb +5 -3
  17. data/lib/labimotion/entities/klass_revision_entity.rb +2 -1
  18. data/lib/labimotion/entities/properties_entity.rb +5 -0
  19. data/lib/labimotion/entities/segment_entity.rb +5 -2
  20. data/lib/labimotion/entities/segment_revision_entity.rb +4 -2
  21. data/lib/labimotion/entities/vocabulary_entity.rb +2 -2
  22. data/lib/labimotion/helpers/converter_helpers.rb +16 -3
  23. data/lib/labimotion/helpers/dataset_helpers.rb +31 -6
  24. data/lib/labimotion/helpers/element_helpers.rb +7 -4
  25. data/lib/labimotion/helpers/exporter_helpers.rb +139 -0
  26. data/lib/labimotion/helpers/param_helpers.rb +6 -0
  27. data/lib/labimotion/helpers/segment_helpers.rb +2 -2
  28. data/lib/labimotion/libs/converter.rb +14 -0
  29. data/lib/labimotion/libs/data/layer/StdDataset.json +212 -0
  30. data/lib/labimotion/libs/data/mapper/Chemwiki.json +2 -2
  31. data/lib/labimotion/libs/dataset_builder.rb +2 -1
  32. data/lib/labimotion/libs/export_element.rb +11 -2
  33. data/lib/labimotion/libs/nmr_mapper.rb +13 -0
  34. data/lib/labimotion/libs/properties_handler.rb +12 -2
  35. data/lib/labimotion/libs/sample_association.rb +7 -4
  36. data/lib/labimotion/libs/template_matcher.rb +39 -0
  37. data/lib/labimotion/libs/vocabulary_handler.rb +8 -6
  38. data/lib/labimotion/libs/xlsx_exporter.rb +285 -0
  39. data/lib/labimotion/models/concerns/datasetable.rb +3 -3
  40. data/lib/labimotion/models/concerns/element_fetchable.rb +53 -0
  41. data/lib/labimotion/models/concerns/generic_klass.rb +16 -0
  42. data/lib/labimotion/models/concerns/segmentable.rb +44 -7
  43. data/lib/labimotion/models/dataset_klass.rb +30 -2
  44. data/lib/labimotion/models/device_description.rb +36 -0
  45. data/lib/labimotion/models/element.rb +37 -1
  46. data/lib/labimotion/models/element_klass.rb +12 -1
  47. data/lib/labimotion/models/reaction.rb +36 -0
  48. data/lib/labimotion/models/research_plan.rb +40 -0
  49. data/lib/labimotion/models/sample.rb +36 -0
  50. data/lib/labimotion/models/screen.rb +36 -0
  51. data/lib/labimotion/models/segment_klass.rb +12 -4
  52. data/lib/labimotion/models/wellplate.rb +40 -0
  53. data/lib/labimotion/utils/serializer.rb +2 -0
  54. data/lib/labimotion/utils/units.rb +609 -468
  55. data/lib/labimotion/utils/utils.rb +42 -0
  56. data/lib/labimotion/version.rb +1 -1
  57. data/lib/labimotion.rb +12 -0
  58. 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 != props['klass_uuid'] || ds.properties != props)
49
- ds.update!(properties_release: klass.properties_release, uuid: uuid, dataset_klass_id: args[:dataset_klass_id], properties: props, klass_uuid: 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
- return if args[:segments].nil?
56
+ args_segments = args[:segments] || []
51
57
 
52
- segments = []
53
- args[:segments]&.each do |seg|
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
- current_user = User.find_by(id: args[:current_user_id])
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']).count.positive?
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']})", desc: "#{term['label']} (#{term['synonym']})", place: term['position'], created_by: Admin.first&.id || 0 }
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