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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +198 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +74 -8
  6. data/lib/labimotion/apis/klass_share_api.rb +648 -0
  7. data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
  8. data/lib/labimotion/apis/labimotion_api.rb +4 -0
  9. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  10. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  11. data/lib/labimotion/apis/ontology_root_api.rb +74 -0
  12. data/lib/labimotion/apis/segment_api.rb +77 -10
  13. data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
  14. data/lib/labimotion/conf.rb +5 -0
  15. data/lib/labimotion/constants.rb +64 -0
  16. data/lib/labimotion/entities/application_entity.rb +8 -0
  17. data/lib/labimotion/entities/eln_element_entity.rb +6 -0
  18. data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
  19. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  20. data/lib/labimotion/entities/properties_entity.rb +78 -2
  21. data/lib/labimotion/entities/segment_entity.rb +8 -0
  22. data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
  23. data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
  24. data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
  25. data/lib/labimotion/helpers/element_helpers.rb +282 -6
  26. data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
  27. data/lib/labimotion/helpers/generic_helpers.rb +293 -4
  28. data/lib/labimotion/helpers/param_helpers.rb +105 -0
  29. data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
  30. data/lib/labimotion/helpers/segment_helpers.rb +102 -4
  31. data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
  32. data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
  33. data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
  34. data/lib/labimotion/libs/ai_models.rb +201 -0
  35. data/lib/labimotion/libs/ai_template.rb +2285 -0
  36. data/lib/labimotion/libs/converter.rb +5 -43
  37. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  38. data/lib/labimotion/libs/export_element.rb +128 -13
  39. data/lib/labimotion/libs/file_extractor.rb +210 -0
  40. data/lib/labimotion/libs/linked_element.rb +313 -0
  41. data/lib/labimotion/libs/ontology_store.rb +226 -0
  42. data/lib/labimotion/libs/ontology_terms.rb +227 -0
  43. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  44. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  45. data/lib/labimotion/libs/sample_association.rb +52 -1
  46. data/lib/labimotion/libs/share_notifier.rb +114 -0
  47. data/lib/labimotion/libs/share_resolver.rb +373 -0
  48. data/lib/labimotion/libs/user_ai_settings.rb +129 -0
  49. data/lib/labimotion/models/cellline.rb +47 -0
  50. data/lib/labimotion/models/concerns/datasetable.rb +3 -0
  51. data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
  52. data/lib/labimotion/models/concerns/segmentable.rb +2 -0
  53. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  54. data/lib/labimotion/models/dataset_klass.rb +1 -1
  55. data/lib/labimotion/models/element_klass.rb +1 -1
  56. data/lib/labimotion/models/klass_share.rb +129 -0
  57. data/lib/labimotion/models/segment_klass.rb +1 -1
  58. data/lib/labimotion/models/user_klass_setting.rb +58 -0
  59. data/lib/labimotion/models/user_setting.rb +60 -0
  60. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  61. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  62. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  63. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  64. data/lib/labimotion/utils/export_utils.rb +1 -0
  65. data/lib/labimotion/utils/import_utils.rb +20 -3
  66. data/lib/labimotion/utils/serializer.rb +27 -0
  67. data/lib/labimotion/utils/units.rb +32 -67
  68. data/lib/labimotion/version.rb +1 -1
  69. data/lib/labimotion.rb +27 -0
  70. 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('elements', 'segments', 'datasets').any?
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&.destroy!
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 = Labimotion::SegmentKlass.create!(attributes)
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
- segment_klass.update!(attributes)
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.create!(attributes)
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