labimotion 2.3.1 → 2.3.2

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: 50d1572a0d16e5ac73fdf5aa6a22e5188c9fd2f008c671fc9705a310fbd090ac
4
- data.tar.gz: 85631425352102b56f1c0c9998710c0e9af6c4f416b272cee3e05cffd6c4209e
3
+ metadata.gz: b2682c1c72815cd6376ed94350afc4289a414a4f09c71b9788230ec4430b3f8f
4
+ data.tar.gz: 603d30166812d812c7c03fdc3c985cc78b2f47965ec76abbfa4481f07d41b88e
5
5
  SHA512:
6
- metadata.gz: a8d4af5906da69a5ce49e100fb5ac4d63ea399bd4cc587af58dff73b2563eebe196d7658aa0257665b439be486b5f1d2bc851226bc265cd14b309e04120ae842
7
- data.tar.gz: 57e90440bc487d8fc950bb408e5c8794fa0566bad76d3a731f3cc92460623234a4893fdd1f6e61ed9f2730a1ec2b045b78ff940556fa49f6e92a6bc73c7475e6
6
+ metadata.gz: b5dae89c2091aa2f7f4e6b59079d13f3c777c07ba0d0891ecc97daa51b8eb571e90b9517c332d035438aaa45d671184b8d0090db382d3b23fa6e96568cf0e940
7
+ data.tar.gz: d66337f46db2b8d49e170bd7078cfae5b45d31fcd8deb92d0c625a09037d4858f21c919339d2547500f5bef0aec67728b7221adb73652961e8ae07c81336aee2
data/CHANGELOG.md CHANGED
@@ -8,10 +8,20 @@
8
8
  * `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
9
  * Import warnings name the cell they came from (`Cell I6: ...`), so the user can go straight to it in the spreadsheet.
10
10
  * 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.
11
12
  * `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.
12
13
  * Dependencies:
13
14
  * Added `roo` (~> 2.10) for reading the uploaded workbook.
14
15
 
16
+ ## [2.1.2]
17
+ > Unreleased
18
+ * Bug fixes:
19
+ * Stored zip attachments are opened read-only, so they are never rewritten in place. A rewrite could delete the upload on a CIFS/SMB share.
20
+ * A converter or NMR mapping failure no longer fails the save: the attachment ends in `ERROR` and the error is reported to Sentry. A file that is not a zip ends in `NONE`.
21
+ * Encrypted zips end in `ERROR` instead of being read as garbage.
22
+ * Maintenance:
23
+ * `ERROR` is terminal, also for transient faults. To retry, e.g. after restoring a lost file, call `attachment.reprocess_converter!` in the Rails console.
24
+
15
25
  ## [2.0.0]
16
26
  > 2025-03-25
17
27
  * Features and enhancements:
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'labimotion/utils/import_utils'
4
+ require 'labimotion/libs/klass_version'
4
5
  module Labimotion
5
6
  class Import
6
7
  def self.import_repo_segment_props(instances, attachments, attachable_uuid, fields)
@@ -67,6 +68,8 @@ module Labimotion
67
68
  dataset_klass = Labimotion::DatasetKlass.find_by(ols_term_id: dk_obj['ols_term_id']) if dataset_klass.nil?
68
69
  next if dataset_klass.nil?
69
70
 
71
+ klass_uuid, release = archived_version(dataset_klass, dataset_klass.dataset_klasses_revisions, fields)
72
+ klass_uuid ||= dkr&.uuid || dataset_klass.uuid
70
73
  dataset = Labimotion::Dataset.create!(
71
74
  fields.slice(
72
75
  'properties', 'properties_release', 'metadata'
@@ -75,7 +78,8 @@ module Labimotion
75
78
  element: element,
76
79
  dataset_klass: dataset_klass,
77
80
  uuid: SecureRandom.uuid,
78
- klass_uuid: dkr&.uuid || dataset_klass.uuid
81
+ klass_uuid: klass_uuid, 'properties_release' => release,
82
+ 'properties' => stamped_properties(fields['properties'], klass_uuid)
79
83
  )
80
84
  )
81
85
  update_instances.call(uuid, dataset)
@@ -110,6 +114,8 @@ module Labimotion
110
114
 
111
115
  ## segment_klass = Labimotion::ImportUtils.create_segment_klass(sk_obj, segment_klass, element_klass, current_user_id)
112
116
 
