labimotion 2.3.2 → 2.3.4

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: b2682c1c72815cd6376ed94350afc4289a414a4f09c71b9788230ec4430b3f8f
4
- data.tar.gz: 603d30166812d812c7c03fdc3c985cc78b2f47965ec76abbfa4481f07d41b88e
3
+ metadata.gz: 7f2a9943b6c1fd9c1d0e09f9b2917985db173634db909a8a3eb3b80ad99d61d4
4
+ data.tar.gz: 828d2be1b26596f926f2e7f4ff06a05925ef7cae8dad9db06f5318b5c5ce527e
5
5
  SHA512:
6
- metadata.gz: b5dae89c2091aa2f7f4e6b59079d13f3c777c07ba0d0891ecc97daa51b8eb571e90b9517c332d035438aaa45d671184b8d0090db382d3b23fa6e96568cf0e940
7
- data.tar.gz: d66337f46db2b8d49e170bd7078cfae5b45d31fcd8deb92d0c625a09037d4858f21c919339d2547500f5bef0aec67728b7221adb73652961e8ae07c81336aee2
6
+ metadata.gz: 2566c6dc15a6acc91cb3cbc9264946b34c7a99be61c02a5916aea8de366bbc7e0b928319ff63b994b0b5fe48d82b6a5ee1a9c8a7e19b4e2a158471a9b9436a2f
7
+ data.tar.gz: ee020ae64b55ff37621cecc819a4675808b815530b3aa8bcea2c49a3b5aa2107779a1297424f986648ecdd2c477adbf5dcdc72b7327430bbe2091ef63414611c
data/CHANGELOG.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # LabIMotion Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [2.3.4]
4
+ > Unreleased
5
+ * Bug fixes:
6
+ * `upload_generics_files` stays on the element it was authorized for ([chemotion#684](https://github.com/ComPlat/chemotion/issues/684)). `delfiles` detached any generic-element attachment by id; it now only detaches the updated element's own. Uploads take `ElementProps`/`SegmentProps` from the server instead of the `type` the client sends in `elInfo`/`seInfo`, and the segment lookup also matches the element type, so an upload authorized for sample 5 no longer reaches the segment of reaction 5 or generic element 5. `att_type` is limited to `Element`, `Sample`, `Reaction`, `ResearchPlan` and `Screen` (400 otherwise, instead of `constantize` on the request value; the client's `CellLineSample`, which already failed in `constantize`, now gets the 400). `elfiles`, `attfiles` and `delfiles` are only handled for `Element`: an `elfiles` upload authorized for a sample used to write into the generic element with the same id. No client change.
7
+ * `GET /generic_elements/export` checks read permission before building the docx (labimotion#358, port of labimotion#336). It exported any Element, Segment or Dataset by id to any signed-in user. It now answers 404 for a missing record and 401 unless the user passes the matrix check (`genericElement` for an element, `segment` for a segment, `genericDataset` for a dataset) and `ElementPolicy#read?` on the element that holds the record: the element itself, a segment's element, or the root element of a dataset's container. A dataset also needs `ElementPolicy#read_dataset?`, so a share below the dataset detail level cannot export one. The table xlsx export (`GET /exporter/table_xlsx/:id`), which already ran these checks for elements and segments, now shares them through the new `Labimotion::ExportAuthorizationHelpers`.
8
+ * A dataset the gem builds itself — by the converter, or through `Labimotion::DatasetBuilder` — stores the metadata of its klass's active release (labimotion#343, port of labimotion#342). Both builders stored the klass's own `metadata`, which follows every save, drafts included, so a dataset created after a draft save got the released layers with the draft's layer groups and group restrictions, and the form then used them. A klass with no revision matching its uuid still uses its own `metadata`. `Labimotion::ReleaseMetadata.for_klass` returns that value for one klass.
9
+
10
+ ## [2.3.3]
11
+ > 2026-10-19
12
+ * Bug fixes:
13
+ * New records and element Reload build from the release's metadata (labimotion#326, port of labimotion#321 and labimotion#324, [chem-generic-ui#1438](https://github.com/cllde8/chem-generic-ui/issues/1438), port of [chem-generic-ui#1430](https://github.com/cllde8/chem-generic-ui/issues/1430)). A klass's `metadata` follows every save, drafts included, so a new element, segment or dataset, and an element brought up to the release by Reload, combined the released layers with the draft's layer groups and restrictions. Klass payloads now carry `metadata_release`: the metadata of the klass's active release, read from its revision row, or `null` when no revision matches the klass's uuid. Sent by `generic_elements/klass`, `segments/list_segment_klass` (nested element klasses included), `generic_dataset/klasses`, and on the nested `element_klass` of the element `GET`, create and update responses, with one revision query per klass family; every other payload is unchanged. A client reading `metadata_release ?? metadata` builds from one version. Read-only: no migration.
14
+
15
+ ## [2.3.2]
16
+ > 2026-10-02
17
+ * Bug fixes:
18
+ * A copied element, segment or dataset keeps the template release its properties were built on (labimotion#301, labimotion#303). Saves used to stamp every new segment and every dataset save with the klass's current `uuid` and `properties_release`, and an element with whatever the client sent, so a copy of a v1.0.0 record was labelled v2.0.0 and the client never offered Reload. `Labimotion::KlassVersion` now resolves the submitted `properties['klass_uuid']` against the klass's current release and its `*_klasses_revisions`, deleted revisions included, and stores that uuid together with the matching `properties_release`. A new record whose uuid is missing or unknown follows the current release; an existing record keeps its stored uuid when none is sent, then the uuid its stored `properties_release` names, and follows the current release only when it has neither, and a uuid this instance never released (a record imported from an instance with its own release history) keeps the release stored with the record. A new element whose properties carry no uuid follows the template the client built it on; a new segment does the same, and a copied one falls back to the `klass_uuid` of the row it was copied from. The collection import stamps the release the archive names instead of the importing instance's current one: it tries the uuid inside the archived `properties_release`, then `properties['klass_uuid']`, then the `klass_uuid` column, and stores the first one this instance knows with the local release; when none resolves, the archived release keeps the uuid it names (or, naming none, the first uuid the archive carries), and the imported properties carry the same `klass_uuid` as the column. A `klass_uuid` that is not a string is ignored, and a dataset whose klass changed follows the new klass and ignores the client's release. Element create returns 404 when the template is missing or deleted, element update resolves a soft-deleted template (`with_deleted`) before writing anything, and segments and datasets of a deleted template are skipped on save. A new record whose `klass_uuid` this instance cannot resolve keeps it with the `properties_release` the client sent, only when that release names the same uuid. No migration and no client change.
19
+
20
+ ## [2.3.0]
21
+ > 2026-08-26
4
22
  * Features and enhancements:
5
23
  * 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
24
  * 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.
@@ -8,7 +26,6 @@
8
26
  * `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
27
  * Import warnings name the cell they came from (`Cell I6: ...`), so the user can go straight to it in the spreadsheet.
10
28
  * Bug fixes:
11
- * A copied element, segment or dataset keeps the template release its properties were built on (labimotion#301, labimotion#303). Saves used to stamp every new segment and every dataset save with the klass's current `uuid` and `properties_release`, and an element with whatever the client sent, so a copy of a v1.0.0 record was labelled v2.0.0 and the client never offered Reload. `Labimotion::KlassVersion` now resolves the submitted `properties['klass_uuid']` against the klass's current release and its `*_klasses_revisions`, deleted revisions included, and stores that uuid together with the matching `properties_release`. A new record whose uuid is missing or unknown follows the current release; an existing record keeps its stored uuid when none is sent, then the uuid its stored `properties_release` names, and follows the current release only when it has neither, and a uuid this instance never released (a record imported from an instance with its own release history) keeps the release stored with the record. A new element whose properties carry no uuid follows the template the client built it on; a new segment does the same, and a copied one falls back to the `klass_uuid` of the row it was copied from. The collection import stamps the release the archive names instead of the importing instance's current one: it tries the uuid inside the archived `properties_release`, then `properties['klass_uuid']`, then the `klass_uuid` column, and stores the first one this instance knows with the local release; when none resolves, the archived release keeps the uuid it names (or, naming none, the first uuid the archive carries), and the imported properties carry the same `klass_uuid` as the column. A `klass_uuid` that is not a string is ignored, and a dataset whose klass changed follows the new klass and ignores the client's release. Element create returns 404 when the template is missing or deleted, element update resolves a soft-deleted template (`with_deleted`) before writing anything, and segments and datasets of a deleted template are skipped on save. A new record whose `klass_uuid` this instance cannot resolve keeps it with the `properties_release` the client sent, only when that release names the same uuid. No migration and no client change.
12
29
  * `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.
13
30
  * Dependencies:
14
31
  * Added `roo` (~> 2.10) for reading the uploaded workbook.
@@ -8,6 +8,7 @@ module Labimotion
8
8
 
9
9
  helpers Labimotion::ParamHelpers
10
10
  helpers Labimotion::ExporterHelpers
11
+ helpers Labimotion::ExportAuthorizationHelpers
11
12
 
12
13
  resource :exporter do
13
14
  resource :table_xlsx do
@@ -46,9 +47,7 @@ module Labimotion
46
47
  klass = fetch_klass
47
48
  @element = klass.find_by(id: params[:id])
48
49
 
49
- error!('404 Not Found', 404) if @element.nil?
50
-
51
- authorize_element_access!
50
+ authorize_export!(params[:klass], @element)
52
51
  rescue ActiveRecord::RecordNotFound
53
52
  error!('404 Not Found', 404)
54
53
  rescue NameError => e
@@ -61,22 +60,6 @@ module Labimotion
61
60
  Labimotion::Utils.resolve_class(params[:klass])
62
61
  end
63
62
 
64
- # Authorize element access based on user permissions
65
- def authorize_element_access!
66
- element_policy = if params[:klass] == 'Element'
67
- ElementPolicy.new(current_user,
68
- @element)
69
- else
70
- ElementPolicy.new(current_user,
71
- @element.element)
72
- end
73
- matrix_name = params[:klass] == 'Element' ? 'genericElement' : 'segment'
74
-
75
- return if current_user.matrix_check_by_name(matrix_name) && element_policy.read?
76
-
77
- error!('401 Unauthorized', 401)
78
- end
79
-
80
63
  # Fetch layer from element properties
81
64
  def fetch_layer
82
65
  properties = @element.properties
@@ -13,7 +13,9 @@ module Labimotion
13
13
  desc 'get dataset klasses'
14
14
  get do
15
15
  list = klass_list(true, false)
16
- present list.sort_by(&:place), with: Labimotion::DatasetKlassEntity, root: 'klass'
16
+ # New datasets are built from these klasses, so they need the release's metadata.
17
+ present list.sort_by(&:place), with: Labimotion::DatasetKlassEntity, root: 'klass',
18
+ metadata_releases: Labimotion::ReleaseMetadata.map_for(list)
17
19
  end
18
20
  end
19
21
 
@@ -17,6 +17,7 @@ module Labimotion
17
17
  helpers Labimotion::GenericHelpers
18
18
  helpers Labimotion::ElementHelpers
19
19
  helpers Labimotion::ParamHelpers
20
+ helpers Labimotion::ExportAuthorizationHelpers
20
21
 
21
22
  resource :generic_elements do
22
23
  # might be removed because the file is moved to public folder
@@ -39,7 +40,9 @@ module Labimotion
39
40
  end
40
41
  get do
41
42
  ek = Labimotion::ElementKlass.find_by(name: params[:name])
42
- present ek, with: Labimotion::ElementKlassEntity, root: 'klass'
43
+ # The ELN builds a new element from this klass, so it needs the release's metadata.
44
+ present ek, with: Labimotion::ElementKlassEntity, root: 'klass',
45
+ metadata_releases: Labimotion::ReleaseMetadata.map_for([ek])
43
46
  rescue StandardError => e
44
47
  Labimotion.log_exception(e, current_user)
45
48
  { klass: [] }
@@ -127,14 +130,8 @@ module Labimotion
127
130
  optional :export_format, type: String, desc: 'export format'
128
131
  end
129
132
  get do
130
- case params[:klass]
131
- when 'Element'
132
- element = Labimotion::Element.find(params[:id])
133
- when 'Segment'
134
- element = Labimotion::Segment.find(params[:id])
135
- when 'Dataset'
136
- element = Labimotion::Dataset.find(params[:id])
137
- end
133
+ element = "Labimotion::#{params[:klass]}".constantize.find_by(id: params[:id])
134
+ authorize_export!(params[:klass], element)
138
135
  export = Labimotion::ExportElement.new current_user, element, params[:export_format]
139
136
  env['api.format'] = :binary
140
137
  content_type 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
@@ -262,14 +259,10 @@ module Labimotion
262
259
  desc 'upload generic files'
263
260
  params do
264
261
  requires :att_id, type: Integer, desc: 'Element Id'
265
- requires :att_type, type: String, desc: 'Element Type'
262
+ requires :att_type, type: String, values: Labimotion::Prop::UPLOAD_ELEMENT_TYPES.keys, desc: 'Element Type'
266
263
  end
267
264
  after_validation do
268
- if params[:att_type] == 'Sample' || params[:att_type] == 'Reaction' || params[:att_type] == 'ResearchPlan'
269
- el = "#{params[:att_type]}".constantize.find_by(id: params[:att_id])
270
- else
271
- el = "Labimotion::#{params[:att_type]}".constantize.find_by(id: params[:att_id])
272
- end
265
+ el = Labimotion::Prop::UPLOAD_ELEMENT_TYPES[params[:att_type]].constantize.find_by(id: params[:att_id])
273
266
  error!('401 Unauthorized', 401) if el.nil?
274
267
 
275
268
  policy_updatable = ElementPolicy.new(current_user, el).update?
@@ -474,7 +467,10 @@ module Labimotion
474
467
  element: Labimotion::ElementEntity.represent(
475
468
  element,
476
469
  detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: element).detail_levels,
477
- policy: @element_policy
470
+ policy: @element_policy,
471
+ # Reload brings the record up to the release, so it needs the release's metadata,
472
+ # not the latest save's.
473
+ metadata_releases: Labimotion::ReleaseMetadata.map_for([element.element_klass])
478
474
  ),
479
475
  attachments: Entities::AttachmentEntity.represent(element&.attachments)
480
476
  }
@@ -495,6 +491,7 @@ module Labimotion
495
491
  with: Labimotion::ElementEntity,
496
492
  root: :element,
497
493
  detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: element).detail_levels,
494
+ metadata_releases: Labimotion::ReleaseMetadata.map_for([element.element_klass])
498
495
  )
