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 +4 -4
- data/CHANGELOG.md +10 -0
- data/lib/labimotion/collection/import.rb +36 -3
- data/lib/labimotion/helpers/element_helpers.rb +27 -5
- data/lib/labimotion/libs/converter.rb +29 -20
- data/lib/labimotion/libs/klass_version.rb +66 -0
- data/lib/labimotion/libs/nmr_mapper.rb +31 -2
- data/lib/labimotion/models/concerns/attachment_converter.rb +24 -0
- data/lib/labimotion/models/concerns/datasetable.rb +16 -5
- data/lib/labimotion/models/concerns/segmentable.rb +24 -5
- data/lib/labimotion/utils/mapper_utils.rb +20 -11
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +6 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2682c1c72815cd6376ed94350afc4289a414a4f09c71b9788230ec4430b3f8f
|
|
4
|
+
data.tar.gz: 603d30166812d812c7c03fdc3c985cc78b2f47965ec76abbfa4481f07d41b88e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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:
|
|
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:
|
|
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'] =
|
|
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:
|
|
93
|
+
klass_uuid: klass_uuid,
|
|
83
94
|
properties: properties,
|
|
84
|
-
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
|
-
|
|
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'] =
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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.
|
|
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
|
|
data/lib/labimotion/version.rb
CHANGED
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.
|
|
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-
|
|
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
|