117
+ klass_uuid, release = archived_version(segment_klass, segment_klass.segment_klasses_revisions, fields)
118
+ klass_uuid ||= skr&.uuid || segment_klass.uuid
113
119
  segment = Labimotion::Segment.create!(
114
120
  fields.slice(
115
121
  'properties', 'properties_release', 'metadata'
@@ -118,7 +124,8 @@ module Labimotion
118
124
  element: element,
119
125
  segment_klass: segment_klass,
120
126
  uuid: SecureRandom.uuid,
121
- klass_uuid: skr&.uuid || segment_klass.uuid
127
+ klass_uuid: klass_uuid, 'properties_release' => release,
128
+ 'properties' => stamped_properties(fields['properties'], klass_uuid)
122
129
  )
123
130
  )
124
131
  properties = Labimotion::ImportUtils.properties_handler(data, instances, attachments, segment, nil)
@@ -147,6 +154,8 @@ module Labimotion
147
154
  end
148
155
  next if element_klass.nil?
149
156
 
157
+ klass_uuid, release = archived_version(element_klass, element_klass.element_klasses_revisions, fields)
158
+ klass_uuid ||= ekr&.uuid || element_klass.uuid
150
159
  element = Labimotion::Element.create!(
151
160
  fields.slice(
152
161
  'name', 'properties', 'properties_release', 'metadata'
@@ -157,7 +166,8 @@ module Labimotion
157
166
  'Collection', 'Labimotion::CollectionsElement', 'element_id', 'collection_id', uuid
158
167
  ),
159
168
  uuid: SecureRandom.uuid,
160
- klass_uuid: ekr&.uuid || element_klass.uuid
169
+ klass_uuid: klass_uuid, 'properties_release' => release,
170
+ 'properties' => stamped_properties(fields['properties'], klass_uuid)
161
171
  )
162
172
  )
163
173
  elements[uuid] = element
@@ -174,5 +184,28 @@ module Labimotion
174
184
  Labimotion.log_exception(e)
175
185
  raise
176
186
  end
187
+
188
+ # The archive names the release a record followed in three places: the uuid inside its
189
+ # properties_release, properties['klass_uuid'] and the klass_uuid column. The first one this
190
+ # instance resolves is stored with the local release; otherwise the archive's release keeps
191
+ # its own uuid, so the pair stays consistent and the Reload the properties need still shows.
192
+ def self.archived_version(klass, revisions, fields)
193
+ archived_release = fields['properties_release']
194
+ release_uuid = Labimotion::KlassVersion.release_uuid(archived_release)
195
+ uuids = [release_uuid, fields.dig('properties', 'klass_uuid'), fields['klass_uuid']]
196
+ .filter_map { |uuid| Labimotion::KlassVersion.string_uuid(uuid).presence }.uniq
197
+ uuids.each do |uuid|
198
+ release = Labimotion::KlassVersion.release_of(klass, revisions, uuid)
199
+ return [uuid, release] if release
200
+ end
201
+ [release_uuid || uuids.first, archived_release]
202
+ end
203
+ private_class_method :archived_version
204
+
205
+ # The stored properties name the same release as the klass_uuid column.
206
+ def self.stamped_properties(properties, klass_uuid)
207
+ properties.is_a?(Hash) ? properties.merge('klass_uuid' => klass_uuid) : properties
208
+ end
209
+ private_class_method :stamped_properties
177
210
  end
178
211
  end
@@ -3,6 +3,7 @@
3
3
  require 'grape'
4
4
  require 'labimotion/conf'
5
5
  require 'labimotion/utils/utils'
6
+ require 'labimotion/libs/klass_version'
6
7
  # require 'labimotion/models/element_klass'
7
8
  module Labimotion
8
9
  ## ElementHelpers
@@ -65,9 +66,19 @@ module Labimotion
65
66
 
66
67
  def create_element(current_user, params)
67
68
  klass = params[:element_klass] || {}
69
+ element_klass = Labimotion::ElementKlass.find_by(id: klass[:id])
70
+ error!('Template is invalid.', 404) if element_klass.nil?
71
+
72
+ # A new element's properties carry no klass_uuid; the template the client built them on
73
+ # names their release, which may be older than the current one after a release mid-session.
74
+ klass_uuid, release = Labimotion::KlassVersion.for_create(
75
+ element_klass, element_klass.element_klasses_revisions,
76
+ Labimotion::KlassVersion.string_uuid(params[:properties]['klass_uuid']).presence || klass[:uuid],
77
+ client_release: params[:properties_release]
78
+ )
68
79
  uuid = SecureRandom.uuid
69
80
  params[:properties]['uuid'] = uuid
70
- params[:properties]['klass_uuid'] = klass[:uuid]
81
+ params[:properties]['klass_uuid'] = klass_uuid
71
82
  params[:properties]['pkg'] = Labimotion::Utils.pkg(params[:properties]['pkg'])