499
496
  rescue StandardError => e
500
497
  Labimotion.log_exception(e, current_user)
@@ -518,6 +515,7 @@ module Labimotion
518
515
  element: Labimotion::ElementEntity.represent(
519
516
  element,
520
517
  detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: element).detail_levels,
518
+ metadata_releases: Labimotion::ReleaseMetadata.map_for([element.element_klass])
521
519
  ),
522
520
  attachments: Entities::AttachmentEntity.represent(element&.attachments),
523
521
  }
@@ -26,7 +26,11 @@ module Labimotion
26
26
  end
27
27
  get do
28
28
  list = klass_list(nil, params[:is_active], false)
29
- present list, with: Labimotion::SegmentKlassEntity, root: 'klass', displayed_in_list: false
29
+ # New segments are built from these klasses. The nested element klasses read the same
30
+ # map, since they are serialized with the same options.
31
+ metadata_releases = Labimotion::ReleaseMetadata.map_for(list.to_a + list.map(&:element_klass))
32
+ present list, with: Labimotion::SegmentKlassEntity, root: 'klass', displayed_in_list: false,
33
+ metadata_releases: metadata_releases
30
34
  end
31
35
  end
32
36
 
@@ -32,7 +32,7 @@ module Labimotion
32
32
  module Tpa
