labimotion 2.3.0.rc5 → 2.4.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: 57f6db758edf09de9105830d7a9f9cfda1270ac36bb6ee4351da8ab65b2ea2dc
4
- data.tar.gz: 0e220ffc2a76645f8e82536cd592d7ee9eecd28e54073b408a602da019839e9f
3
+ metadata.gz: 4c92bee7516a80229220d269bcc9b53d700df17b0fa78f98857476f1bdd9ab02
4
+ data.tar.gz: 70ebf34d13b72d35eaeb4a05e039977add30227afe2acef0c00f1d434f2ae110
5
5
  SHA512:
6
- metadata.gz: f3f5ca1afdb69edbbf36f09834a04843d72d9b964db836719991a5d8b1acb10036cf4470f4ca1810bac6b629bc6908dc2ed5054127216ed9d6109b01d205ab07
7
- data.tar.gz: cf72bfa7a774ef98e1474d5a4e3914304f940105137f18f05adb4b5675afdeab668e305386bda9ce1b842b2083005fc622c210d27c60b1005cfcba90c1eb2617
6
+ metadata.gz: 15952d33d6cceadfc8a3bb21e020e0d924b27e127e84b4fa5667e98bb34a691969d19b642d8624f233ab9c9f18adb2c36d45920594f73fc56296d2a0590c72f5
7
+ data.tar.gz: 2d47c8be30f7ecbbe68db65e8a6979ed7e0d8a1edff6fdaa6fe0dcf1cafbaf1e914f085fdde09385b6546f4be1ba8e0de0830a94584d9aee75d45738ba5f384a
data/CHANGELOG.md CHANGED
@@ -2,13 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
  * Features and enhancements:
5
+ * Added per-user, per-klass toolbar configuration (Overview/Arrange visibility) via `POST /user_klass_settings/save` (upsert) and `GET /user_klass_settings/fetch` ([chem-generic-ui#905](https://github.com/LabIMotion/chem-generic-ui/issues/905)). Each user stores their own `settings` (`{ toolbar: { overview, arrange } }`) per element/segment/dataset klass, keyed by the stable klass identifier so it applies to every instance of that klass across template versions. Requires host ELN twin migration `create_user_klass_settings`.
6
+ * Designer can enable/disable common layer functionalities (record time, add reaction, add/remove layer) per template; template-scoped and independent of template versions ([chem-generic-ui#904](https://github.com/LabIMotion/chem-generic-ui/issues/904)). Saved segments expose the effective klass `settings` so records honor the toggles after a reload. Requires host ELN twin migration `add_settings_to_klasses`.
7
+ * Support linking CellLine elements in the generic Link Element via the `Labimotion::CellLine` wrapper ([chem-generic-ui#906](https://github.com/LabIMotion/chem-generic-ui/issues/906)). Requires a host ELN `element_klasses` row for `cell_line` (migration) — see PR notes.
8
+ * `LinkedElement` resolves `cell_line` links to curated `CelllineSample` columns (previously an unknown el_klass misresolved to a generic `Labimotion::Element` sharing the same id).
9
+ * `LinkedElement` exposes two distinct names for `cell_line` links — the shared **Cell line name** (`material_name`, from the linked `CelllineMaterial`) and the per-sample **Name of specific sample** (`name`) — as separately selectable inline attributes.
5
10
  * Element variations can be exported to and imported from Excel (`.xlsx`). The exported workbook is itself the import template: `GET /api/v1/element_variations/:element_id/export` and `POST /api/v1/element_variations/:element_id/import`.
6
11
  * The exported sheet carries the grid's grouped header -- group, sub-group and field rows, merged as the grid draws them -- above the hidden key and unit rows. Workbooks from the earlier single-row header still import.
7
12
  * Import merges rows by uuid, so a sheet only updates the columns it carries; a row with a blank `Row ID` is added as a new variation.
8
13
  * `number`, `integer` and `system-defined` columns accept numbers only. A cell holding anything else is cleared with a warning rather than stored as text, which also cleans up text those columns already hold.
9
14
  * Import warnings name the cell they came from (`Cell I6: ...`), so the user can go straight to it in the spreadsheet.
10
15
  * Bug fixes:
11
- * `fetch_for_user`: the shared-records branch joined `collection_shares` directly on the element model (an association that only exists on `Collection`), raising `ActiveRecord::ConfigurationError` — rescued into empty search results. Now joins through `collections`. Fixed for both the basic elements (`ElementFetchable`) and the generic element (`Labimotion::Element`), so `search_by_like` and `search_basic_by_like` return shared records again.
16
+ * `ElementFetchable#fetch_for_user`: the shared-records branch joined `collection_shares` directly on the element model (an association that only exists on `Collection`), raising `ActiveRecord::ConfigurationError` — rescued into empty search results for every element type. Now joins through `collections`.
17
+ * `Labimotion::Element.fetch_for_user` carried the same broken `collection_shares` join, so the generic-element `search_by_like` endpoint silently returned no shared records. Now joins through `collections` as well.
18
+ * `Labimotion::CellLine` delegation forwards keyword arguments on Ruby >= 3.0 (`ruby2_keywords`); without it `fetch_for_user(id, name:, short_label:, limit:)` raised `ArgumentError`.
19
+ * `SampleAssociation#update_sample_association` no longer creates `Labimotion::ElementsElement` join rows for drag_element values that link standard elements (e.g. `cell_line`, `reaction`), whose `el_id` is not a `Labimotion::Element` id.
20
+ * `Segmentable#save_segments` → `touch_analyses_properties` assumed every segmentable model defines `#analyses`; `CelllineSample` did not, so `POST /api/v1/cell_lines` raised `NameError` on create/update. Guard the analysis-vocabulary touch when `#analyses` is undefined so the save no longer fails.
12
21
  * Dependencies:
13
22
  * Added `roo` (~> 2.10) for reading the uploaded workbook.
14
23
 
@@ -88,6 +88,28 @@ module Labimotion
88
88
  end
89
89
  end
90
90
 
91
+ namespace :linked_attributes do
92
+ desc 'List the selectable inline attributes of an element linked from a drag_element field'
93
+ params do
94
+ optional :el_klass, type: String, desc: 'Generic element klass name or permit-target key (reaction, sample, ...)'
95
+ optional :el_type, type: String, desc: 'Linked element type (element, sample, molecule, ...)'
96
+ requires :el_id, type: Integer, desc: 'Linked element id'
97
+ end
98
+ get do
99
+ linked = Labimotion::LinkedElement.new(params[:el_klass], params[:el_id], params[:el_type])
100
+ record = linked.record
101
+ error!('404 Not Found', 404) if record.nil?
102
+ # Reference data (molecules) is public; everything else is collection-scoped.
103
+ readable = record.is_a?(::Molecule) || ElementPolicy.new(current_user, record).read?
104
+ error!('401 Unauthorized', 401) unless readable
105
+
106
+ { attributes: linked.available }
107
+ rescue StandardError => e
108
+ Labimotion.log_exception(e, current_user)
109
+ { attributes: [], error: e.message }
110
+ end
111
+ end
112
+
91
113
  namespace :search_basic_by_like do
92
114
  desc 'Search basic elements by name and short label (case-insensitive like search)'
93
115
  params do
@@ -14,7 +14,7 @@ module Labimotion
14
14
  desc 'export klass'
15
15
  params do
16
16
  requires :id, type: Integer, desc: 'element id'
17
- requires :klass, type: String, desc: 'Klass', values: %w[ElementKlass SegmentKlass DatasetKlass]
17
+ requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
18
18
  end
19
19
  get do
20
20
  entity = "Labimotion::#{params[:klass]}".constantize.find_by(id: params[:id])
@@ -35,7 +35,7 @@ module Labimotion
35
35
  namespace :de_activate do
36
36
  desc 'activate or deactivate Generic Klass'
37
37
  params do
38
- requires :klass, type: String, desc: 'Klass', values: %w[ElementKlass SegmentKlass DatasetKlass]
38
+ requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
39
39
  requires :id, type: Integer, desc: 'Klass ID'
40
40
  requires :is_active, type: Boolean, desc: 'Active or Inactive Klass'
41
41
  end
@@ -54,11 +54,33 @@ module Labimotion
54
54
  end
55
55
  end
56
56
 
57
+ namespace :update_settings do
58
+ desc 'update Generic Klass settings'
59
+ params do
60
+ requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
61
+ requires :id, type: Integer, desc: 'Klass ID'
62
+ requires :settings, type: Hash, desc: 'Klass settings'
63
+ end
64
+ after_validation do
65
+ authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
66
+ fetch_klass(params[:klass], params[:id])
67
+ end
68
+ post do
69
+ updated_klass = update_klass_settings(params)
70
+ entity_class = "Labimotion::#{params[:klass]}Entity".constantize
71
+ serialized_data = entity_class.represent(updated_klass)
72
+ { mc: 'ss00', data: serialized_data }
73
+ rescue StandardError => e
74
+ Labimotion.log_exception(e, current_user)
75
+ { mc: 'se00', msg: e.message, data: {} }
76
+ end
77
+ end
78
+
57
79
  namespace :fetch do
58
80
  desc 'fetch Generic Klass by id'
59
81
  params do
60
82
  requires :id, type: Integer, desc: 'Klass ID'
61
- requires :klass, type: String, desc: 'Klass', values: %w[ElementKlass SegmentKlass DatasetKlass]
83
+ requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
62
84
  end
63
85
  get do
64
86
  klass_obj = fetch_klass(params[:klass], params[:id])
@@ -13,6 +13,7 @@ module Labimotion
13
13
  mount Labimotion::StandardLayerAPI
14
14
  mount Labimotion::VocabularyAPI
15
15
  mount Labimotion::UserAPI
16
+ mount Labimotion::UserKlassSettingsAPI
16
17
  mount Labimotion::MttAPI
17
18
  mount Labimotion::ElementVariationAPI
18
19
  mount Labimotion::LabimotionDoiAPI
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labimotion/version'
4
+
5
+ module Labimotion
6
+ # Per-user, per-klass UI settings API (toolbar Overview/Arrange visibility).
7
+ # Every route is scoped to the authenticated user via created_by, so a user
8
+ # only ever reads/writes their own setting; no klass-edit permission needed.
9
+ class UserKlassSettingsAPI < Grape::API
10
+ helpers Labimotion::ParamHelpers
11
+
12
+ helpers do
13
+ def settings_scope
14
+ Labimotion::UserKlassSetting.where(created_by: current_user.id)
15
+ end
16
+
17
+ def klass_toolbar(settings)
18
+ (settings || {})['toolbar'] || (settings || {})[:toolbar] || {}
19
+ end
20
+ end
21
+
22
+ resource :user_klass_settings do
23
+ desc 'Fetch the current user UI setting for a klass'
24
+ params do
25
+ requires :klass, type: String, values: %w[ElementKlass SegmentKlass DatasetKlass], desc: 'Klass type'
26
+ requires :identifier, type: String, desc: 'Klass identifier'
27
+ end
28
+ get 'fetch' do
29
+ error!('401 Unauthorized', 401) unless current_user
30
+
31
+ record = settings_scope.find_by(klass: params[:klass], klass_identifier: params[:identifier])
32
+ data = record ? Labimotion::UserKlassSettingEntity.represent(record) : nil
33
+ { mc: 'ss00', data: data }
34
+ rescue StandardError => e
35
+ Labimotion.log_exception(e, current_user)
36
+ { mc: 'se00', msg: e.message, data: nil }
37
+ end
38
+
39
+ desc 'Upsert the current user UI setting for a klass'
40
+ params do
41
+ use :user_klass_settings_save
42
+ end
43
+ post 'save' do
44
+ error!('401 Unauthorized', 401) unless current_user
45
+
46
+ record = settings_scope.find_or_initialize_by(
47
+ klass: params[:klass], klass_identifier: params[:klass_identifier]
48
+ )
49
+ record.klass_label = params[:klass_label] if params[:klass_label]
50
+ record.created_by ||= current_user.id
51
+ record.settings = (record.settings || {}).merge('toolbar' => klass_toolbar(params[:settings]))
52
+ record.save!
53
+ { mc: 'ss00', data: Labimotion::UserKlassSettingEntity.represent(record) }
54
+ rescue StandardError => e
55
+ Labimotion.log_exception(e, current_user)
56
+ { mc: 'se00', msg: e.message, data: {} }
57
+ end
58
+
59
+ desc 'List all UI settings of the current user'
60
+ get 'list' do
61
+ error!('401 Unauthorized', 401) unless current_user
62
+
63
+ { mc: 'ss00', data: Labimotion::UserKlassSettingEntity.represent(settings_scope.to_a) }
64
+ rescue StandardError => e
65
+ Labimotion.log_exception(e, current_user)
66
+ { mc: 'se00', msg: e.message, data: [] }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -9,6 +9,7 @@ module Labimotion
9
9
  expose :properties_template, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
10
10
  expose :properties_release, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
11
11
  expose :metadata, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
12
+ expose :settings, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
12
13
  expose_timestamps(timestamp_fields: %i[released_at created_at updated_at sync_time])
13
14
  end
14
15
  end
@@ -20,6 +20,7 @@ module Labimotion
20
20
 
21
21
  def process_fields(key, layer)
22
22
  process_sample_and_molecule_fields(key, layer)
23
+ process_linked_attr_fields(key, layer)
23
24
  process_reaction_fields(key, layer)
24
25
  process_table_fields(key, layer)
25
26
  # process_voc_fields(key, layer)
@@ -32,6 +33,18 @@ module Labimotion
32
33
  end
33
34
  end
34
35
 
36
+ # Resolve the linked attributes a user chose to surface inline on a drag field
37
+ # (stored as value['el_attrs']). Only the selection is persisted; the current
38
+ # values are refreshed here from the linked element on every load. Works for
39
+ # drag_element (generic element or any permit-target type) as well as
40
+ # drag_sample / drag_molecule.
41
+ def process_linked_attr_fields(key, layer)
42
+ select_fields(layer, [Labimotion::FieldType::DRAG_ELEMENT, Labimotion::FieldType::DRAG_SAMPLE,
43
+ Labimotion::FieldType::DRAG_MOLECULE]).each do |field, idx|
44
+ update_linked_attr_field(key, field, idx)
45
+ end
46
+ end
47
+
35
48
  def process_reaction_fields(key, layer)
36
49
  select_fields(layer, [Labimotion::FieldType::SYS_REACTION]).each do |field, idx|
37
50
  update_reaction_field(key, field, idx)
@@ -80,6 +93,21 @@ module Labimotion
80
93
  })
81
94
  end
82
95
 
96
+ def update_linked_attr_field(key, field, idx)
97
+ return unless field['value'].is_a?(Hash)
98
+
99
+ attrs = field.dig('value', 'el_attrs')
100
+ return unless attrs.is_a?(Array) && attrs.present?
101
+ return unless object.properties.dig(Labimotion::Prop::LAYERS, key, Labimotion::Prop::FIELDS, idx,
102
+ 'value').present?
103
+
104
+ el_klass = field.dig('value', 'el_klass')
105
+ el_id = field.dig('value', 'el_id')
106
+ el_type = field.dig('value', 'el_type')
107
+ object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx]['value']['el_attrs'] =
108
+ Labimotion::LinkedElement.new(el_klass, el_id, el_type).resolve(attrs)
109
+ end
110
+
83
111
  def update_reaction_field(key, field, idx)
84
112
  return unless field['value'].is_a?(Hash)
85
113
 
@@ -9,9 +9,17 @@ module Labimotion
9
9
  expose :properties, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
10
10
  expose :properties_release, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
11
11
  expose :metadata, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
12
+ expose :settings, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
12
13
 
13
14
  def klass_label
14
15
  object.segment_klass.label
15
16
  end
17
+
18
+ # Saved segments are serialized without their klass, so surface the
19
+ # template-scoped settings here; guarded for hosts not yet migrated.
20
+ def settings
21
+ klass = object.segment_klass
22
+ klass.respond_to?(:settings) ? klass.settings || {} : {}
23
+ end
16
24
  end
17
25
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labimotion/entities/application_entity'
4
+ module Labimotion
5
+ # Per-user, per-klass UI setting entity
6
+ class UserKlassSettingEntity < Labimotion::ApplicationEntity
7
+ expose :id, :klass, :klass_identifier, :klass_label, :settings
8
+ expose_timestamps(timestamp_fields: %i[created_at])
9
+ end
10
+ end
@@ -36,6 +36,15 @@ module Labimotion
36
36
  raise e
37
37
  end
38
38
 
39
+ def update_klass_settings(params)
40
+ klz = fetch_klass(params[:klass], params[:id])
41
+ klz&.update!(settings: (klz.settings || {}).merge(params[:settings] || {}))
42
+ klz
43
+ rescue StandardError => e
44
+ Labimotion.log_exception(e, current_user)
45
+ raise e
46
+ end
47
+
39
48
  def delete_klass(params)
40
49
  authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
41
50
  klz = fetch_klass(params[:klass], params[:id])
@@ -14,6 +14,7 @@ module Labimotion
14
14
  optional :klass_prefix, type: String, desc: 'Klass klass_prefix'
15
15
  optional :icon_name, type: String, desc: 'Klass icon_name'
16
16
  optional :metadata, type: Hash, desc: 'Klass metadata'
17
+ optional :settings, type: Hash, desc: 'Klass settings'
17
18
  requires :properties_template, type: Hash, desc: 'Klass template'
18
19
  optional :properties_release, type: Hash, desc: 'Klass release'
19
20
  optional :released_at, type: DateTime, desc: 'Klass released_at'
@@ -72,6 +73,7 @@ module Labimotion
72
73
  requires :label, type: String, desc: 'Klass label'
73
74
  optional :desc, type: String, desc: 'Klass desc'
74
75
  optional :metadata, type: Hash, desc: 'Klass metadata'
76
+ optional :settings, type: Hash, desc: 'Klass settings'
75
77
  requires :properties_template, type: Hash, desc: 'Klass template'
76
78
  optional :properties_release, type: Hash, desc: 'Klass release'
77
79
  optional :released_at, type: DateTime, desc: 'Klass released_at'
@@ -91,6 +93,7 @@ module Labimotion
91
93
  optional :desc, type: String, desc: 'Segment Klass Desc'
92
94
  optional :place, type: String, desc: 'Segment Klass Place', default: '100'
93
95
  optional :identifier, type: String, desc: 'Segment Identifier'
96
+ optional :metadata, type: Hash, desc: 'Klass metadata'
94
97
  end
95
98
 
96
99
  params :create_segment_klass_params do
@@ -152,5 +155,17 @@ module Labimotion
152
155
  requires :id, type: Integer, desc: 'Element ID'
153
156
  requires :wellplate_ids, type: Array, desc: 'Selected Wellplates'
154
157
  end
158
+
159
+ params :user_klass_settings_save do
160
+ requires :klass, type: String, values: %w[ElementKlass SegmentKlass DatasetKlass], desc: 'Klass type'
161
+ requires :klass_identifier, type: String, desc: 'Stable klass identifier'
162
+ optional :klass_label, type: String, desc: 'Klass label (display only)'
163
+ requires :settings, type: Hash do
164
+ optional :toolbar, type: Hash do
165
+ optional :overview, type: Boolean, default: true
166
+ optional :arrange, type: Boolean, default: true
167
+ end
168
+ end
169
+ end
155
170
  end
156
171
  end
@@ -125,6 +125,7 @@ module Labimotion
125
125
  tmp_file.rewind
126
126
 
127
127
  filename = oat.filename
128
+ # name = "#{File.basename(filename, '.*')}.zip"
128
129
  name = "#{File.basename(filename, '.*')}#{File.extname(filename) == '.zip' ? '.bagit.zip' : '.zip'}"
129
130
  att = Attachment.new(
130
131
  filename: name,
@@ -246,22 +247,14 @@ module Labimotion
246
247
  dsr.each do |ds|
247
248
  layer = layers[ds[:layer]]
248
249
  next if layer.blank? || layer[Labimotion::Prop::FIELDS].blank?
249
- is_unit = ds[:field].start_with?(Prop::CONVERTER_FIELD_UINT_PREFIX)
250
- next if is_unit && ds[:value].nil?
251
250
 
252
- field_name = ds[:field].delete_prefix(Prop::CONVERTER_FIELD_UINT_PREFIX)
253
-
254
- fields = layer[Labimotion::Prop::FIELDS].select{ |f| f['field'] == field_name }
251
+ fields = layer[Labimotion::Prop::FIELDS].select{ |f| f['field'] == ds[:field] }
255
252
  fi = fields&.first
256
253
  next if fi.blank?
257
254
 
258
255
  idx = layer[Labimotion::Prop::FIELDS].find_index(fi)
259
- if is_unit
260
- fi['value_system'] = ds[:value]
261
- else
262
- fi['value'] = ds[:value]
263
- fi['device'] = ds[:device] || ds[:value]
264
- end
256
+ fi['value'] = ds[:value]
257
+ fi['device'] = ds[:device] || ds[:value]
265
258
  new_prop[Labimotion::Prop::LAYERS][ds[:layer]][Labimotion::Prop::FIELDS][idx] = fi
266
259
  end
267
260
  element = Container.find(dataset.element_id)&.root_element
@@ -327,37 +320,6 @@ module Labimotion
327
320
  res
328
321
  end
329
322
 
330
- def self.test_conversions(tmpfile, format)
331
- res = {}
332
- File.open(tmpfile.path, 'r') do |file|
333
- body = { file: file, format: format }
334
- response = HTTParty.post(
335
- uri('conversions'),
336
- basic_auth: auth,
337
- body: body,
338
- timeout: timeout,
339
- )
340
- res = response.parsed_response
341
- end
342
- res
343
- end
344
-
345
- def self.restore(profile_id, version, hard)
346
- body = { hard: hard }
347
- response = HTTParty.post(
348
- uri("profiles/restore/#{profile_id}/#{version}"),
349
- headers: {
350
- "Content-Type" => "application/json"
351
- },
352
- basic_auth: auth,
353
- body: body.to_json,
354
- timeout: timeout,
355
- )
356
- res = response.parsed_response
357
-
358
- res
359
- end
360
-
361
323
  def self.metadata(id, current_user)
362
324
  att = Attachment.find(id)
363
325
  return if att.nil? || att.attachable_id.nil? || att.attachable_type != Labimotion::Prop::CONTAINER
@@ -372,4 +334,4 @@ end
372
334
  # rubocop: enable Metrics/AbcSize
373
335
  # rubocop: enable Metrics/MethodLength
374
336
  # rubocop: enable Metrics/ClassLength
375
- # rubocop: enable Metrics/CyclomaticComplexity
337
+ # rubocop: enable Metrics/CyclomaticComplexity
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ # Resolves the "inline attributes" of an element linked from a generic
5
+ # drag_element field.
6
+ #
7
+ # A drag_element link target is identified by the stored value's +el_klass+:
8
+ # * a generic element klass name -> the target is a Labimotion::Element and
9
+ # its selectable attributes are its own property fields (layer + field);
10
+ # * a permit-target key (reaction, sample, molecule, wellplate, screen,
11
+ # research_plan, device_description, cell_line) -> the target is a standard
12
+ # ELN model and its selectable attributes are a curated set of database columns.
13
+ #
14
+ # Used both to list the attributes a user can pick (#available) and to refresh
15
+ # the current values of a previously stored selection (#resolve).
16
+ class LinkedElement
17
+ SOURCE_PROPERTY = 'property'
18
+ SOURCE_COLUMN = 'column'
19
+
20
+ # el_klass (permit-target key) => model class name
21
+ MODELS = {
22
+ 'reaction' => '::Reaction',
23
+ 'sample' => '::Sample',
24
+ 'molecule' => '::Molecule',
25
+ 'wellplate' => '::Wellplate',
26
+ 'screen' => '::Screen',
27
+ 'research_plan' => '::ResearchPlan',
28
+ 'device_description' => '::DeviceDescription',
29
+ 'cell_line' => '::CelllineSample'
30
+ }.freeze
31
+
32
+ # Curated, scalar columns exposed per type: [column/method, label, unit_column?]
33
+ COLUMNS = {
34
+ 'reaction' => [
35
+ %w[name Name], %w[short_label], ['status', 'Status'], ['role', 'Role'],
36
+ ['rxno', 'Reaction Type'], ['conditions', 'Conditions'],
37
+ ['duration', 'Duration'], %w[solvent Solvent]
38
+ ],
39
+ 'sample' => [
40
+ %w[name Name], %w[short_label], ['external_label', 'External Label'],
41
+ ['sum_formula', 'Sum Formula'], ['molecular_mass', 'Molecular Mass'],
42
+ %w[purity Purity], %w[density Density],
43
+ ['real_amount_value', 'Real Amount', 'real_amount_unit'],
44
+ ['target_amount_value', 'Target Amount', 'target_amount_unit'],
45
+ %w[location Location]
46
+ ],
47
+ 'molecule' => [
48
+ ['iupac_name', 'IUPAC Name'], ['sum_formular', 'Sum Formula'],
49
+ ['molecular_weight', 'Molecular Weight'],
50
+ ['exact_molecular_weight', 'Exact Molecular Weight'],
51
+ ['cano_smiles', 'Canonical SMILES'], ['melting_point', 'Melting Point'],
52
+ ['boiling_point', 'Boiling Point'], %w[density Density]
53
+ ],
54
+ 'wellplate' => [
55
+ %w[name Name], %w[short_label], ['description', 'Description'],
56
+ %w[width Width], %w[height Height]
57
+ ],
58
+ 'screen' => [
59
+ %w[name Name], ['description', 'Description'], %w[result Result],
60
+ %w[collaborator Collaborator], ['conditions', 'Conditions'],
61
+ %w[requirements Requirements]
62
+ ],
63
+ 'research_plan' => [
64
+ %w[name Name], %w[short_label]
65
+ ],
66
+ 'device_description' => [
67
+ %w[name Name], %w[short_label], ['serial_number', 'Serial Number'],
68
+ ['device_class', 'Device Class'], ['operation_mode', 'Operation Mode'],
69
+ ['application_name', 'Application Name'], ['vendor_url', 'Vendor URL'],
70
+ %w[institute Institute], %w[building Building], %w[room Room]
71
+ ],
72
+ 'cell_line' => [
73
+ ['material_name', 'Cell line name'], ['name', 'Name of specific sample'],
74
+ %w[short_label], %w[description Description],
75
+ %w[amount Amount unit], %w[passage Passage],
76
+ %w[contamination Contamination]
77
+ ]
78
+ }.freeze
79
+
80
+ # Property field types whose value is a scalar worth surfacing inline.
81
+ PROPERTY_TYPES = %w[
82
+ text textarea number integer select select-multi checkbox
83
+ datetime system-defined formula-field text-formula ontology-select
84
+ ].freeze
85
+
86
+ def initialize(el_klass, el_id, el_type = nil)
87
+ @el_klass = el_klass.to_s
88
+ @el_type = el_type.to_s
89
+ @el_id = el_id
90
+ end
91
+
92
+ # drag_element keeps the linked class in +el_klass+; drag_sample / drag_molecule
93
+ # keep it in +el_type+ (and may omit el_klass). Resolve one effective dispatch key.
94
+ def target_key
95
+ return @target_key if defined?(@target_key)
96
+
97
+ @target_key =
98
+ if MODELS.key?(@el_klass) || generic_klass?(@el_klass)
99
+ @el_klass
100
+ elsif MODELS.key?(@el_type)
101
+ @el_type
102
+ else
103
+ ''
104
+ end
105
+ end
106
+
107
+ def column_based?
108
+ MODELS.key?(target_key)
109
+ end
110
+
111
+ def generic?
112
+ generic_klass?(target_key)
113
+ end
114
+
115
+ def record
116
+ return @record if defined?(@record)
117
+
118
+ @record =
119
+ if column_based?
120
+ MODELS[target_key].safe_constantize&.find_by(id: @el_id)
121
+ elsif generic?
122
+ Labimotion::Element.find_by(id: @el_id)
123
+ end
124
+ end
125
+
126
+ # All attributes the user can pick, each with its current value.
127
+ def available
128
+ return generic_attributes if generic?
129
+ return column_attributes if column_based?
130
+
131
+ []
132
+ end
133
+
134
+ # Refresh the values/labels of a previously stored selection. Selections whose
135
+ # source attribute no longer exists are kept but flagged as missing; for an
136
+ # unknown/unsupported el_klass the selection is returned untouched.
137
+ def resolve(selected)
138
+ return selected unless selected.is_a?(Array)
139
+ return selected unless generic? || column_based?
140
+
141
+ index = available.index_by { |a| [a['source'], a['key']] }
142
+ selected.map do |attr|
143
+ next attr unless attr.is_a?(Hash)
144
+
145
+ index[[attr['source'], attr['key']]] || attr.merge('value' => nil, 'missing' => true)
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ def generic_klass?(name)
152
+ name.present? && !MODELS.key?(name) && Labimotion::ElementKlass.exists?(name: name)
153
+ end
154
+
155
+ def generic_attributes
156
+ el = record
157
+ return [] unless el&.properties.is_a?(Hash)
158
+
159
+ attrs = []
160
+ layers = el.properties[Labimotion::Prop::LAYERS] || {}
161
+ layers.values.sort_by { |layer| layer['position'] || 0 }.each do |layer|
162
+ (layer[Labimotion::Prop::FIELDS] || []).each do |field|
163
+ next unless PROPERTY_TYPES.include?(field['type'])
164
+
165
+ attr = {
166
+ 'source' => SOURCE_PROPERTY,
167
+ 'key' => "#{layer['key']}::#{field['field']}",
168
+ 'label' => field['label'].presence || field['field'],
169
+ 'type' => field['type'],
170
+ 'value' => field['value'],
171
+ 'group' => layer['label'].presence || layer['key']
172
+ }
173
+ attr['value_system'] = field['value_system'] if field['value_system'].present?
174
+ attrs << attr
175
+ end
176
+ end
177
+ attrs
178
+ end
179
+
180
+ def column_attributes
181
+ rec = record
182
+ return [] if rec.blank?
183
+
184
+ (COLUMNS[target_key] || []).filter_map do |col, label, unit_col|
185
+ next unless rec.respond_to?(col)
186
+
187
+ attr = {
188
+ 'source' => SOURCE_COLUMN,
189
+ 'key' => col,
190
+ 'label' => label.presence || col.to_s.humanize,
191
+ 'type' => SOURCE_COLUMN,
192
+ 'value' => rec.public_send(col),
193
+ 'group' => target_key
194
+ }
195
+ attr['value_system'] = rec.public_send(unit_col) if unit_col && rec.respond_to?(unit_col) && rec.public_send(unit_col).present?
196
+ attr
197
+ end
198
+ end
199
+ end
200
+ end
@@ -109,6 +109,11 @@ module Labimotion
109
109
  sid = field.dig('value', 'el_id')
110
110
  next if element.nil? || sid.blank? || sid == element.id
111
111
 
112
+ # drag_element may link a standard element (reaction, cell_line, ...);
113
+ # el_id is then NOT a Labimotion::Element id, so an ElementsElement row
114
+ # would associate an unrelated generic element that shares the id.
115
+ next if Labimotion::LinkedElement::MODELS.key?(field.dig('value', 'el_klass').to_s)
116
+
112
117
  el = Labimotion::Element.find_by(id: sid)
113
118
  next if el.nil?
114
119
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file extends the existing CelllineSample model in the consuming app
4
+ # and defines a Labimotion::CellLine wrapper for convenient access.
5
+ # NB: the ELN host model is ::CelllineSample, but the wrapper is named
6
+ # Labimotion::CellLine so that "cell_line".camelize == "CellLine" resolves
7
+ # to it in the search_basic_by_like endpoint.
8
+
9
+ # rubocop:disable Style/RedundantConstantBase
10
+
11
+ ActiveSupport.on_load(:active_record) do
12
+ if defined?(::CelllineSample)
13
+ ::CelllineSample.class_eval do
14
+ include Labimotion::ElementFetchable
15
+
16
+ def self.element_klass_name
17
+ 'cell_line'
18
+ end
19
+ end
20
+ else
21
+ warn '[Labimotion] CelllineSample is not defined when Labimotion extension was loaded.'
22
+ end
23
+ end
24
+
25
+ # Namespace wrapper to keep your preferred call style
26
+ module Labimotion
27
+ module CellLine
28
+ class << self
29
+ # Delegate class methods to ::CelllineSample.
30
+ # ruby2_keywords is required so keyword arguments survive the *args
31
+ # round-trip on Ruby >= 3.0 (e.g. fetch_for_user(id, name:, limit:)).
32
+ ruby2_keywords def method_missing(method, *args, &block)
33
+ if ::CelllineSample.respond_to?(method)
34
+ ::CelllineSample.public_send(method, *args, &block)
35
+ else
36
+ super
37
+ end
38
+ end
39
+
40
+ def respond_to_missing?(method, include_private = false)
41
+ ::CelllineSample.respond_to?(method, include_private) || super
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ # rubocop:enable Style/RedundantConstantBase
@@ -96,6 +96,8 @@ module Labimotion
96
96
  end
97
97
 
98
98
  def touch_analyses_properties(current_user)
99
+ return unless respond_to?(:analyses)
100
+
99
101
  analyses.each do |analysis|
100
102
  analysis.children.each do |child|
101
103
  dataset = child.dataset
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ # Per-user, per-klass UI settings (e.g. toolbar Overview/Arrange visibility).
5
+ # Scoped to the owning user via created_by; at most one row per
6
+ # (created_by, klass, klass_identifier) — see the unique index. Keyed on the
7
+ # stable klass identifier so it applies to every instance of that klass,
8
+ # across template versions.
9
+ class UserKlassSetting < ApplicationRecord
10
+ self.table_name = :user_klass_settings
11
+ end
12
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.3.0.rc5'
5
+ VERSION = '2.4.0.rc2'
6
6
  end
data/lib/labimotion.rb CHANGED
@@ -28,6 +28,7 @@ module Labimotion
28
28
  autoload :StandardLayerAPI, 'labimotion/apis/standard_layer_api'
29
29
  autoload :VocabularyAPI, 'labimotion/apis/vocabulary_api'
30
30
  autoload :UserAPI, 'labimotion/apis/user_api'
31
+ autoload :UserKlassSettingsAPI, 'labimotion/apis/user_klass_settings_api'
31
32
  autoload :MttAPI, 'labimotion/apis/mtt_api'
32
33
  autoload :ElementVariationAPI, 'labimotion/apis/element_variation_api'
33
34
  autoload :LabimotionDoiAPI, 'labimotion/apis/labimotion_doi_api'
@@ -56,6 +57,7 @@ module Labimotion
56
57
  ## autoload :DatasetRevisionEntity, 'labimotion/entities/dataset_revision_entity'
57
58
  autoload :VocabularyEntity, 'labimotion/entities/vocabulary_entity'
58
59
  autoload :UserEntity, 'labimotion/entities/user_entity'
60
+ autoload :UserKlassSettingEntity, 'labimotion/entities/user_klass_setting_entity'
59
61
  autoload :ElementVariationEntity, 'labimotion/entities/element_variation_entity'
60
62
  autoload :LabimotionTemplateDoiEntity, 'labimotion/entities/labimotion_template_doi_entity'
61
63
 
@@ -89,6 +91,7 @@ module Labimotion
89
91
  autoload :ExportDataset, 'labimotion/libs/export_dataset'
90
92
  autoload :SampleAssociation, 'labimotion/libs/sample_association'
91
93
  autoload :PropertiesHandler, 'labimotion/libs/properties_handler'
94
+ autoload :LinkedElement, 'labimotion/libs/linked_element'
92
95
  autoload :AttachmentHandler, 'labimotion/libs/attachment_handler'
93
96
  autoload :VocabularyHandler, 'labimotion/libs/vocabulary_handler'
94
97
  autoload :XlsxExporter, 'labimotion/libs/xlsx_exporter'
@@ -133,7 +136,9 @@ module Labimotion
133
136
 
134
137
  autoload :StdLayer, 'labimotion/models/std_layer'
135
138
  autoload :StdLayersRevision, 'labimotion/models/std_layers_revision'
139
+ autoload :UserKlassSetting, 'labimotion/models/user_klass_setting'
136
140
 
141
+ autoload :CellLine, 'labimotion/models/cellline'
137
142
  autoload :DeviceDescription, 'labimotion/models/device_description'
138
143
  autoload :DoseRespRequest, 'labimotion/models/dose_resp_request'
139
144
  autoload :DoseRespOutput, 'labimotion/models/dose_resp_output'
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.3.0.rc5
4
+ version: 2.4.0.rc2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chia-Lin Lin
@@ -86,6 +86,7 @@ files:
86
86
  - lib/labimotion/apis/standard_api.rb
87
87
  - lib/labimotion/apis/standard_layer_api.rb
88
88
  - lib/labimotion/apis/user_api.rb
89
+ - lib/labimotion/apis/user_klass_settings_api.rb
89
90
  - lib/labimotion/apis/vocabulary_api.rb
90
91
  - lib/labimotion/apis/wellplate_api.rb
91
92
  - lib/labimotion/collection/export.rb
@@ -110,6 +111,7 @@ files:
110
111
  - lib/labimotion/entities/segment_klass_entity.rb
111
112
  - lib/labimotion/entities/segment_revision_entity.rb
112
113
  - lib/labimotion/entities/user_entity.rb
114
+ - lib/labimotion/entities/user_klass_setting_entity.rb
113
115
  - lib/labimotion/entities/vocabulary_entity.rb
114
116
  - lib/labimotion/helpers/converter_helpers.rb
115
117
  - lib/labimotion/helpers/dataset_helpers.rb
@@ -136,6 +138,7 @@ files:
136
138
  - lib/labimotion/libs/export_element.rb
137
139
  - lib/labimotion/libs/export_element_variations.rb
138
140
  - lib/labimotion/libs/import_element_variations.rb
141
+ - lib/labimotion/libs/linked_element.rb
139
142
  - lib/labimotion/libs/nmr_mapper.rb
140
143
  - lib/labimotion/libs/properties_handler.rb
141
144
  - lib/labimotion/libs/sample_association.rb
@@ -143,6 +146,7 @@ files:
143
146
  - lib/labimotion/libs/template_matcher.rb
144
147
  - lib/labimotion/libs/vocabulary_handler.rb
145
148
  - lib/labimotion/libs/xlsx_exporter.rb
149
+ - lib/labimotion/models/cellline.rb
146
150
  - lib/labimotion/models/collections_element.rb
147
151
  - lib/labimotion/models/concerns/attachment_converter.rb
148
152
  - lib/labimotion/models/concerns/datasetable.rb
@@ -182,6 +186,7 @@ files:
182
186
  - lib/labimotion/models/std_layer.rb
183
187
  - lib/labimotion/models/std_layers_revision.rb
184
188
  - lib/labimotion/models/template_submission.rb
189
+ - lib/labimotion/models/user_klass_setting.rb
185
190
  - lib/labimotion/models/vocabulary.rb
186
191
  - lib/labimotion/models/wellplate.rb
187
192
  - lib/labimotion/usecases/build_template_doi_xml.rb