72
83
  params[:properties]['klass'] = 'Element'
73
84
  params[:properties]['identifier'] = klass[:identifier]
@@ -79,9 +90,9 @@ module Labimotion
79
90
  name: params[:name],
80
91
  element_klass_id: klass[:id],
81
92
  uuid: uuid,
82
- klass_uuid: klass[:uuid],
93
+ klass_uuid: klass_uuid,
83
94
  properties: properties,
84
- properties_release: params[:properties_release],
95
+ properties_release: release,
85
96
  metadata: params[:metadata] || {},
86
97
  created_by: current_user.id
87
98
  }
@@ -110,6 +121,12 @@ module Labimotion
110
121
 
111
122
  def update_element_by_id(current_user, params)
112
123
  element = Labimotion::Element.find(params[:id])
124
+ # Resolved before the container and sample-association writes, so a missing template fails
125
+ # the request whole; with_deleted because a deleted template still names the release the
126
+ # element's properties follow. error! throws, so it escapes this method's rescue-and-log.
127
+ element_klass = Labimotion::ElementKlass.with_deleted.find_by(id: element.element_klass_id)
128
+ error!('Template is invalid.', 404) if element_klass.nil?
129
+
113
130
  update_datamodel(params[:container], current_user)
114
131
  properties = update_sample_association(params[:properties], current_user, element)
115
132
  params.delete(:container)
@@ -119,7 +136,11 @@ module Labimotion
119
136
  attributes = declared(params.except(:segments), include_missing: false)
120
137
  properties['pkg'] = Labimotion::Utils.pkg(properties['pkg'])
121
138
  metadata = params[:metadata] || element.metadata || {}
122
- if element.klass_uuid != properties['klass_uuid'] || element.properties != properties || element.name != params[:name] || element.metadata != metadata
139
+ klass_uuid, release = Labimotion::KlassVersion.for_update(
140
+ element_klass, element_klass.element_klasses_revisions, properties['klass_uuid'], element
141
+ )
142
+ properties['klass_uuid'] = klass_uuid if klass_uuid.present?
143
+ if element.klass_uuid != klass_uuid || element.properties != properties || element.name != params[:name] || element.metadata != metadata
123
144
  properties['klass'] = 'Element'
124
145
  uuid = SecureRandom.uuid
125
146
  properties['uuid'] = uuid
@@ -130,7 +151,8 @@ module Labimotion
130
151
  attributes['properties'] = properties
131
152
  attributes['properties']['uuid'] = uuid
132
153
  attributes['uuid'] = uuid
133
- attributes['klass_uuid'] = properties['klass_uuid']
154
+ attributes['klass_uuid'] = klass_uuid
155
+ attributes['properties_release'] = release
134
156
  attributes['metadata'] = metadata
135
157
  attributes['updated_at'] = Time.current
136
158
  element.update_columns(attributes)
@@ -6,6 +6,7 @@ require 'json'
6
6
  require 'date'
7
7
  require 'labimotion/version'
8
8
  require 'labimotion/utils/utils'
9
+ require 'labimotion/utils/mapper_utils'
9
10
  require 'labimotion/helpers/converter_helpers'
10
11
 
11
12
  # rubocop: disable Metrics/AbcSize
@@ -20,30 +21,37 @@ module Labimotion
20
21
  end
21
22
 
22
23
  def self.process_ds(id, current_user = {})
23
- att = Attachment.find_by(id: id, con_state: Labimotion::ConState::CONVERTED)
24
+ ctx = { att: nil, ols: nil, dsr: [] }
25
+ # Savepoint: a failure leaves no half-built dataset and keeps the caller's transaction usable
26
+ Attachment.transaction(requires_new: true) { map_ds(id, current_user, ctx) }
27
+ rescue StandardError => e
28
+ Labimotion::Converter.logger.error ["Att ID: #{ctx[:att]&.id}, OLS: #{ctx[:ols]}", "DSR: #{ctx[:dsr]}", e.message, *e.backtrace].join($INPUT_RECORD_SEPARATOR)
29
+ Labimotion.report_exception(e)
30
+ # Terminal state: do not fail the request, and do not retry on the next save
31
+ ctx[:att]&.update_column(:con_state, Labimotion::ConState::ERROR)
32
+ Labimotion::ConState::ERROR
33
+ end
34
+
35
+ def self.map_ds(id, current_user, ctx)
36
+ att = ctx[:att] = Attachment.find_by(id: id, con_state: Labimotion::ConState::CONVERTED)
24
37
  return if att.nil? || att.attachable_id.nil? || att.attachable_type != 'Container' || att.filename.split('.')&.last != 'zip'
25
38
 