33
33
  # Segments and layers sent along with reaction variations to a third-party
34
34
  # app (see ExportTpaSegments), as segment-klass label => layer keys.
35
- SEGMENT_LAYERS = { 'Mol interactions' => %w[binding_props bind_partners].freeze }.freeze
35
+ SEGMENT_LAYERS = { 'Mol interactions' => %w[general binding_props bind_partners].freeze }.freeze
36
36
  end
37
37
  end
38
38
  end
@@ -10,5 +10,16 @@ module Labimotion
10
10
  expose :properties_release, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
11
11
  expose :metadata, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
12
12
  expose_timestamps(timestamp_fields: %i[released_at created_at updated_at sync_time])
13
+
14
+ # The klass's own `metadata` follows every save, drafts included, so a client building a
15
+ # record from `properties_release` reads this instead: the metadata that release was cut
16
+ # with, or nil when no revision matches the klass's uuid (never released, or imported
17
+ # without one). Exposed only where the endpoint preloaded the map, so no other payload pays
18
+ # a revision query per row.
19
+ expose :metadata_release, **DISPLAYED_IN_LIST_CONDITION, if: :metadata_releases
20
+
21
+ def metadata_release
22
+ options[:metadata_releases][Labimotion::ReleaseMetadata.key_of(object)]
23
+ end
13
24
  end
