labimotion 2.3.0 → 2.4.0.rc11
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 +25 -1
- data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
- data/lib/labimotion/apis/generic_element_api.rb +198 -8
- data/lib/labimotion/apis/generic_klass_api.rb +74 -8
- data/lib/labimotion/apis/klass_share_api.rb +648 -0
- data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
- data/lib/labimotion/apis/labimotion_api.rb +4 -0
- data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
- data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
- data/lib/labimotion/apis/ontology_root_api.rb +74 -0
- data/lib/labimotion/apis/segment_api.rb +77 -10
- data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
- data/lib/labimotion/conf.rb +5 -0
- data/lib/labimotion/constants.rb +64 -0
- data/lib/labimotion/entities/application_entity.rb +8 -0
- data/lib/labimotion/entities/eln_element_entity.rb +6 -0
- data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
- data/lib/labimotion/entities/klass_share_entity.rb +48 -0
- data/lib/labimotion/entities/properties_entity.rb +78 -2
- data/lib/labimotion/entities/segment_entity.rb +8 -0
- data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
- data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
- data/lib/labimotion/helpers/element_helpers.rb +282 -6
- data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
- data/lib/labimotion/helpers/generic_helpers.rb +293 -4
- data/lib/labimotion/helpers/param_helpers.rb +105 -0
- data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
- data/lib/labimotion/helpers/segment_helpers.rb +102 -4
- data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
- data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
- data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
- data/lib/labimotion/libs/ai_models.rb +201 -0
- data/lib/labimotion/libs/ai_template.rb +2285 -0
- data/lib/labimotion/libs/converter.rb +5 -43
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
- data/lib/labimotion/libs/export_element.rb +128 -13
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/libs/linked_element.rb +313 -0
- data/lib/labimotion/libs/ontology_store.rb +226 -0
- data/lib/labimotion/libs/ontology_terms.rb +227 -0
- data/lib/labimotion/libs/owner_resolver.rb +50 -0
- data/lib/labimotion/libs/ownership_audit.rb +73 -0
- data/lib/labimotion/libs/sample_association.rb +52 -1
- data/lib/labimotion/libs/share_notifier.rb +114 -0
- data/lib/labimotion/libs/share_resolver.rb +373 -0
- data/lib/labimotion/libs/user_ai_settings.rb +129 -0
- data/lib/labimotion/models/cellline.rb +47 -0
- data/lib/labimotion/models/concerns/datasetable.rb +3 -0
- data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
- data/lib/labimotion/models/concerns/segmentable.rb +2 -0
- data/lib/labimotion/models/concerns/template_doi.rb +133 -0
- data/lib/labimotion/models/dataset_klass.rb +1 -1
- data/lib/labimotion/models/element_klass.rb +1 -1
- data/lib/labimotion/models/klass_share.rb +129 -0
- data/lib/labimotion/models/segment_klass.rb +1 -1
- data/lib/labimotion/models/user_klass_setting.rb +58 -0
- data/lib/labimotion/models/user_setting.rb +60 -0
- data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
- data/lib/labimotion/usecases/release_template_doi.rb +42 -17
- data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
- data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
- data/lib/labimotion/utils/export_utils.rb +1 -0
- data/lib/labimotion/utils/import_utils.rb +20 -3
- data/lib/labimotion/utils/serializer.rb +27 -0
- data/lib/labimotion/utils/units.rb +32 -67
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +27 -0
- metadata +45 -3
|
@@ -1,22 +1,110 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'grape'
|
|
3
3
|
require 'labimotion/conf'
|
|
4
|
+
require 'labimotion/constants'
|
|
4
5
|
require 'labimotion/utils/utils'
|
|
6
|
+
require 'labimotion/libs/owner_resolver'
|
|
5
7
|
# Helper for associated sample
|
|
6
8
|
module Labimotion
|
|
7
9
|
## Generic Helpers
|
|
8
10
|
module GenericHelpers
|
|
9
11
|
extend Grape::API::Helpers
|
|
10
12
|
|
|
13
|
+
# Message code for "somebody else saved this template while you were editing it".
|
|
14
|
+
STALE_TEMPLATE_MC = 'sc09'
|
|
15
|
+
# Message code for "an active template already holds the identity you are restoring".
|
|
16
|
+
RESTORE_CONFLICT_MC = 'sc10'
|
|
17
|
+
# Message code for "this template is maintained by somebody else" — the 403 a share-gated
|
|
18
|
+
# action answers with. Deliberately not the legacy 401: nobody here is unauthorised, they
|
|
19
|
+
# are asking about a colleague's template (template-sharing.md §3).
|
|
20
|
+
AUTHORIZATION_REFUSAL_MC = 'sc11'
|
|
21
|
+
# Message code for "the owner row cannot be removed or downgraded — transfer instead".
|
|
22
|
+
OWNER_ROW_MC = 'sc12'
|
|
23
|
+
# Message code for "you already hold access to this template, so there is nothing to
|
|
24
|
+
# request". The 409 request_access answers a caller whose share row is already at
|
|
25
|
+
# viewer/editor/owner — a stale grid, not a mistake worth an error banner.
|
|
26
|
+
ACCESS_ALREADY_HELD_MC = 'sc13'
|
|
27
|
+
|
|
28
|
+
# How a refusal names the action it refused, and which actions are owner-only for the
|
|
29
|
+
# wording. Kept as plain symbols here rather than reading ShareResolver::REQUIRED_LEVEL —
|
|
30
|
+
# the wording must not chain-load the ActiveRecord model in a process that only wanted
|
|
31
|
+
# the helpers. ShareResolver stays the enforcement authority.
|
|
32
|
+
ACTION_PHRASE = {
|
|
33
|
+
read: 'view', write: 'edit', release: 'release',
|
|
34
|
+
release_minor: 'release a minor version of',
|
|
35
|
+
deactivate: 'activate or deactivate', destroy: 'delete', manage: 'manage sharing for'
|
|
36
|
+
}.freeze
|
|
37
|
+
# `release_minor` is deliberately NOT here: a maintainer holds it too, so its refusal
|
|
38
|
+
# must not claim "only the owner can" — it gets its own maintainer wording below.
|
|
39
|
+
OWNER_ONLY_ACTIONS = %i[release deactivate destroy manage].freeze
|
|
40
|
+
|
|
41
|
+
# What makes a klass unique among the *active* rows. These are model validations only —
|
|
42
|
+
# there is no DB unique index behind any of them — and `restore!` writes through
|
|
43
|
+
# `update_columns`, so nothing re-checks them on the way back in.
|
|
44
|
+
KLASS_IDENTITY = {
|
|
45
|
+
'ElementKlass' => %i[name],
|
|
46
|
+
'SegmentKlass' => %i[label element_klass_id],
|
|
47
|
+
'DatasetKlass' => %i[ols_term_id]
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# How the refusal names the identity that is taken.
|
|
51
|
+
KLASS_IDENTITY_NOUN = {
|
|
52
|
+
'ElementKlass' => 'name',
|
|
53
|
+
'SegmentKlass' => 'label',
|
|
54
|
+
'DatasetKlass' => 'ontology term'
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
# Seconds, symmetric around the deleted template's own `deleted_at`, within which a
|
|
58
|
+
# soft-deleted child counts as "deleted together with it" (see Paranoia#restore!).
|
|
59
|
+
# Wide enough to cover a slow cascade over a template with many elements, narrow enough
|
|
60
|
+
# that rows binned on their own, on another day, stay binned.
|
|
61
|
+
RESTORE_RECOVERY_WINDOW = 300
|
|
62
|
+
|
|
11
63
|
def authenticate_admin!(type)
|
|
12
64
|
unauthorized = -> { error!('401 Unauthorized', 401) }
|
|
13
65
|
if %w[standard_layers vocabularies].include?(type)
|
|
14
|
-
unauthorized.call unless current_user.generic_admin.values_at(
|
|
66
|
+
unauthorized.call unless current_user.generic_admin.values_at(*Labimotion::Constants::Family::ALL).any?
|
|
15
67
|
else
|
|
16
68
|
unauthorized.call unless current_user.generic_admin[type]
|
|
17
69
|
end
|
|
18
70
|
end
|
|
19
71
|
|
|
72
|
+
# Family string (Constants::Family) for a loaded klass record — what the legacy gate
|
|
73
|
+
# keys on.
|
|
74
|
+
def klass_family(klz)
|
|
75
|
+
Labimotion::Constants::Family::FAMILY_OF[klz.class.name.split('::').last]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The per-template gate (template-sharing.md §6). Takes the record, not a family string:
|
|
79
|
+
# resolution needs that klass's share rows and, for a SegmentKlass, its parent element's.
|
|
80
|
+
# Owner-less templates (and hosts that have not migrated) fall back to the legacy
|
|
81
|
+
# designer-wide gate — permanent behaviour, not a transition. A refusal is a 403 naming
|
|
82
|
+
# the owner, so the person refused knows who to ask, never the legacy bare 401.
|
|
83
|
+
def authorize_klass!(klz, action)
|
|
84
|
+
result = Labimotion::ShareResolver.authorize(klz, current_user, action)
|
|
85
|
+
return if result == :ok
|
|
86
|
+
return authenticate_admin!(klass_family(klz)) if result == :legacy
|
|
87
|
+
|
|
88
|
+
owner = Labimotion::OwnerResolver.find(result.owner_id)
|
|
89
|
+
name = owner.respond_to?(:name) && owner.name.present? ? owner.name : 'another user'
|
|
90
|
+
error!({ mc: AUTHORIZATION_REFUSAL_MC,
|
|
91
|
+
msg: authorization_refusal_msg(name, action),
|
|
92
|
+
owner: { id: result.owner_id, name: name == 'another user' ? nil : name } }, 403)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def authorization_refusal_msg(name, action)
|
|
96
|
+
requirement = if OWNER_ONLY_ACTIONS.include?(action)
|
|
97
|
+
"Only the owner can #{ACTION_PHRASE[action]} this template."
|
|
98
|
+
elsif action == :release_minor
|
|
99
|
+
"You need maintainer access to #{ACTION_PHRASE[action]} this template."
|
|
100
|
+
elsif action == :read
|
|
101
|
+
"You need shared access to #{ACTION_PHRASE[action]} this template."
|
|
102
|
+
else
|
|
103
|
+
"You need edit access to #{ACTION_PHRASE[action]} this template."
|
|
104
|
+
end
|
|
105
|
+
"This template is maintained by #{name}. #{requirement}"
|
|
106
|
+
end
|
|
107
|
+
|
|
20
108
|
def fetch_klass(name, id)
|
|
21
109
|
klz = "Labimotion::#{name}".constantize.find_by(id: id)
|
|
22
110
|
error!("#{name.gsub(/(Klass)/, '')} is invalid. Please re-select.", 500) if klz.nil?
|
|
@@ -26,8 +114,66 @@ module Labimotion
|
|
|
26
114
|
raise e
|
|
27
115
|
end
|
|
28
116
|
|
|
117
|
+
# Per-user AI overrides (model + API key) from the user's own settings row.
|
|
118
|
+
# Returns {} when nothing is stored, so AiTemplate falls back to the
|
|
119
|
+
# server-wide yml/ENV configuration. Shared by the dataset / element /
|
|
120
|
+
# segment AI helpers.
|
|
121
|
+
def ai_user_overrides(current_user)
|
|
122
|
+
return {} if current_user.nil?
|
|
123
|
+
|
|
124
|
+
settings = Labimotion::UserAiSettings.for(current_user)
|
|
125
|
+
api_key = settings.api_key
|
|
126
|
+
model = api_key.blank? ? ai_shared_key_model(settings.model) : settings.model
|
|
127
|
+
|
|
128
|
+
{ model: model, api_key: api_key }.merge(ai_provider_overrides(settings, api_key)).compact
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
Labimotion.log_exception(e, current_user)
|
|
131
|
+
{}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Personal provider endpoint (base_url/api_path) — honored only ALONGSIDE a
|
|
135
|
+
# personal key, so the shared server key is never sent to a user-supplied URL.
|
|
136
|
+
# The base_url itself is SSRF-validated later, in AiTemplate.
|
|
137
|
+
def ai_provider_overrides(settings, api_key)
|
|
138
|
+
return {} if api_key.blank?
|
|
139
|
+
|
|
140
|
+
{ base_url: settings.base_url, api_path: settings.api_path }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# On the SHARED server key (user has no personal key), keep the user's model
|
|
144
|
+
# only if the admin allowed it; otherwise return nil so AiTemplate falls back
|
|
145
|
+
# to the server default. Returns the model unchanged when no allowlist is
|
|
146
|
+
# configured (fail open). Guards an off-list model being billed to the shared key.
|
|
147
|
+
def ai_shared_key_model(model)
|
|
148
|
+
return model if model.blank?
|
|
149
|
+
|
|
150
|
+
allow = ai_server_model_allowlist
|
|
151
|
+
allow.any? && !allow.include?(model.to_s) ? nil : model
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Model ids the server permits on the SHARED key: the admin pick-list
|
|
155
|
+
# (:models) plus the server default (:model / KI_TOOLBOX_MODEL), read from the
|
|
156
|
+
# host config the same way Labimotion::AiTemplate does. Empty when the admin
|
|
157
|
+
# configured neither — the caller then skips the clamp (fail open), matching
|
|
158
|
+
# the gem's decoupled, non-blocking handling of overrides.
|
|
159
|
+
def ai_server_model_allowlist
|
|
160
|
+
cfg = Rails.configuration.labimotion_ai || {}
|
|
161
|
+
list = cfg[:models].is_a?(Array) ? cfg[:models] : []
|
|
162
|
+
ids = list.map { |entry| ai_model_entry_id(entry) }
|
|
163
|
+
(ids << cfg[:model].to_s << ENV['KI_TOOLBOX_MODEL'].to_s).reject(&:blank?).uniq
|
|
164
|
+
rescue StandardError
|
|
165
|
+
[]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def ai_model_entry_id(entry)
|
|
169
|
+
return entry.to_s unless entry.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
(entry[:id] || entry['id']).to_s
|
|
172
|
+
end
|
|
173
|
+
|
|
29
174
|
def deactivate_klass(params)
|
|
30
175
|
klz = fetch_klass(params[:klass], params[:id])
|
|
176
|
+
authorize_klass!(klz, :deactivate)
|
|
31
177
|
klz&.update!(is_active: params[:is_active])
|
|
32
178
|
generate_klass_file unless klz.class.name != 'Labimotion::ElementKlass'
|
|
33
179
|
klz
|
|
@@ -36,9 +182,19 @@ module Labimotion
|
|
|
36
182
|
raise e
|
|
37
183
|
end
|
|
38
184
|
|
|
185
|
+
def update_klass_settings(params)
|
|
186
|
+
klz = fetch_klass(params[:klass], params[:id])
|
|
187
|
+
authorize_klass!(klz, :write)
|
|
188
|
+
klz&.update!(settings: (klz.settings || {}).merge(params[:settings] || {}))
|
|
189
|
+
klz
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
Labimotion.log_exception(e, current_user)
|
|
192
|
+
raise e
|
|
193
|
+
end
|
|
194
|
+
|
|
39
195
|
def delete_klass(params)
|
|
40
|
-
authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
|
|
41
196
|
klz = fetch_klass(params[:klass], params[:id])
|
|
197
|
+
authorize_klass!(klz, :destroy)
|
|
42
198
|
klz&.destroy!
|
|
43
199
|
generate_klass_file unless klz.class.name != 'Labimotion::ElementKlass'
|
|
44
200
|
status 201
|
|
@@ -49,6 +205,19 @@ module Labimotion
|
|
|
49
205
|
|
|
50
206
|
def update_template(params, current_user)
|
|
51
207
|
klz = fetch_klass(params[:klass], params[:id])
|
|
208
|
+
# The split that makes `editor` mean "drafting only" and `maintainer` mean "drafting
|
|
209
|
+
# plus minor releases": a draft save needs :write; a minor release bumps the version
|
|
210
|
+
# and cuts a revision, which a maintainer may do (:release_minor); a major release —
|
|
211
|
+
# and any release value nobody recognises — stays owner-only (:release), so an unknown
|
|
212
|
+
# value fails toward the stricter gate.
|
|
213
|
+
# Authorization before the stale check: who may act comes before whether the copy is fresh.
|
|
214
|
+
release_action = case params[:release]
|
|
215
|
+
when 'draft' then :write
|
|
216
|
+
when 'minor' then :release_minor
|
|
217
|
+
else :release
|
|
218
|
+
end
|
|
219
|
+
authorize_klass!(klz, release_action)
|
|
220
|
+
guard_stale_template!(klz, params[:properties_template])
|
|
52
221
|
uuid = SecureRandom.uuid
|
|
53
222
|
properties = params[:properties_template]
|
|
54
223
|
properties['uuid'] = uuid
|
|
@@ -70,12 +239,133 @@ module Labimotion
|
|
|
70
239
|
raise e
|
|
71
240
|
end
|
|
72
241
|
|
|
242
|
+
# A template save replaces `properties_template` wholesale, so two designers holding the
|
|
243
|
+
# same template open used to overwrite each other with no warning and nothing to recover
|
|
244
|
+
# from: drafts are never snapshotted (`Utils.next_version` is a no-op for 'draft' and
|
|
245
|
+
# `create_klasses_revision` is skipped), and making drafts cut revisions is not an option
|
|
246
|
+
# because cutting a revision writes `properties_release` — it publishes.
|
|
247
|
+
#
|
|
248
|
+
# `update_template` already mints a fresh uuid into the template on every save, so the
|
|
249
|
+
# stored uuid is a ready-made optimistic-locking token: the client posts back the uuid it
|
|
250
|
+
# was handed, and a difference means somebody saved in between.
|
|
251
|
+
#
|
|
252
|
+
# Blank on either side means we cannot tell — a template stored before this guard existed,
|
|
253
|
+
# or a client that dropped the field — and a template with no stored uuid must not start
|
|
254
|
+
# refusing saves.
|
|
255
|
+
def guard_stale_template!(klz, properties)
|
|
256
|
+
held = klz.properties_template.is_a?(Hash) ? klz.properties_template['uuid'] : nil
|
|
257
|
+
sent = properties.is_a?(Hash) ? properties['uuid'] : nil
|
|
258
|
+
return if held.blank? || sent.blank? || held == sent
|
|
259
|
+
|
|
260
|
+
error!(stale_template_error(klz), 409)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Naming the other designer is what turns a refusal into something a user can act on, but
|
|
264
|
+
# it is decoration: `OwnerResolver.find` returns nil rather than raising, so a lookup that
|
|
265
|
+
# fails costs the name and leaves the refusal itself intact.
|
|
266
|
+
def stale_template_error(klz)
|
|
267
|
+
saver = Labimotion::OwnerResolver.find(klz.updated_by)
|
|
268
|
+
name = saver.respond_to?(:name) ? saver.name : nil
|
|
269
|
+
name = nil if name.blank?
|
|
270
|
+
{ mc: STALE_TEMPLATE_MC,
|
|
271
|
+
msg: stale_template_msg(name, klz.updated_at),
|
|
272
|
+
saved_by: { id: klz.updated_by, name: name },
|
|
273
|
+
saved_at: klz.updated_at.blank? ? nil : klz.updated_at.iso8601 }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def stale_template_msg(name, saved_at)
|
|
277
|
+
who = name.blank? ? 'Another user' : name
|
|
278
|
+
moment = saved_at.blank? ? 'after you opened it' : "at #{saved_at.strftime('%Y-%m-%d %H:%M %Z')}"
|
|
279
|
+
"#{who} saved this template #{moment}, so your copy is out of date; " \
|
|
280
|
+
'reload the template and re-apply your changes.'
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# `delete_klass` soft-deletes (the klasses are `acts_as_paranoid`), but nothing could list
|
|
284
|
+
# or restore the result, so recovery meant direct database access.
|
|
285
|
+
def deleted_klasses(params)
|
|
286
|
+
"Labimotion::#{params[:klass]}".constantize.only_deleted.order(deleted_at: :desc)
|
|
287
|
+
rescue StandardError => e
|
|
288
|
+
Labimotion.log_exception(e, current_user)
|
|
289
|
+
raise e
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def restore_klass(params)
|
|
293
|
+
klz = fetch_deleted_klass(params[:klass], params[:id])
|
|
294
|
+
# Restore is delete's inverse and carries the same cascade into elements, so it takes
|
|
295
|
+
# the same :destroy level. Shares survive deletion, so the owner row is still there to
|
|
296
|
+
# answer this.
|
|
297
|
+
authorize_klass!(klz, :destroy)
|
|
298
|
+
guard_restore_conflict!(params[:klass], klz)
|
|
299
|
+
# `recursive` is what brings the template's elements/segments back with it; without it
|
|
300
|
+
# paranoia restores the klass row alone and every child stays deleted. The window keeps
|
|
301
|
+
# that cascade to the children deleted *with* the template.
|
|
302
|
+
klz.restore!(recursive: true, recovery_window: RESTORE_RECOVERY_WINDOW)
|
|
303
|
+
generate_klass_file if params[:klass] == 'ElementKlass'
|
|
304
|
+
klz.reload
|
|
305
|
+
rescue StandardError => e
|
|
306
|
+
Labimotion.log_exception(e, current_user)
|
|
307
|
+
raise e
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def fetch_deleted_klass(name, id)
|
|
311
|
+
klz = "Labimotion::#{name}".constantize.only_deleted.find_by(id: id)
|
|
312
|
+
error!("#{name.gsub(/(Klass)/, '')} is not in the deleted list. Please refresh.", 404) if klz.nil?
|
|
313
|
+
klz
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# `restore!` writes through `update_columns`, so the uniqueness validations do not run, and
|
|
317
|
+
# no DB unique index backs them either. Restoring onto a taken identity would leave two
|
|
318
|
+
# active rows with the same one, and the ten `find_by(name:)` call sites in the gem — two of
|
|
319
|
+
# which pick the `properties_release` that renders real elements — would then resolve it
|
|
320
|
+
# arbitrarily. Refuse instead, and let a human decide which row keeps the name.
|
|
321
|
+
def guard_restore_conflict!(name, klz)
|
|
322
|
+
keys = KLASS_IDENTITY[name]
|
|
323
|
+
other = conflicting_active_klass(name, keys, klz)
|
|
324
|
+
return if other.nil?
|
|
325
|
+
|
|
326
|
+
error!({ mc: RESTORE_CONFLICT_MC,
|
|
327
|
+
msg: restore_conflict_msg(name, other, keys),
|
|
328
|
+
blocked_by: { id: other.id, label: klass_label(other, keys) } }, 409)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def conflicting_active_klass(name, keys, klz)
|
|
332
|
+
return nil if keys.nil?
|
|
333
|
+
|
|
334
|
+
identity = keys.to_h { |key| [key, klz.public_send(key)] }
|
|
335
|
+
return nil if identity[keys.first].blank?
|
|
336
|
+
|
|
337
|
+
# The default scope of a paranoid model excludes deleted rows, so this can only match an
|
|
338
|
+
# active one — never the row being restored.
|
|
339
|
+
"Labimotion::#{name}".constantize.where(identity).first
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def restore_conflict_msg(name, other, keys)
|
|
343
|
+
"Cannot restore: the active #{name.gsub(/(Klass)/, '')} \"#{klass_label(other, keys)}\" " \
|
|
344
|
+
"(id #{other.id}) already uses this #{KLASS_IDENTITY_NOUN[name]}."
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def klass_label(klz, keys)
|
|
348
|
+
label = klz.respond_to?(:label) ? klz.label : nil
|
|
349
|
+
label.blank? ? klz.public_send(keys.first).to_s : label
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# The revision must belong to the klass the caller named: the klass row is what
|
|
353
|
+
# authorize_klass! (and the active-revision guard) runs on, so accepting a mismatched
|
|
354
|
+
# pair would let ownership of one template authorize deleting — even the active —
|
|
355
|
+
# revisions of another.
|
|
356
|
+
def revision_of_klass?(revision, klass, klass_name)
|
|
357
|
+
foreign_key = "#{klass_name.sub('Klass', '_klass').downcase}_id"
|
|
358
|
+
!klass.nil? && revision.public_send(foreign_key) == klass.id
|
|
359
|
+
end
|
|
360
|
+
|
|
73
361
|
def delete_klass_revision(params)
|
|
74
362
|
revision = "Labimotion::#{params[:klass]}esRevision".constantize.find(params[:id])
|
|
75
363
|
klass = "Labimotion::#{params[:klass]}".constantize.find_by(id: params[:klass_id]) unless revision.nil?
|
|
76
364
|
error!('Revision is invalid.', 404) if revision.nil?
|
|
365
|
+
error!('Revision is invalid.', 404) unless revision_of_klass?(revision, klass, params[:klass])
|
|
366
|
+
authorize_klass!(klass, :destroy)
|
|
77
367
|
error!('Can not delete the active revision.', 405) if revision.uuid == klass.uuid
|
|
78
|
-
revision
|
|
368
|
+
revision.destroy!
|
|
79
369
|
rescue StandardError => e
|
|
80
370
|
Labimotion.log_exception(e, current_user)
|
|
81
371
|
raise e
|
|
@@ -101,7 +391,6 @@ module Labimotion
|
|
|
101
391
|
raise e
|
|
102
392
|
end
|
|
103
393
|
|
|
104
|
-
|
|
105
394
|
###############
|
|
106
395
|
def generate_klass_file
|
|
107
396
|
klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
|
|
@@ -14,6 +14,7 @@ module Labimotion
|
|
|
14
14
|
optional :klass_prefix, type: String, desc: 'Klass klass_prefix'
|
|
15
15
|
optional :icon_name, type: String, desc: 'Klass icon_name'
|
|
16
16
|
optional :metadata, type: Hash, desc: 'Klass metadata'
|
|
17
|
+
optional :settings, type: Hash, desc: 'Klass settings'
|
|
17
18
|
requires :properties_template, type: Hash, desc: 'Klass template'
|
|
18
19
|
optional :properties_release, type: Hash, desc: 'Klass release'
|
|
19
20
|
optional :released_at, type: DateTime, desc: 'Klass released_at'
|
|
@@ -34,6 +35,88 @@ module Labimotion
|
|
|
34
35
|
optional :properties_template, type: Hash, desc: 'Element Klass properties template'
|
|
35
36
|
end
|
|
36
37
|
|
|
38
|
+
params :create_ai_dataset_klass_params do
|
|
39
|
+
requires :ols_term_id, type: String, desc: 'CHMO ontology term (e.g. "CHMO:0000470 | mass spectrometry (MS)")'
|
|
40
|
+
optional :desc, type: String, desc: 'Description / intent of the new dataset template'
|
|
41
|
+
optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Default columns per row for generated layers'
|
|
42
|
+
optional :references, type: Array[String], desc: 'Reference links (publications / standards)'
|
|
43
|
+
optional :files, type: Array, desc: 'Reference files for context' do
|
|
44
|
+
optional :filename, type: String, desc: 'File name'
|
|
45
|
+
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|
|
46
|
+
optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
params :create_ai_element_klass_params do
|
|
51
|
+
requires :name, type: String, desc: 'Element Klass Name'
|
|
52
|
+
requires :label, type: String, desc: 'Element Klass Label'
|
|
53
|
+
requires :klass_prefix, type: String, desc: 'Element Klass Short Label Prefix'
|
|
54
|
+
optional :icon_name, type: String, desc: 'Element Klass Icon Name'
|
|
55
|
+
optional :desc, type: String, desc: 'Description / intent of the new element template'
|
|
56
|
+
optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Default columns per row for generated layers'
|
|
57
|
+
optional :references, type: Array[String], desc: 'Reference links (publications / standards)'
|
|
58
|
+
optional :files, type: Array, desc: 'Reference files for context' do
|
|
59
|
+
optional :filename, type: String, desc: 'File name'
|
|
60
|
+
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|
|
61
|
+
optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# AI auto-fill of a generic element instance's DATA VALUES from a document.
|
|
66
|
+
# `files` mirrors the create_ai_*_klass_params file block (source == 'upload').
|
|
67
|
+
params :ai_fill_element_data_params do
|
|
68
|
+
requires :element_id, type: Integer, desc: 'Generic element id whose data values to fill'
|
|
69
|
+
requires :source, type: String, values: %w[attachment analysis upload text], desc: 'Value source'
|
|
70
|
+
optional :attachment_id, type: Integer, desc: 'Attachment id (required when source == attachment)'
|
|
71
|
+
optional :container_id, type: Integer, desc: 'Analysis container id (required when source == analysis)'
|
|
72
|
+
optional :text, type: String, desc: 'Pasted plain text (source == text)'
|
|
73
|
+
optional :files, type: Array, desc: 'Uploaded files (source == upload)' do
|
|
74
|
+
optional :filename, type: String, desc: 'File name'
|
|
75
|
+
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|
|
76
|
+
optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
params :create_ai_segment_klass_params do
|
|
81
|
+
requires :label, type: String, desc: 'Segment Klass Label'
|
|
82
|
+
requires :element_klass, type: Integer, desc: 'Parent Element Klass Id'
|
|
83
|
+
optional :metadata, type: Hash, desc: 'Klass metadata (segment render kind, e.g. { is_properties })'
|
|
84
|
+
optional :desc, type: String, desc: 'Description / intent of the new segment template'
|
|
85
|
+
optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Default columns per row for generated layers'
|
|
86
|
+
optional :references, type: Array[String], desc: 'Reference links (publications / standards)'
|
|
87
|
+
optional :files, type: Array, desc: 'Reference files for context' do
|
|
88
|
+
optional :filename, type: String, desc: 'File name'
|
|
89
|
+
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|
|
90
|
+
optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
params :refine_ai_dataset_klass_params do
|
|
95
|
+
requires :instruction, type: String, desc: 'Natural-language change to apply to the template'
|
|
96
|
+
optional :label, type: String, desc: 'Current template label'
|
|
97
|
+
optional :ols_term_id, type: String, desc: 'CHMO ontology term (context only)'
|
|
98
|
+
optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Fallback columns per row when a layer omits its own'
|
|
99
|
+
optional :layers, type: Hash, desc: 'Current template layers'
|
|
100
|
+
optional :select_options, type: Hash, desc: 'Current template select options'
|
|
101
|
+
optional :history, type: Array, desc: 'Prior chat turns for continuity' do
|
|
102
|
+
optional :role, type: String, desc: 'user | assistant'
|
|
103
|
+
optional :content, type: String, desc: 'Message content'
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
params :plan_ai_dataset_klass_params do
|
|
108
|
+
requires :instruction, type: String, desc: 'Natural-language change to apply to the template'
|
|
109
|
+
optional :ols_term_id, type: String, desc: 'CHMO ontology term (context only)'
|
|
110
|
+
# The INDEX, not the template: layer/field keys, labels and types only. Left
|
|
111
|
+
# as an opaque Hash because the gem only relays it — the client builds it and
|
|
112
|
+
# the client consumes the plan that comes back.
|
|
113
|
+
optional :index, type: Hash, desc: 'Compact index of the open template (keys, labels, types, groups)'
|
|
114
|
+
optional :history, type: Array, desc: 'Prior chat turns for continuity' do
|
|
115
|
+
optional :role, type: String, desc: 'user | assistant'
|
|
116
|
+
optional :content, type: String, desc: 'Message content'
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
37
120
|
params :update_element_klass_params do
|
|
38
121
|
requires :id, type: Integer, desc: 'Element Klass ID'
|
|
39
122
|
optional :label, type: String, desc: 'Element Klass Label'
|
|
@@ -72,6 +155,7 @@ module Labimotion
|
|
|
72
155
|
requires :label, type: String, desc: 'Klass label'
|
|
73
156
|
optional :desc, type: String, desc: 'Klass desc'
|
|
74
157
|
optional :metadata, type: Hash, desc: 'Klass metadata'
|
|
158
|
+
optional :settings, type: Hash, desc: 'Klass settings'
|
|
75
159
|
requires :properties_template, type: Hash, desc: 'Klass template'
|
|
76
160
|
optional :properties_release, type: Hash, desc: 'Klass release'
|
|
77
161
|
optional :released_at, type: DateTime, desc: 'Klass released_at'
|
|
@@ -91,6 +175,7 @@ module Labimotion
|
|
|
91
175
|
optional :desc, type: String, desc: 'Segment Klass Desc'
|
|
92
176
|
optional :place, type: String, desc: 'Segment Klass Place', default: '100'
|
|
93
177
|
optional :identifier, type: String, desc: 'Segment Identifier'
|
|
178
|
+
optional :metadata, type: Hash, desc: 'Klass metadata'
|
|
94
179
|
end
|
|
95
180
|
|
|
96
181
|
params :create_segment_klass_params do
|
|
@@ -152,5 +237,25 @@ module Labimotion
|
|
|
152
237
|
requires :id, type: Integer, desc: 'Element ID'
|
|
153
238
|
requires :wellplate_ids, type: Array, desc: 'Selected Wellplates'
|
|
154
239
|
end
|
|
240
|
+
|
|
241
|
+
params :user_klass_settings_save do
|
|
242
|
+
requires :klass, type: String, values: Labimotion::Constants::Klass::USER_SETTINGS, desc: 'Klass type'
|
|
243
|
+
requires :klass_identifier, type: String, desc: 'Stable klass identifier'
|
|
244
|
+
optional :klass_label, type: String, desc: 'Klass label (display only)'
|
|
245
|
+
requires :settings, type: Hash do
|
|
246
|
+
optional :toolbar, type: Hash do
|
|
247
|
+
optional :overview, type: Boolean, default: true
|
|
248
|
+
optional :arrange, type: Boolean, default: true
|
|
249
|
+
end
|
|
250
|
+
# The user's default inline attributes for elements linked from a
|
|
251
|
+
# drag field. Only the identity of each attribute is stored; the values
|
|
252
|
+
# are resolved live on every load. An empty array clears the default.
|
|
253
|
+
optional :linked_el_attrs, type: Array do
|
|
254
|
+
requires :key, type: String, desc: 'Attribute key (layer::field, or column name)'
|
|
255
|
+
optional :source, type: String, desc: 'property or column'
|
|
256
|
+
optional :label, type: String, desc: 'Attribute label (display only)'
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
155
260
|
end
|
|
156
261
|
end
|
|
@@ -19,6 +19,13 @@ module Labimotion
|
|
|
19
19
|
[]
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def build_table_element(field_tables, current_user, element)
|
|
23
|
+
Labimotion::SampleAssociation.build_table_element(field_tables, current_user, element)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
Labimotion.log_exception(e, current_user)
|
|
26
|
+
[]
|
|
27
|
+
end
|
|
28
|
+
|
|
22
29
|
def update_sample_association(properties, current_user, element)
|
|
23
30
|
Labimotion::SampleAssociation.update_sample_association(properties, current_user, element)
|
|
24
31
|
rescue StandardError => e
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'grape'
|
|
3
|
-
require 'labimotion/models/segment_klass'
|
|
3
|
+
# require 'labimotion/models/segment_klass' — the constant resolves through the gem's
|
|
4
|
+
# autoload table at call time, like the element path; an eager require drags ActiveRecord
|
|
5
|
+
# into any process that only wants the helpers (element_helpers keeps its own model require
|
|
6
|
+
# commented for the same reason).
|
|
4
7
|
require 'labimotion/utils/utils'
|
|
5
8
|
|
|
6
9
|
module Labimotion
|
|
@@ -36,7 +39,13 @@ module Labimotion
|
|
|
36
39
|
attributes[:uuid] = uuid
|
|
37
40
|
attributes[:released_at] = DateTime.now
|
|
38
41
|
attributes[:properties_release] = attributes[:properties_template]
|
|
39
|
-
klass
|
|
42
|
+
# The owner row shares the create's transaction: a klass committed without it is
|
|
43
|
+
# owner-less, which falls open to the legacy designer-wide gate.
|
|
44
|
+
klass = Labimotion::SegmentKlass.transaction do
|
|
45
|
+
Labimotion::SegmentKlass.create!(attributes).tap do |klz|
|
|
46
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
40
49
|
klass.reload
|
|
41
50
|
klass.create_klasses_revision(current_user)
|
|
42
51
|
klass
|
|
@@ -45,8 +54,86 @@ module Labimotion
|
|
|
45
54
|
raise e
|
|
46
55
|
end
|
|
47
56
|
|
|
57
|
+
# Create a new (inactive) segment klass whose properties template is
|
|
58
|
+
# generated by an LLM from a user-provided label plus optional description,
|
|
59
|
+
# reference links and uploaded files. The parent ElementKlass must be set on
|
|
60
|
+
# @klass by the API (after_validation), mirroring create_segment_klass. The
|
|
61
|
+
# admin reviews/edits the generated template in the designer and activates it.
|
|
62
|
+
def create_ai_segment_klass(current_user, params)
|
|
63
|
+
return { status: 'error', message: 'A parent element class is required.' } if @klass.nil?
|
|
64
|
+
|
|
65
|
+
if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
|
|
66
|
+
return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
overrides = ai_user_overrides(current_user)
|
|
70
|
+
ai = Labimotion::AiTemplate.generate(
|
|
71
|
+
kind: 'segment',
|
|
72
|
+
subject: params[:label],
|
|
73
|
+
desc: params[:desc],
|
|
74
|
+
cols: params[:cols],
|
|
75
|
+
references: params[:references],
|
|
76
|
+
files: params[:files],
|
|
77
|
+
**overrides
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
uuid = SecureRandom.uuid
|
|
81
|
+
# property-base schema requires pkg, uuid, klass, layers, version, identifier.
|
|
82
|
+
properties_template = {
|
|
83
|
+
'uuid' => uuid,
|
|
84
|
+
'klass' => 'SegmentKlass',
|
|
85
|
+
'pkg' => Labimotion::Utils.pkg(nil),
|
|
86
|
+
'version' => '1.0.0',
|
|
87
|
+
'identifier' => uuid,
|
|
88
|
+
'layers' => ai['layers'],
|
|
89
|
+
'select_options' => ai['select_options'],
|
|
90
|
+
'metadata' => ai['metadata']
|
|
91
|
+
}
|
|
92
|
+
attributes = {
|
|
93
|
+
'label' => params[:label],
|
|
94
|
+
'desc' => params[:desc].presence || ai['label'].presence,
|
|
95
|
+
'element_klass' => @klass,
|
|
96
|
+
'place' => 100,
|
|
97
|
+
'is_active' => false,
|
|
98
|
+
'uuid' => uuid,
|
|
99
|
+
'released_at' => DateTime.now,
|
|
100
|
+
'properties_template' => properties_template,
|
|
101
|
+
'properties_release' => properties_template,
|
|
102
|
+
'created_by' => current_user.id
|
|
103
|
+
}
|
|
104
|
+
# Klass-level segment metadata (render kind: own tab vs the host element's
|
|
105
|
+
# Properties tab), mirroring the non-AI create_segment_klass which persists
|
|
106
|
+
# params[:metadata] via declared. Absent -> leave the column default.
|
|
107
|
+
attributes['metadata'] = params[:metadata] if params[:metadata].present?
|
|
108
|
+
|
|
109
|
+
# Same as the non-AI create beside it: the owner row shares the create's
|
|
110
|
+
# transaction, because a klass committed without one is owner-less and
|
|
111
|
+
# falls OPEN to the legacy designer-wide gate — every designer of the
|
|
112
|
+
# family could then edit, release and delete it, while its creator holds
|
|
113
|
+
# no special standing at all. seed_owner! rescues only RecordNotUnique and
|
|
114
|
+
# RecordInvalid, so any other failure has to take the klass down with it;
|
|
115
|
+
# this helper turns every StandardError into { status: 'error' }, and
|
|
116
|
+
# without the transaction the caller would be told the create failed while
|
|
117
|
+
# an unowned template silently persisted.
|
|
118
|
+
klass = Labimotion::SegmentKlass.transaction do
|
|
119
|
+
Labimotion::SegmentKlass.create!(attributes).tap do |klz|
|
|
120
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
klass.reload
|
|
124
|
+
klass.create_klasses_revision(current_user)
|
|
125
|
+
# `record` is the row itself. The background job that calls this needs the
|
|
126
|
+
# template, not a sentence about it, to link the notification back to it.
|
|
127
|
+
{ status: 'success', record: klass,
|
|
128
|
+
message: "The AI segment template [#{params[:label]}] has been created as inactive. Review and activate it in the designer." }
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
Labimotion.log_exception(e, current_user)
|
|
131
|
+
{ status: 'error', message: e.message }
|
|
132
|
+
end
|
|
133
|
+
|
|
48
134
|
def update_segment_klass(current_user, params)
|
|
49
135
|
segment = fetch_klass('SegmentKlass', params[:id])
|
|
136
|
+
authorize_klass!(segment, :write)
|
|
50
137
|
place = params[:place]
|
|
51
138
|
begin
|
|
52
139
|
place = place.to_i if place.present? && place.to_i == place.to_f
|
|
@@ -77,7 +164,14 @@ module Labimotion
|
|
|
77
164
|
if segment_klass['uuid'] == attributes['uuid'] && segment_klass['version'] == attributes['version']
|
|
78
165
|
return { status: 'success', message: "This segment: #{attributes['label']} has the latest version!" }
|
|
79
166
|
else
|
|
80
|
-
|
|
167
|
+
# Upgrading bumps the version and cuts a revision — :release, owner-only, and the
|
|
168
|
+
# gate lives here because the create/update branch is unknown until the identifier
|
|
169
|
+
# lookup. error! throws rather than raises, so it escapes this method's rescue.
|
|
170
|
+
authorize_klass!(segment_klass, :release)
|
|
171
|
+
# Same reassignment defect as the element path: `upload_klass` merges
|
|
172
|
+
# `created_by: current_user.id` unconditionally (segment_api.rb:147), so upgrading an
|
|
173
|
+
# existing segment template would hand its authorship to the importer.
|
|
174
|
+
segment_klass.update!(attributes.except('created_by', :created_by))
|
|
81
175
|
segment_klass.create_klasses_revision(current_user)
|
|
82
176
|
return { status: 'success', message: "This segment: [#{attributes['label']}] has been upgraded to the version: #{attributes['version']}!" }
|
|
83
177
|
end
|
|
@@ -87,7 +181,11 @@ module Labimotion
|
|
|
87
181
|
return { status: 'error', message: "The segment [#{attributes['label']}] is already in use." }
|
|
88
182
|
else
|
|
89
183
|
attributes['created_by'] = current_user.id
|
|
90
|
-
segment_klass = Labimotion::SegmentKlass.
|
|
184
|
+
segment_klass = Labimotion::SegmentKlass.transaction do
|
|
185
|
+
Labimotion::SegmentKlass.create!(attributes).tap do |klz|
|
|
186
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
91
189
|
segment_klass.create_klasses_revision(current_user)
|
|
92
190
|
return { status: 'success', message: "The segment: #{attributes['label']} has been created using version: #{attributes['version']}!" }
|
|
93
191
|
end
|