26
39
  eln_ds = Container.find_by(id: att.attachable_id, container_type: 'dataset')
27
40
  return if eln_ds.nil? || eln_ds.parent.nil? || eln_ds.parent&.container_type != 'analysis'
28
41
 
29
- dsr = []
30
- ols = nil
31
- Zip::File.open(att.attachment_attacher.file.url) do |zip_file|
32
- if att.filename.split('.')&.last == 'zip'
33
- res = Labimotion::Converter.collect_metadata(zip_file, current_user)
34
- Labimotion::Converter.collect_reaction_converter_metadata(att, zip_file)
35
- end
36
- ols = res[:o] unless res&.dig(:o).nil?
37
- dsr.push(res[:d]) unless res&.dig(:d).nil?
38
- end
39
- dsr.flatten!
40
- dataset = build_ds(att.attachable_id, ols)
41
- update_ds(dataset, dsr, current_user) if dataset.present?
42
+ # Never close: closing can rewrite the stored file in place
43
+ zip_file = Zip::File.new(att.attachment_attacher.file.url)
44
+ res = Labimotion::Converter.collect_metadata(zip_file, current_user)
45
+ Labimotion::Converter.collect_reaction_converter_metadata(att, zip_file)
46
+ ctx[:ols] = res[:o] unless res&.dig(:o).nil?
47
+ ctx[:dsr].push(res[:d]) unless res&.dig(:d).nil?
48
+ ctx[:dsr].flatten!
49
+ dataset = build_ds(att.attachable_id, ctx[:ols])
50
+ update_ds(dataset, ctx[:dsr], current_user) if dataset.present?
42
51
  att.update_column(:con_state, Labimotion::ConState::COMPLETED)
43
- rescue StandardError => e
44
- Labimotion::Converter.logger.error ["Att ID: #{att&.id}, OLS: #{ols}", "DSR: #{dsr}", e.message, *e.backtrace].join($INPUT_RECORD_SEPARATOR)
45
- raise e
52
+ Labimotion::ConState::COMPLETED
46
53
  end
54
+ private_class_method :map_ds
47
55
 
48
56
  def self.uri(api_name)
49
57
  url = Rails.configuration.converter.url
@@ -129,7 +137,7 @@ module Labimotion
129
137
  zip_file.each do |entry|
130
138
  next unless entry.name == 'metadata/converter.json'
131
139
 
132
- metadata = entry.get_input_stream.read.force_encoding('UTF-8')
140
+ metadata = Labimotion::MapperUtils.read_entry(entry).force_encoding('UTF-8')
133
141
  jdata = JSON.parse(metadata)
134
142
 
135
143
  ols = jdata['ols']
@@ -194,8 +202,9 @@ module Labimotion
194
202
  )
195
203
  end
196
204
  if response.ok?
197
- Labimotion::Converter.handle_response(data[:a], response, current_user)
198
- Labimotion::ConState::PROCESSED
205
+ state = Labimotion::Converter.handle_response(data[:a], response, current_user)
206
+ # A failed dataset mapping also marks the source attachment as ERROR
207
+ state == Labimotion::ConState::ERROR ? state : Labimotion::ConState::PROCESSED
199
208
  else
200
209
  Labimotion::Converter.logger.error ["Converter Response Error: id: [#{data[:a]&.id}], filename: [#{data[:a]&.filename}], response: #{response}"].join($INPUT_RECORD_SEPARATOR)