14
25
  end
@@ -182,13 +182,18 @@ module Labimotion
182
182
 
183
183
  def upload_generics_files(current_user, params)
184
184
  attach_ary = []
185
+ element_type = Labimotion::Prop::UPLOAD_ELEMENT_TYPES.fetch(params[:att_type])
186
+ generic_element = element_type == Labimotion::Prop::L_ELEMENT
187
+ # Element properties and direct attachments only exist on generic elements; for any
188
+ # other att_type, att_id names a different table, so they are not touched.
185
189
  att_ary = create_uploads(
186
190
  Labimotion::Prop::ELEMENT,
187
191
  params[:att_id],
188
192
  params[:elfiles],
189
193
  params[:elInfo],
190
194
  current_user.id,
191
- ) if params[:elfiles].present? && params[:elInfo].present?
195
+ element_type
196
+ ) if generic_element && params[:elfiles].present? && params[:elInfo].present?
192
197
 
193
198
  (attach_ary << att_ary).flatten! unless att_ary&.empty?
194
199
 
@@ -197,16 +202,17 @@ module Labimotion
197
202
  params[:att_id],
198
203
  params[:sefiles],
199
204
  params[:seInfo],
200
- current_user.id
205
+ current_user.id,
206
+ element_type
201
207
  ) if params[:sefiles].present? && params[:seInfo].present?
