labimotion 2.4.0.rc1 → 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: 20affa359afd5082ac4bc5e900b3e66c2c537a4b83fc9032883e33f25dc60362
4
- data.tar.gz: e8bb4ae665e5de5166790ac27ecf42a95394057a2fd93d5deebc95dc858a5189
3
+ metadata.gz: 4c92bee7516a80229220d269bcc9b53d700df17b0fa78f98857476f1bdd9ab02
4
+ data.tar.gz: 70ebf34d13b72d35eaeb4a05e039977add30227afe2acef0c00f1d434f2ae110
5
5
  SHA512:
6
- metadata.gz: 7e6d407b44837ccb8758aa2a86b6c4b109350dfa50dde7ebb3affae93b070c1efba512b4db2b08f0608b165a25c1a53c4caaa3450f55c1a15e5c1401a1ca87de
7
- data.tar.gz: 60c1e3df69128dda039990086d98ad816e7fcedbf74e1a90b896b9b94eb7446b3e5feb613e42770be1f8807d72e5ee3a7c07ec8eb8e158b776d5904335d07671
6
+ metadata.gz: 15952d33d6cceadfc8a3bb21e020e0d924b27e127e84b4fa5667e98bb34a691969d19b642d8624f233ab9c9f18adb2c36d45920594f73fc56296d2a0590c72f5
7
+ data.tar.gz: 2d47c8be30f7ecbbe68db65e8a6979ed7e0d8a1edff6fdaa6fe0dcf1cafbaf1e914f085fdde09385b6546f4be1ba8e0de0830a94584d9aee75d45738ba5f384a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # LabIMotion Changelog
2
2
 
3
+ ## [Unreleased]
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.
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`.
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.
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.
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.
14
+ * Import warnings name the cell they came from (`Cell I6: ...`), so the user can go straight to it in the spreadsheet.
15
+ * Bug fixes:
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.
21
+ * Dependencies:
22
+ * Added `roo` (~> 2.10) for reading the uploaded workbook.
23
+
3
24
  ## [2.0.0]
4
25
  > 2025-03-25
5
26
  * Features and enhancements:
@@ -1,15 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'uri'
4
+
3
5
  module Labimotion
4
6
  class ElementVariationAPI < Grape::API
7
+ XLSX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
8
+
5
9
  rescue_from ActiveRecord::RecordNotFound do
6
10
  error!('404 Element not found', 404)
7
11
  end
8
12
 
13
+ helpers do
14
+ def configure_xlsx_response(filename)
15
+ env['api.format'] = :binary
16
+ content_type XLSX_CONTENT_TYPE
17
+ encoded = URI.encode_www_form_component(filename)
18
+ header('Content-Disposition', "attachment; filename=\"#{filename}\"; filename*=UTF-8''#{encoded}")
19
+ end
20
+
21
+ def uploaded_xlsx_path!(file)
22
+ filename = file[:filename].to_s
23
+ error!('400 Bad Request - expected an .xlsx file', 400) unless filename.downcase.end_with?('.xlsx')
24
+
25
+ tempfile = file[:tempfile]
26
+ error!('400 Bad Request - upload is empty', 400) if tempfile.nil?
27
+
28
+ tempfile.path
29
+ end
30
+ end
31
+
9
32
  resource :element_variations do
10
33
  params do
11
34
  requires :element_id, type: Integer, desc: 'Generic element id'
12
35
  end
36
+ # rubocop:disable Metrics/BlockLength
13
37
  route_param :element_id do
14
38
  before do
15
39
  @element = Labimotion::Element.find(params[:element_id])
@@ -32,14 +56,45 @@ module Labimotion
32
56
 
33
57
  record = Labimotion::ElementVariation.find_or_initialize_by(element_id: @element.id)
34
58
  record.variations = params[:variations] || {}
35
- if params.key?(:layout) && record.class.column_names.include?('layout')
36
- record.layout = params[:layout] || {}
37
- end
59
+ record.layout = params[:layout] || {} if params.key?(:layout) && record.class.column_names.include?('layout')
38
60
  record.save!
39
61
 
40
62
  present record, with: Labimotion::ElementVariationEntity, root: 'element_variation'
41
63
  end
64
+
65
+ desc 'Export element variations as an xlsx workbook'
66
+ get :export do
67
+ exporter = Labimotion::ExportElementVariations.new(@element, user: current_user)
68
+ configure_xlsx_response(exporter.filename)
69
+ exporter.read
70
+ rescue StandardError => e
71
+ Labimotion.log_exception(e, current_user)
72
+ error!("500 Internal Server Error: #{e.message}", 500)
73
+ end
74
+
75
+ desc 'Import element variations from an xlsx workbook produced by the export'
76
+ params do
77
+ requires :file, type: File, desc: 'xlsx file'
78
+ end
79
+ post :import do
80
+ error!('401 Unauthorized', 401) unless ElementPolicy.new(current_user, @element).update?
81
+
82
+ path = uploaded_xlsx_path!(params[:file])
83
+ importer = Labimotion::ImportElementVariations.new(@element, path)
84
+ record = importer.execute!
85
+
86
+ {
87
+ element_variation: Labimotion::ElementVariationEntity.represent(record),
88
+ warnings: importer.warnings
89
+ }
90
+ rescue Labimotion::ImportElementVariations::InvalidWorkbook => e
91
+ error!(e.message, 422)
92
+ rescue StandardError => e
93
+ Labimotion.log_exception(e, current_user)
94
+ error!("500 Internal Server Error: #{e.message}", 500)
95
+ end
42
96
  end
97
+ # rubocop:enable Metrics/BlockLength
43
98
  end
44
99
  end
45
100
  end
@@ -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
@@ -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'
@@ -153,5 +155,17 @@ module Labimotion
153
155
  requires :id, type: Integer, desc: 'Element ID'
154
156
  requires :wellplate_ids, type: Array, desc: 'Selected Wellplates'
155
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
156
170
  end
157
171
  end