201
210
  Labimotion::ConState::ERROR
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object/blank'
4
+
5
+ module Labimotion
6
+ # Resolves which release of a template a saved element, segment or dataset follows, as
7
+ # the pair [klass_uuid, properties_release]. The submitted properties carry the klass_uuid
8
+ # they were built on; it counts when it is the klass's current release or one of its
9
+ # revisions, so a copy of an older record keeps that release instead of being relabelled.
10
+ module KlassVersion
11
+ # A new record without a known release, such as a new empty one, follows the current release.
12
+ # A copy of a record from an instance with releases of its own names a klass_uuid this one
13
+ # cannot resolve; it keeps the release the client sent only when that release names the same uuid.
14
+ def self.for_create(klass, revisions, submitted_uuid, client_release: nil)
15
+ uuid = string_uuid(submitted_uuid)
16
+ release = release_of(klass, revisions, uuid)
17
+ return [uuid, release] if release
18
+ return [uuid, client_release] if uuid.present? && release_uuid(client_release) == uuid
19
+
20
+ [klass.uuid, klass.properties_release]
21
+ end
22
+
23
+ # An existing record keeps its stored klass_uuid when none is submitted, then the uuid its
24
+ # stored release names, and follows the current release only when it has neither. A klass_uuid
25
+ # this instance cannot resolve (a record imported from elsewhere) keeps the release stored with it.
26
+ def self.for_update(klass, revisions, submitted_uuid, record)
27
+ uuid = string_uuid(submitted_uuid).presence || record.klass_uuid.presence
28
+ if uuid.nil?
29
+ stored_uuid = release_uuid(record.properties_release)
30
+ return [stored_uuid, record.properties_release] if stored_uuid
31
+
32
+ return [klass.uuid, klass.properties_release]
33
+ end
34
+
35
+ release = release_of(klass, revisions, uuid) || record.properties_release.presence
36
+ [uuid, release || klass.properties_release]
37
+ end
38
+
39
+ # for_update when the record exists, for_create when it is nil.
40
+ def self.for_record(klass, revisions, submitted_uuid, record, client_release: nil)
41
+ return for_create(klass, revisions, submitted_uuid, client_release: client_release) if record.nil?
42
+
43
+ for_update(klass, revisions, submitted_uuid, record)
44
+ end
45
+
46
+ # Revisions are soft-deleted, and deleting one does not change which release the records
47
+ # built on it follow, so a deleted revision still answers here.
48
+ def self.release_of(klass, revisions, uuid)
49
+ return if uuid.blank?
50
+ return klass.properties_release if uuid == klass.uuid
51
+
52
+ revisions.with_deleted.where(uuid: uuid).order(id: :desc).pick(:properties_release)
53
+ end
54
+
55
+ # Every release hash carries the uuid of the release it describes; nil when it names none.
56
+ def self.release_uuid(release)
57
+ release.is_a?(Hash) ? string_uuid(release['uuid']).presence : nil
58
+ end
59
+
60
+ # properties is a free-form hash, so klass_uuid can arrive as an array or a number; either
61
+ # would turn the revision lookup into a query matching more than the record's own release.
62
+ def self.string_uuid(submitted_uuid)
63
+ submitted_uuid.is_a?(String) ? submitted_uuid : nil
64
+ end
65
+ end
66
+ end
@@ -30,7 +30,21 @@ module Labimotion
30
30
 
31
31
  class << self
32
32
  def process_ds(id, current_user = {})
33
- att = find_attachment(id)
33
+ ctx = { att: nil }
34
+ # Savepoint: a failure leaves no half-built dataset and keeps the caller's transaction usable
35
+ Attachment.transaction(requires_new: true) { map_ds(id, current_user, ctx) }
36
+ rescue StandardError => e
37
+ Labimotion.log_exception(e, current_user)
38
+ # Not a readable zip: nothing to map, and not an error
39
+ return Labimotion::ConState::NONE if invalid_archive?(e, ctx[:att])
40
+
41
+ # Terminal state: do not fail the request, and do not retry on the next save
42
+ Labimotion.report_exception(e)
43
+ Labimotion::ConState::ERROR
44
+ end
45
+
46
+ def map_ds(id, current_user, ctx)
47
+ att = ctx[:att] = find_attachment(id)
34
48
  return Labimotion::ConState::NONE if att.nil?
35
49
 
36
50
  result = process(att)
@@ -133,6 +147,20 @@ module Labimotion
133
147
  Attachment.find_by(id: id, con_state: Labimotion::ConState::NMR)
134
148
  end
135
149
 
150
+ # True when the upload itself is not a readable zip. A missing or changed file, an encrypted zip
151
+ # or an unsupported compression method is not covered and stays ERROR.
152
+ def invalid_archive?(error, att)
153
+ return false unless error.instance_of?(Zip::Error) || error.is_a?(Zip::DecompressionError)
154
+
155
+ file = att&.attachment_attacher&.file
156
+ path = file&.url
157
+ on_disk = path && File.size?(path)
158
+ return false if on_disk.nil?
159
+
160
+ recorded = file.size
161
+ recorded.nil? || recorded == on_disk
162
+ end
163
+
136
164
  def handle_process_result(result, att, id, current_user)
137
165
  if result[:is_bagit]
138
166
  handle_bagit_result(att, id, current_user)
@@ -146,7 +174,8 @@ module Labimotion
146
174
  def handle_bagit_result(att, id, current_user)
147
175
  att.update_column(:con_state, Labimotion::ConState::CONVERTED)
148
176
  Labimotion::Converter.metadata(id, current_user)
149
- Labimotion::ConState::COMPLETED
177
+ # Return the state Converter.metadata stored; CONVERTED means still waiting for an analysis dataset
178
+ Attachment.find_by(id: id)&.con_state || Labimotion::ConState::NONE
150
179
  end
151
180
 
152
181
  def invalid_metadata?(result)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'zip'