202
208
 
203
209
  (attach_ary << att_ary).flatten! unless att_ary&.empty?
204
210
 
205
- if params[:attfiles].present? || params[:delfiles].present? then
211
+ if generic_element && (params[:attfiles].present? || params[:delfiles].present?)
206
212
  att_ary = create_attachments(
207
213
  params[:attfiles],
208
214
  params[:delfiles],
209
- "Labimotion::#{params[:att_type]}",
215
+ element_type,
210
216
  params[:att_id],
211
217
  params[:attfilesIdentifier],
212
218
  current_user.id
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ # Gate shared by the routes that export an Element, Segment or Dataset to a file.
5
+ # A segment or dataset is readable when the element that holds it is; a dataset
6
+ # sits on an analysis container, so its element is that container's root element.
7
+ module ExportAuthorizationHelpers
8
+ # Answers 404 for a missing record and 401 unless the user passes the matrix
9
+ # check for the record's family and may read the element that holds it.
10
+ def authorize_export!(klass_name, record)
11
+ error!('404 Not Found', 404) if record.nil?
12
+ return if export_readable?(klass_name, record)
13
+
14
+ error!('401 Unauthorized', 401)
15
+ end
16
+
17
+ # The matrix right that shows each family in the UI.
18
+ EXPORT_MATRIX = { 'Element' => 'genericElement', 'Segment' => 'segment', 'Dataset' => 'genericDataset' }.freeze
19
+
20
+ def export_readable?(klass_name, record)
21
+ return false unless current_user.matrix_check_by_name(EXPORT_MATRIX.fetch(klass_name))
22
+
23
+ policy = ElementPolicy.new(current_user, export_parent_element(klass_name, record))
24
+ return policy.read? unless klass_name == 'Dataset'
25
+
26
+ # A dataset holds analysis data, which a share below the dataset detail
27
+ # level hides.
28
+ policy.read? && policy.read_dataset?
29
+ end
30
+
31
+ def export_parent_element(klass_name, record)
32
+ case klass_name
33
+ when 'Element' then record
34
+ when 'Segment' then record.element
35
+ when 'Dataset' then record.element&.root_element
36
+ end
37
+ end
38
+ end
39
+ end
@@ -142,16 +142,20 @@ module Labimotion
142
142
  raise e
143
143
  end
144
144
 
145
- def create_uploads(type, id, files, param_info, user_id)
145
+ # element_type is the class name of the element that id names, so a segment
146
+ # lookup cannot reach another element type's segment with the same element_id.
147
+ def create_uploads(type, id, files, param_info, user_id, element_type)
146
148
  return if files.nil? || param_info.nil? || files.empty? || param_info.empty?
147
149
 
148
150
  attach_ary = []
149
151
  map_info = JSON.parse(param_info)
152
+ # Derived here, not taken from param_info, so the request cannot pick the attachable_type
153
+ attachable_type = type == Labimotion::Prop::SEGMENT ? Labimotion::Prop::SEGMENTPROPS : Labimotion::Prop::ELEMENTPROPS
150
154
  map_info&.keys&.each do |key|
151
155
  next if map_info[key]['files'].empty?
152
156
 
153
157
  if type == Labimotion::Prop::SEGMENT
154
- element = Labimotion::Segment.find_by(element_id: id, segment_klass_id: key)
158
+ element = Labimotion::Segment.find_by(element_type: element_type, element_id: id, segment_klass_id: key)
155
159
  elsif type == Labimotion::Prop::ELEMENT
156
160
  element = Labimotion::Element.find_by(id: id)
157
161
  end
@@ -172,7 +176,7 @@ module Labimotion
172
176
  created_by: user_id,
173
177
  created_for: user_id,
174
178
  content_type: file[:type],
175
- attachable_type: map_info[key]['type'],
179
+ attachable_type: attachable_type,
176
180
  attachable_id: element.id,
177
181
  )