4
+
3
5
  module Labimotion
4
6
  module AttachmentConverter
5
7
  ACCEPTED_FORMATS = (Rails.configuration.try(:converter).try(:ext) || []).freeze
@@ -27,6 +29,8 @@ module Labimotion
27
29
 
28
30
  return if attachable_id.nil? && con_state != Labimotion::ConState::WAIT
29
31
 
32
+ # No attachable_id-changed guard: an attachment created already attached must still be processed.
33
+ # Each branch leaves its pending state, so the work runs once.
30
34
  current_user = User.find_by(id: created_by)
31
35
  case con_state
32
36
  when Labimotion::ConState::NMR
@@ -40,6 +44,26 @@ module Labimotion
40
44
  Labimotion::Converter.metadata(id, current_user)
41
45
  end
42
46
  end
47
+
48
+ # Run the zip mapping again for an attachment in ERROR, e.g. after its file was restored.
49
+ # Returns the new con_state.
50
+ def reprocess_converter!
51
+ return con_state unless has_attribute?(:con_state) && File.extname(filename.to_s.downcase) == '.zip'
52
+
53
+ path = attachment_attacher&.file&.url
54
+ return con_state unless path && File.size?(path)
55
+
56
+ self.con_state = converter_output?(path) ? Labimotion::ConState::CONVERTED : Labimotion::ConState::NMR
57
+ update_column(:con_state, con_state)
58
+ exec_converter
59
+ reload.con_state
60
+ end
61
+
62
+ private def converter_output?(path)
63
+ !Zip::File.new(path).find_entry('metadata/converter.json').nil?
64
+ rescue Zip::Error
65
+ false # not a readable zip: the NMR mapper classifies it
66
+ end
43
67
  end
44
68
  end
45
69
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  # Datasetable concern
4
4
  require 'labimotion/utils/utils'
5
+ require 'labimotion/libs/klass_version'
5
6
 
6
7
  module Labimotion
7
8
  ## Datasetable concern
@@ -38,7 +39,10 @@ module Labimotion
38
39
 
39
40
  args = dataset_args[:dataset]
40
41
  dataset_klass_id = args[:dataset_klass_id]
41
- klass = Labimotion::DatasetKlass.find_by(id: dataset_klass_id)
42
+ # A deleted template has already taken its datasets with it, so there is nothing to save.
43
+ klass = Labimotion::DatasetKlass.with_deleted.find_by(id: dataset_klass_id)
44
+ return if klass.nil? || klass.deleted_at.present?
45
+
42
46
  uuid = SecureRandom.uuid
43
47
  metadata = args[:metadata] || {}
44
48
  props = args[:properties]
@@ -46,16 +50,23 @@ module Labimotion
46
50
  props['identifier'] = klass.identifier if klass.identifier.present?
47
51
  props['uuid'] = uuid
48
52
  props['klass'] = 'Dataset'
49
- props['klass_uuid'] = klass.uuid
50
53
  props = Labimotion::VocabularyHandler.update_vocabularies(props, dataset_args[:current_user], dataset_args[:element])
51
54
 
52
55
  ds = Labimotion::Dataset.find_by(element_type: self.class.name, element_id: id)
53
- if ds.present? && (ds.klass_uuid != klass.uuid || ds.properties != props || ds.metadata != metadata)
54
- ds.update!(properties_release: klass.properties_release, uuid: uuid, dataset_klass_id: dataset_klass_id, properties: props, klass_uuid: klass.uuid, metadata: metadata)
56
+ # A dataset whose klass changed follows the new klass, not the release stored for the old
57
+ # one, so the switch resolves as a new record does. The release the client sent counts only
58
+ # for a new dataset; on a switch it may still describe the old klass.
59
+ on_same_klass = ds if ds.present? && ds.dataset_klass_id == klass.id
60
+ klass_uuid, release =
61
+ Labimotion::KlassVersion.for_record(klass, klass.dataset_klasses_revisions, props['klass_uuid'], on_same_klass,
62
+ client_release: (args[:properties_release] if ds.nil?))
63
+ props['klass_uuid'] = klass_uuid if klass_uuid.present?
64
+ if ds.present? && (ds.klass_uuid != klass_uuid || ds.properties != props || ds.metadata != metadata)
65
+ ds.update!(properties_release: release, uuid: uuid, dataset_klass_id: dataset_klass_id, properties: props, klass_uuid: klass_uuid, metadata: metadata)
55
66
  end
56
67
  return if ds.present?
57
68
 
58
- Labimotion::Dataset.create!(properties_release: klass.properties_release, uuid: uuid, dataset_klass_id: dataset_klass_id, element_type: self.class.name, element_id: id, properties: props, klass_uuid: klass.uuid, metadata: metadata)
69
+ Labimotion::Dataset.create!(properties_release: release, uuid: uuid, dataset_klass_id: dataset_klass_id, element_type: self.class.name, element_id: id, properties: props, klass_uuid: klass_uuid, metadata: metadata)
59
70
  end
60
71
 
61
72
  def destroy_datasetable
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'labimotion/utils/utils'
4
4
  require 'labimotion/libs/attachment_handler'
5
+ require 'labimotion/libs/klass_version'
5
6
 
6
7
  module Labimotion
7
8
  # Segmentable concern
@@ -46,6 +47,14 @@ module Labimotion
46
47
  end
47
48
  end
48
49
 
50
+ # The release a new segment was built on: its properties first, then the template the client
51
+ # loaded, then the klass_uuid a copied row carried.
52
+ def submitted_segment_uuid(seg, props)
53
+ segment_klass = seg['segment_klass']
54
+ [props['klass_uuid'], (segment_klass['uuid'] if segment_klass.is_a?(Hash)), seg['klass_uuid']]
55
+ .map { |uuid| Labimotion::KlassVersion.string_uuid(uuid) }.find(&:present?)
56
+ end
57
+
49
58
  def touch_vocabulary(current_user)
50
59
  touch_element_properties(current_user) if instance_of?(::Labimotion::Element)
51
60
  touch_segments_properties(current_user)
@@ -57,7 +66,10 @@ module Labimotion
57
66
 
58
67
  current_user = User.find_by(id: args[:current_user_id])
59
68
  args_segments.each do |seg|
60
- klass = Labimotion::SegmentKlass.find_by(id: seg['segment_klass_id'])
69
+ # A deleted template has already taken its segments with it, so there is nothing to save.
70
+ klass = Labimotion::SegmentKlass.with_deleted.find_by(id: seg['segment_klass_id'])
71
+ next if klass.nil? || klass.deleted_at.present?
72
+
61
73
  uuid = SecureRandom.uuid
62
74
  props = seg['properties']
63
75
  props['pkg'] = Labimotion::Utils.pkg(props['pkg'])
@@ -67,15 +79,22 @@ module Labimotion
67
79
  props = Labimotion::SampleAssociation.update_sample_association(props, args[:current_user_id])
68
80
  # props = Labimotion::VocabularyHandler.update_vocabularies(props, current_user, self)
69
81
  segment = Labimotion::Segment.where(element_type: self.class.name, element_id: self.id, segment_klass_id: seg['segment_klass_id']).order(id: :desc).first
70
- if segment.present? && (segment.klass_uuid != props['klass_uuid'] || segment.properties != props)
71
- segment.update!(properties_release: klass.properties_release, properties: props, uuid: uuid, klass_uuid: props['klass_uuid'], metadata: seg['metadata'] || {})
82
+ # Like an element update, an existing segment reads the release from its properties alone.
83
+ submitted_uuid = segment.nil? ? submitted_segment_uuid(seg, props) : props['klass_uuid']
84
+ klass_uuid, release =
85
+ Labimotion::KlassVersion.for_record(klass, klass.segment_klasses_revisions, submitted_uuid, segment,
86
+ client_release: seg['properties_release'])
87
+ # props is persisted as submitted, so the resolved value goes back in: otherwise a
88
+ # payload without klass_uuid stores a segment whose column and properties disagree.
89
+ props['klass_uuid'] = klass_uuid if klass_uuid.present?
90
+ if segment.present? && (segment.klass_uuid != klass_uuid || segment.properties != props)
91
+ segment.update!(properties_release: release, properties: props, uuid: uuid, klass_uuid: klass_uuid, metadata: seg['metadata'] || {})
72
92
  # segments.push(segment)
73
93
  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
74
94
  end
75
95
  next if segment.present?
76
96
 
77
- props['klass_uuid'] = klass.uuid
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, metadata: seg['metadata'] || {})
97
+ segment = Labimotion::Segment.create!(properties_release: 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, metadata: seg['metadata'] || {})
79
98
  # segments.push(segment)
80
99
  end
81
100
 
@@ -6,8 +6,18 @@ require 'zip'
6
6
  require 'labimotion/constants'
7
7
 
8
8
  module Labimotion
9
+ # rubyzip 2.x does not detect encrypted entries and would inflate them as garbage
10
+ class EncryptedZipError < Zip::Error; end
11
+
9
12
  class MapperUtils
10
13
  class << self