178
182
  begin
@@ -221,7 +225,8 @@ module Labimotion
221
225
  end
222
226
  end
223
227
  unless (del_files || []).empty?
224
- Attachment.where('id IN (?) AND attachable_type = (?)', del_files.map!(&:to_i), type).update_all(attachable_id: nil)
228
+ # Scoped to the element, so ids of another element's attachments are left alone
229
+ Attachment.where(id: del_files.map(&:to_i), attachable_type: type, attachable_id: id).update_all(attachable_id: nil)
225
230
  end
226
231
  attach_ary
227
232
  rescue StandardError => e
@@ -262,7 +262,7 @@ module Labimotion
262
262
  properties: props,
263
263
  properties_release: klass.properties_release,
264
264
  klass_uuid: klass.uuid,
265
- metadata: klass.metadata
265
+ metadata: Labimotion::ReleaseMetadata.for_klass(klass)
266
266
  )
267
267
  end
268
268
 
@@ -45,7 +45,7 @@ module Labimotion
45
45
  properties: props,
46
46
  properties_release: klass.properties_release,
47
47
  klass_uuid: klass.uuid,
48
- metadata: klass.metadata
48
+ metadata: Labimotion::ReleaseMetadata.for_klass(klass)
49
49
  )
50
50
  end
51
51
 
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `underscore`, `constantize` and `index_by` are ActiveSupport core extensions, not plain
4
+ # Ruby. A booted host always has them; declaring them here keeps this file from depending on
5
+ # load order.
6
+ require 'active_support/core_ext/enumerable'
7
+ require 'active_support/core_ext/string/inflections'
8
+
9
+ module Labimotion
10
+ ## ReleaseMetadata
11
+ # Each klass's metadata as of its active release, for the `metadata_releases:` option the
12
+ # klass entities read and for records the gem builds itself. The klass row's own `metadata`
13
+ # follows every save, drafts included, so a record built from `properties_release` needs the
14
+ # copy kept on the revision.
15
+ # The active revision is the one whose uuid equals the klass's, which changes only on release.
16
+ class ReleaseMetadata
17
+ # A klass with no revision matching its uuid (never released, or imported without one)
18
+ # is left out of the map, and the entity reads it as nil.
19
+ def self.map_for(klasses)
20
+ Array(klasses).compact.group_by(&:class).each_with_object({}) do |(model, group), map|
21
+ map.merge!(map_for_family(model, group))
22
+ end
23
+ end
24
+
25
+ # The metadata a record built from the klass's `properties_release` stores: the active
26
+ # release's, or the klass's own when no revision matches its uuid. A failed lookup also
27
+ # falls back to the klass's own, so building the record never fails on it.
28
+ def self.for_klass(klass)
29
+ map_for([klass])[key_of(klass)] || klass.metadata || {}
30
+ end
31
+
32
+ # Family as well as id: a segment klass payload nests its element klass, and ids repeat
33
+ # across the klass tables.
34
+ def self.key_of(klass)
35
+ "#{klass.class.name.split('::').last}:#{klass.id}"
36
+ end
37
+
38
+ def self.map_for_family(model, klasses)
39
+ by_pair = klasses.reject { |klass| klass.uuid.nil? }.index_by { |klass| [klass.id, klass.uuid] }
40
+ return {} if by_pair.empty?
41
+
42
+ revisions_for(model, by_pair.keys).each_with_object({}) do |(klass_id, uuid, metadata), map|
43
+ # The IN lists cross-match: a revision of one listed klass can carry the uuid of
44
+ # another. Only the exact pair is that klass's active release.
45
+ klass = by_pair[[klass_id, uuid]]
46
+ map[key_of(klass)] = metadata unless klass.nil?
47
+ end
48
+ rescue StandardError => e
49
+ # The release metadata is an addition to a list that must still render, and a failing
50
+ # family must not blank the families that resolved. A record builder falls back too.
51
+ Labimotion.log_exception(e)
52
+ {}
53
+ end
54
+
55
+ # The revision model and its foreign key are named after the klass model.
56
+ def self.revisions_for(model, pairs)
57
+ foreign_key = "#{model.name.underscore.split('/').last}_id"
58
+ "#{model.name}esRevision".constantize
59
+ .where(foreign_key => pairs.map(&:first), uuid: pairs.map(&:last))
60
+ .pluck(foreign_key, :uuid, :metadata)
61
+ end
62
+ private_class_method :map_for_family, :revisions_for
63
+ end
64
+ end
@@ -19,10 +19,17 @@ module Labimotion
19
19
  SAMPLE = 'Sample'.freeze