14
+ # Read an entry and close its stream, so no handle on the stored file stays open
15
+ def read_entry(entry)
16
+ raise EncryptedZipError, "Encrypted zip entry: #{entry.name}" if entry.encrypted?
17
+
18
+ entry.get_input_stream(&:read)
19
+ end
20
+
11
21
  def load_config(config_json)
12
22
  JSON.parse(config_json)
13
23
  rescue JSON::ParserError => e
@@ -37,13 +47,8 @@ module Labimotion
37
47
  def extract_data_from_zip(zip_file_url, source_map)
38
48
  return nil if zip_file_url.nil?
39
49
 
50
+ # nil means nothing to map; errors are raised so the caller can record ERROR
40
51
  process_zip_file(zip_file_url, source_map)
41
- rescue Zip::Error => e
42
- Rails.logger.error "Zip file error: #{e.message}"
43
- nil
44
- rescue StandardError => e
45
- Rails.logger.error "Unexpected error extracting metadata: #{e.message}"
46
- nil
47
52
  end
48
53
 
49
54
  def check_if_bagit(zip_file_url)
@@ -183,10 +188,12 @@ module Labimotion
183
188
  def process_zip_file(zip_file_url, source_map)
184
189
  final_parameters = {}
185
190
 
186
- Zip::File.open(zip_file_url) do |zip_file|
187
- source_map['sourceSelector'].each do |source_name|
188
- process_source(zip_file, source_map[source_name], final_parameters)
189
- end
191
+ # Never close: closing can rewrite the stored file in place
192
+ zip_file = Zip::File.new(zip_file_url)
193
+ source_map['sourceSelector'].each do |source_name|
194
+ result = process_source(zip_file, source_map[source_name], final_parameters)
195
+ # A BagIt zip is mapped by the converter instead
196
+ return result if result&.dig(:is_bagit)
190
197
  end
191
198
 
192
199
  return { is_bagit: false, metadata: final_parameters } if final_parameters.present?
@@ -198,6 +205,8 @@ module Labimotion
198
205
  return if invalid_source_config?(source_config)
199
206
 
200
207
  zip_file.each do |entry|
208
+ next if entry.directory?
209
+
201
210
  if source_file?(entry, source_config)
202
211
  process_file_entry(entry, source_config, final_parameters)
203
212
  elsif bagit_metadata_file?(entry)
@@ -207,7 +216,7 @@ module Labimotion
207
216
  end
208
217
 
209
218
  def process_file_entry(entry, source_config, final_parameters)
210
- file_content = entry.get_input_stream.read.force_encoding(Constants::File::ENCODING)
219
+ file_content = read_entry(entry).force_encoding(Constants::File::ENCODING)
211
220
  extracted_parameters = extract_parameters(file_content, source_config['parameters'])
212
221
  final_parameters.merge!(extracted_parameters) if extracted_parameters.present?
213
222
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.3.1'
5
+ VERSION = '2.3.2'
6
6
  end
data/lib/labimotion.rb CHANGED
@@ -13,6 +13,11 @@ module Labimotion
13
13
  Labimotion.logger.error(exception.backtrace.join("\n"))
14
14
  end
15
15
 
16
+ # Report a rescued exception to Sentry, if the host app uses it
17
+ def self.report_exception(exception)
18
+ Sentry.capture_exception(exception) if defined?(Sentry)
19
+ end
20
+
16
21
  autoload :MapperUtils, 'labimotion/utils/mapper_utils'
17
22
  autoload :Utils, 'labimotion/utils/utils'
18
23
 
@@ -89,6 +94,7 @@ module Labimotion
89
94
  autoload :ExportDataset, 'labimotion/libs/export_dataset'
90
95
  autoload :SampleAssociation, 'labimotion/libs/sample_association'
91
96
  autoload :PropertiesHandler, 'labimotion/libs/properties_handler'
97
+ autoload :KlassVersion, 'labimotion/libs/klass_version'
92
98
  autoload :AttachmentHandler, 'labimotion/libs/attachment_handler'
93
99
  autoload :VocabularyHandler, 'labimotion/libs/vocabulary_handler'
94
100
  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.1
4
+ version: 2.3.2
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-09-15 00:00:00.000000000 Z
12
+ date: 2026-10-02 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: caxlsx
@@ -137,6 +137,7 @@ files:
137
137
  - lib/labimotion/libs/export_element_variations.rb
138
138
  - lib/labimotion/libs/export_tpa_segments.rb
139
139
  - lib/labimotion/libs/import_element_variations.rb
140
+ - lib/labimotion/libs/klass_version.rb
140
141
  - lib/labimotion/libs/nmr_mapper.rb
141
142
  - lib/labimotion/libs/properties_handler.rb
142
143
  - lib/labimotion/libs/sample_association.rb