20
20
  MOLECULE = 'Molecule'.freeze
21
21
  REACTION = 'Reaction'.freeze
22
+ RESEARCH_PLAN = 'ResearchPlan'.freeze
23
+ SCREEN = 'Screen'.freeze
22
24
  CONTAINER = 'Container'.freeze
23
25
  ELEMENTPROPS = 'ElementProps'.freeze
24
26
  SEGMENTPROPS = 'SegmentProps'.freeze
25
27
  DATASETPROPS = 'DatasetProps'.freeze
26
28
  UPLOADPROPS = [ELEMENTPROPS, SEGMENTPROPS, DATASETPROPS].freeze
29
+ # att_type accepted by upload_generics_files => class name of the element it names
30
+ UPLOAD_ELEMENT_TYPES = {
31
+ ELEMENT => L_ELEMENT, SAMPLE => SAMPLE, REACTION => REACTION, RESEARCH_PLAN => RESEARCH_PLAN,
32
+ SCREEN => SCREEN
33
+ }.freeze
27
34
  end
28
35
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.3.2'
5
+ VERSION = '2.3.4'
6
6
  end
data/lib/labimotion.rb CHANGED
@@ -73,6 +73,7 @@ module Labimotion
73
73
  autoload :ParamHelpers, 'labimotion/helpers/param_helpers'
74
74
  autoload :ConverterHelpers, 'labimotion/helpers/converter_helpers'
75
75
  autoload :ExporterHelpers, 'labimotion/helpers/exporter_helpers'
76
+ autoload :ExportAuthorizationHelpers, 'labimotion/helpers/export_authorization_helpers'
76
77
  autoload :SampleAssociationHelpers, 'labimotion/helpers/sample_association_helpers'
77
78
  autoload :RepositoryHelpers, 'labimotion/helpers/repository_helpers'
78
79
  autoload :VocabularyHelpers, 'labimotion/helpers/vocabulary_helpers'
@@ -95,6 +96,7 @@ module Labimotion
95
96
  autoload :SampleAssociation, 'labimotion/libs/sample_association'
96
97
  autoload :PropertiesHandler, 'labimotion/libs/properties_handler'
97
98
  autoload :KlassVersion, 'labimotion/libs/klass_version'
99
+ autoload :ReleaseMetadata, 'labimotion/libs/release_metadata'
98
100
  autoload :AttachmentHandler, 'labimotion/libs/attachment_handler'
99
101
  autoload :VocabularyHandler, 'labimotion/libs/vocabulary_handler'
100
102
  autoload :XlsxExporter, 'labimotion/libs/xlsx_exporter'
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.2
4
+ version: 2.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chia-Lin Lin
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-10-02 00:00:00.000000000 Z
12
+ date: 2026-11-03 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: caxlsx
@@ -114,6 +114,7 @@ files:
114
114
  - lib/labimotion/helpers/converter_helpers.rb
115
115
  - lib/labimotion/helpers/dataset_helpers.rb
116
116
  - lib/labimotion/helpers/element_helpers.rb
117
+ - lib/labimotion/helpers/export_authorization_helpers.rb
117
118
  - lib/labimotion/helpers/exporter_helpers.rb
118
119
  - lib/labimotion/helpers/generic_helpers.rb
119
120
  - lib/labimotion/helpers/mtt_helpers.rb
@@ -140,6 +141,7 @@ files:
140
141
  - lib/labimotion/libs/klass_version.rb
141
142
  - lib/labimotion/libs/nmr_mapper.rb
142
143
  - lib/labimotion/libs/properties_handler.rb
144
+ - lib/labimotion/libs/release_metadata.rb
143
145
  - lib/labimotion/libs/sample_association.rb
144
146
  - lib/labimotion/libs/template_hub.rb
145
147
  - lib/labimotion/libs/template_matcher.rb