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
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labimotion/constants'
4
+
5
+ module Labimotion
6
+ ## ShareResolver
7
+ # The one place share rows are turned into an answer, for both shapes of question:
8
+ # `authorize` for a single record on a mutating endpoint, `context_for` for a whole grid —
9
+ # a hundred-template list must cost a fixed number of queries, not a lookup per row
10
+ # (template-sharing.md §7).
11
+ class ShareResolver
12
+ # The authorization ladder (§6). Seven names even where thresholds coincide: the call site
13
+ # documents intent, and the collapse stays visible in one constant instead of drifting
14
+ # apart across the codebase. `release_minor` (25, the maintainer's one extra right) is
15
+ # distinct from `release` (30, major — still owner-only) for the same reason: the two
16
+ # release buttons ask two different questions, and collapsing them here is how an editor
17
+ # once ended up refused on a button the grid had offered. Values are KlassShare::LEVELS
18
+ # (10 viewer / 20 editor / 25 maintainer / 30 owner) as literals — dereferencing the
19
+ # ActiveRecord model in this class body would force it to load in any process that touches
20
+ # the resolver, and the numbers are already frozen into the one-owner partial index anyway.
21
+ REQUIRED_LEVEL = {
22
+ read: 10, write: 20, release_minor: 25, release: 30, deactivate: 30, destroy: 30,
23
+ manage: 30
24
+ }.freeze
25
+
26
+ # The requester's inbox predicate (§7), verbatim from the design, as one SQL fragment
27
+ # because its three conjuncts are one idea: "a grant somebody else made me, which I have
28
+ # not seen in its current state".
29
+ #
30
+ # permission_level >= 10
31
+ # A level that actually confers access. A level-0 row is the caller's own standing ask,
32
+ # and it belongs to the *owner's* queue, not to theirs.
33
+ #
34
+ # created_by <> shared_with_id
35
+ # Load-bearing, and it looks removable — it is not. Every owner row is a self-grant
36
+ # written by KlassShare.seed_owner! on the create path (and by the seed migration for
37
+ # everything older), so without this conjunct every designer would be told "you were
38
+ # granted owner on…" for every template they ever made. A real grant re-stamps
39
+ # created_by to the granter (KlassShareAPI's POST /shares), and transfer_ownership does
40
+ # the same, so both still announce themselves.
41
+ #
42
+ # acked_at IS NULL OR acked_at < updated_at
43
+ # The whole ack mechanism — see KlassShare's comment on the column. There is no reset
44
+ # logic: a later upgrade or transfer moves updated_at past acked_at and re-announces by
45
+ # itself, and a revocation is a hard delete, so the entry disappears instead.
46
+ #
47
+ # Levels as literals for the reason REQUIRED_LEVEL gives above.
48
+ UNACKNOWLEDGED_GRANTS = 'permission_level >= 10 AND created_by <> shared_with_id ' \
49
+ 'AND (acked_at IS NULL OR acked_at < updated_at)'
50
+
51
+ # A refusal carries who to ask — naming the owner is what keeps a 403 from reading as an
52
+ # authorisation failure between equally authorised peers (§3).
53
+ Refusal = Struct.new(:owner_id)
54
+
55
+ # An owner entry in the context: the share row's user id survives even when the user
56
+ # record cannot be resolved, so a departed-and-purged owner still shows as "user #id
57
+ # (unknown)" instead of losing the id the transfer flow needs.
58
+ OwnerRef = Struct.new(:id, :user)
59
+
60
+ class << self
61
+ def key_for(klass)
62
+ Labimotion::KlassShare.key_of(klass)
63
+ end
64
+
65
+ # :ok — the action may proceed.
66
+ # :legacy — no owner row exists (or the host has not migrated): fall back to the
67
+ # designer-wide authenticate_admin! gate. Permanent, not transitional — an
68
+ # un-backfilled template degrades to "as before", never to "locked out".
69
+ # Refusal — the template is owned and the user's level is insufficient.
70
+ #
71
+ # Resolution order: administrator (STI type 'Admin' — owner level on everything,
72
+ # implicit, no rows of its own) → the user's own share, a SegmentKlass taking the
73
+ # higher of its own level and its parent ElementKlass's → legacy fallback when no
74
+ # owner row exists. Errors propagate: an owned template must never fail open just
75
+ # because a query failed.
76
+ #
77
+ # A departed owner's row still counts as "has an owner" — ignoring it would drop the
78
+ # klass into the legacy gate, which is exactly the fail-open the design forbids; the
79
+ # row waits for the super owner's transfer instead. The requesting user's own rows
80
+ # need no departed filter: a deleted or deactivated user cannot be logged in.
81
+ def authorize(klass, user, action)
82
+ required = REQUIRED_LEVEL.fetch(action)
83
+ return :legacy unless shares_enabled?
84
+ return :ok if admin?(user)
85
+
86
+ level = level_for(klass, user)
87
+ return :ok if level && level >= required
88
+
89
+ owner_row = Labimotion::KlassShare.owner_row_for(klass)
90
+ return :legacy if owner_row.nil?
91
+
92
+ Refusal.new(owner_row.shared_with_id)
93
+ end
94
+
95
+ # Per-request preload for the grids: nil when the host has not migrated (entities then
96
+ # keep their pre-share behaviour), else the owner and the requesting user's level per
97
+ # klass key, in two share queries plus one user query however long the list is.
98
+ # Departed users stay in the owners map on purpose — the grid badge and the
99
+ # awaiting-transfer list are how anyone finds out.
100
+ def context_for(klasses, user)
101
+ return nil unless shares_enabled?
102
+
103
+ rows = Array(klasses).compact
104
+ shares = shares_for(rows)
105
+
106
+ {
107
+ admin: admin?(user),
108
+ has_owner: owned_keys(shares),
109
+ owners: owners_map(shares),
110
+ levels: levels_map(rows, shares, user),
111
+ # The requesting user's legacy designer rights per klass type: what an owner-less
112
+ # template falls back to. The ungated list endpoints serve non-designers too, so
113
+ # capability booleans must not assume everyone reading the grid holds them.
114
+ legacy: legacy_rights(user),
115
+ pending_requests: pending_requests_map(shares, user)
116
+ }
117
+ rescue StandardError => e
118
+ # The grid must render even if share resolution breaks; entities fall back to the
119
+ # pre-share display. Display only — `authorize` above deliberately has no such net.
120
+ Labimotion.log_exception(e)
121
+ nil
122
+ end
123
+
124
+ # The Designer's share poller (§7), over the share rows the requesting user is involved
125
+ # in: the rows anybody holds on the templates they own — every collaborator's, every
126
+ # requester's — plus the rows granting them access on other people's templates.
127
+ #
128
+ # Three values, two questions. `count` and `latest` answer "did anything about my sharing
129
+ # move at all", the cheap early-out a client can use alone. `digests` answers "which
130
+ # templates", one entry per involved klass key, each the same pair taken over that klass's
131
+ # rows alone — so the client re-reads the rows whose digest changed, appeared or vanished
132
+ # instead of reloading the whole grid. That whole-grid reload is what destroyed a
133
+ # Designer's unsaved edits, since re-seeding the list re-seeds the open Work Area.
134
+ #
135
+ # Opaque change tokens, not clocks. The client compares them for inequality in *either*
136
+ # direction, and none of this must ever grow "newer than" semantics: a revocation is a
137
+ # hard delete (KlassShare is deliberately not acts_as_paranoid), so a key's `latest` moves
138
+ # backwards while its `count` drops, or the key disappears from the map outright —
139
+ # precisely the events a timestamp cursor would silently stop detecting.
140
+ #
141
+ # Aggregates, never the rows: this is polled about once a minute per open Designer page,
142
+ # and the owner of a busy template is involved in every row on it. The per-key map is a
143
+ # GROUP BY over the same scope, so it costs the same two round trips the bare pair did —
144
+ # and the pair is then derived from the groups rather than queried again, which also makes
145
+ # `count` the sum of the digests' counts by construction.
146
+ #
147
+ # The administrator is an implicit owner everywhere but holds rows only where they really
148
+ # own something, so their token moves for their own templates alone. Same stance as
149
+ # pending_requests_map: it is the owner's signal, not a global one.
150
+ def activity_for(user)
151
+ return { count: 0, latest: nil, digests: {} } unless shares_enabled?
152
+
153
+ scope = involving(user).group(:klass_type, :klass_id)
154
+ # Two round trips over one scope rather than a hand-written SELECT COUNT(*), MAX(...):
155
+ # a write landing between them yields either a token that differs from the client's (a
156
+ # spurious refresh) or one that matches it and is superseded by the next poll a minute
157
+ # later. Neither loses an event, and the query stays ActiveRecord's.
158
+ counts = scope.count
159
+ latests = scope.maximum(:updated_at)
160
+ { count: counts.values.sum,
161
+ latest: latests.values.compact.max,
162
+ digests: digests_map(counts, latests) }
163
+ end
164
+
165
+ # The Designer's share inbox (§7), and the last hop of the request loop finally landing
166
+ # where somebody is looking: the owner's queue of standing requests, and the requester's
167
+ # queue of grants they have not marked seen.
168
+ #
169
+ # Both are queries over klass_shares. There is no event table and no message log by
170
+ # decision, so nothing can hold ack state that disagrees with the rows themselves —
171
+ # a second store would immediately have its own retention question and its own idea of
172
+ # what is outstanding.
173
+ #
174
+ # `requests` — the owner's queue: the level-0 rows on the templates the caller holds the
175
+ # owner row on. No column gates it and none should. A grant promotes the row off level 0
176
+ # and a reject hard-deletes it, so the queue empties itself; that *is* the ack, and there
177
+ # is deliberately no dismiss-without-acting for a column to record. It is also the same
178
+ # predicate pending_requests_map counts for the grid's ShareBtn badge, over the same
179
+ # rows — so the badge and the queue cannot disagree about what is waiting.
180
+ #
181
+ # `grants` — the requester's queue: see UNACKNOWLEDGED_GRANTS.
182
+ #
183
+ # Rows rather than aggregates, unlike activity_for on the same poll tick, and bounded by
184
+ # what a human can act on rather than by the size of the account: a level-0 row lasts only
185
+ # until somebody answers it, and a grant row only until its recipient marks it seen.
186
+ #
187
+ # At most three queries however large the account — the owned keys, then one per queue,
188
+ # with the requests query skipped outright for a caller who owns nothing. The caller
189
+ # resolves klass labels and user names on top of that, in bulk.
190
+ def inbox_for(user)
191
+ return { requests: [], grants: [] } unless shares_enabled?
192
+
193
+ { requests: pending_requests_for(user), grants: unacknowledged_grants_for(user) }
194
+ end
195
+
196
+ def admin?(user)
197
+ user.respond_to?(:type) && user.type == 'Admin'
198
+ end
199
+
200
+ private
201
+
202
+ # The owned keys grouped by klass_type into one exact `klass_type = ? AND klass_id IN (…)`
203
+ # branch each, exactly as `involving` does it — a user owns at most the three types, so the
204
+ # OR stays short and every branch stays indexed. The level filter lands outside the OR:
205
+ # `(A OR B) AND permission_level = 0`.
206
+ def pending_requests_for(user)
207
+ scope = owned_scope(owned_keys_for(user))
208
+ return [] if scope.nil?
209
+
210
+ scope.where(permission_level: Labimotion::KlassShare::LEVELS[:requested]).to_a
211
+ end
212
+
213
+ def owned_scope(keys)
214
+ keys.group_by(&:first).reduce(nil) do |scope, (type, group)|
215
+ branch = Labimotion::KlassShare.where(klass_type: type,
216
+ klass_id: group.map(&:last).uniq)
217
+ scope.nil? ? branch : scope.or(branch)
218
+ end
219
+ end
220
+
221
+ # One query, on the shared_with_id index.
222
+ def unacknowledged_grants_for(user)
223
+ Labimotion::KlassShare.where(shared_with_id: user.id)
224
+ .where(UNACKNOWLEDGED_GRANTS)
225
+ .to_a
226
+ end
227
+
228
+ # The grouped aggregates, re-keyed into the `"KlassType:id"` strings KlassShare.key_of
229
+ # produces — the same format the batch row read takes as its `keys[]`, so an entry the
230
+ # client saw move is directly the argument that re-reads it.
231
+ #
232
+ # The values stay a Ruby Integer and Time: turning the pair into its wire string belongs
233
+ # next to where `latest` is formatted, in the API, so the millisecond precision the whole
234
+ # token depends on is decided in exactly one place.
235
+ def digests_map(counts, latests)
236
+ counts.each_with_object({}) do |(pair, count), map|
237
+ map["#{pair.first}:#{pair.last}"] = { count: count, latest: latests[pair] }
238
+ end
239
+ end
240
+
241
+ # Every share row the user is involved in, as one scope: the rows naming them ORed with
242
+ # the rows on the templates they own. The OR is also what de-duplicates the overlap —
243
+ # their own owner row belongs to both halves — so nothing has to be counted twice and
244
+ # subtracted back.
245
+ #
246
+ # Same tuple-IN problem as rows_matching, solved the other way round. That one queries the
247
+ # cross-product of types and ids and narrows back to the exact keys *in Ruby*, which needs
248
+ # the rows in hand; an aggregate has none. Grouping the owned keys by klass_type instead
249
+ # makes every branch exact in SQL — `klass_type = ? AND klass_id IN (…)` — and a user owns
250
+ # at most the three types, so the OR stays short. Both halves are indexed: shared_with_id
251
+ # has an index of its own, and (klass_type, klass_id) is the leading pair of the unique
252
+ # klass-and-user index.
253
+ def involving(user)
254
+ mine = Labimotion::KlassShare.where(shared_with_id: user.id)
255
+ owned_keys_for(user).group_by(&:first).reduce(mine) do |scope, (type, keys)|
256
+ scope.or(Labimotion::KlassShare.where(klass_type: type,
257
+ klass_id: keys.map(&:last).uniq))
258
+ end
259
+ end
260
+
261
+ # The (klass_type, klass_id) of every template the user holds the owner row on — the one
262
+ # thing here that is read rather than counted, and unavoidably so: these keys *are* the
263
+ # predicate the aggregate is taken over. Two columns, no model instances, one lookup on
264
+ # the shared_with_id index.
265
+ def owned_keys_for(user)
266
+ Labimotion::KlassShare
267
+ .where(shared_with_id: user.id,
268
+ permission_level: Labimotion::KlassShare::LEVELS[:owner])
269
+ .pluck(:klass_type, :klass_id)
270
+ end
271
+
272
+ def shares_enabled?
273
+ Labimotion::KlassShare.table_exists?
274
+ rescue StandardError
275
+ false
276
+ end
277
+
278
+ # A SegmentKlass inherits from its parent ElementKlass (§4): you cannot own an element
279
+ # template yet be locked out of its own segments. Datasets have no parent.
280
+ def parent_key(klass)
281
+ return nil unless klass.respond_to?(:element_klass_id) && klass.element_klass_id.present?
282
+
283
+ "ElementKlass:#{klass.element_klass_id}"
284
+ end
285
+
286
+ def key_of_row(row)
287
+ "#{row.klass_type}:#{row.klass_id}"
288
+ end
289
+
290
+ def level_for(klass, user)
291
+ keys = [key_for(klass), parent_key(klass)].compact
292
+ rows_matching(keys, Labimotion::KlassShare.where(shared_with_id: user.id))
293
+ .map(&:level).max
294
+ end
295
+
296
+ # One query over the cross-product of types and ids, narrowed back to the exact keys in
297
+ # Ruby — an IN-list per column instead of a tuple IN, which ActiveRecord 6.1 cannot
298
+ # express portably.
299
+ def shares_for(klasses)
300
+ keys = (klasses.map { |klz| key_for(klz) } +
301
+ klasses.filter_map { |klz| parent_key(klz) }).uniq
302
+ rows_matching(keys, Labimotion::KlassShare.all)
303
+ end
304
+
305
+ def rows_matching(keys, scope)
306
+ pairs = keys.uniq.map { |key| key.split(':') }
307
+ return [] if pairs.empty?
308
+
309
+ scope.where(klass_type: pairs.map(&:first).uniq,
310
+ klass_id: pairs.map { |pair| pair.last.to_i }.uniq)
311
+ .to_a
312
+ .select { |row| keys.include?(key_of_row(row)) }
313
+ end
314
+
315
+ def owner_rows(shares)
316
+ shares.select { |row| row.level == Labimotion::KlassShare::LEVELS[:owner] }
317
+ end
318
+
319
+ def owned_keys(shares)
320
+ owner_rows(shares).map { |row| key_of_row(row) }.uniq
321
+ end
322
+
323
+ def owners_map(shares)
324
+ rows = owner_rows(shares)
325
+ users = Labimotion::OwnerResolver.map_for_ids(rows.map(&:shared_with_id))
326
+ rows.to_h do |row|
327
+ [key_of_row(row), OwnerRef.new(row.shared_with_id, users[row.shared_with_id])]
328
+ end
329
+ end
330
+
331
+ # Which legacy designer right in `users.generic_admin` answers for an owner-less klass
332
+ # of each type.
333
+ def legacy_rights(user)
334
+ grants = user.respond_to?(:generic_admin) && user.generic_admin.is_a?(Hash) ? user.generic_admin : {}
335
+ Labimotion::Constants::Family::FAMILY_OF.transform_values { |right| grants[right] == true }
336
+ end
337
+
338
+ # How many people are waiting on the requesting user, per template they own — what
339
+ # colours the Share button in the grid. A derivation over rows already in hand, not a
340
+ # query of its own: `shares` is every share row for the listed klasses, level-0 rows
341
+ # included, so the count is a select and a tally.
342
+ #
343
+ # Scoped to the user's own owner rows, which is the whole design of it. A count over
344
+ # every listed template would ship "somebody asked" to every viewer and editor in the
345
+ # payload — a signal only the person who can grant it can act on. Sparse on purpose:
346
+ # keys with nothing pending are absent and the entity reads a missing key as 0, so a
347
+ # grid of a hundred templates carries entries only for the ones actually waiting.
348
+ #
349
+ # The administrator is an implicit owner everywhere but holds no rows, so they see
350
+ # counts on the templates they really own and none on other people's. Correct: it is
351
+ # the owner's queue, not a global inbox.
352
+ def pending_requests_map(shares, user)
353
+ owned = owner_rows(shares).select { |row| row.shared_with_id == user.id }
354
+ .map { |row| key_of_row(row) }
355
+ return {} if owned.empty?
356
+
357
+ shares.select { |row| row.level == Labimotion::KlassShare::LEVELS[:requested] }
358
+ .map { |row| key_of_row(row) }
359
+ .select { |key| owned.include?(key) }
360
+ .tally
361
+ end
362
+
363
+ def levels_map(klasses, shares, user)
364
+ by_key = shares.select { |row| row.shared_with_id == user.id }
365
+ .group_by { |row| key_of_row(row) }
366
+ klasses.to_h do |klz|
367
+ levels = (by_key[key_for(klz)] || []) + (by_key[parent_key(klz)] || [])
368
+ [key_for(klz), levels.map(&:level).max]
369
+ end
370
+ end
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ ## Per-user LabIMotion AI settings: the model, an optional personal API key and
5
+ # an optional provider endpoint of one's own.
6
+ #
7
+ # These used to sit in the host's profile jsonb. They now live in our own
8
+ # user_settings table under the 'ai' key, because the settings are LabIMotion's
9
+ # and the host's profile is not a good place to keep growing them — see
10
+ # Labimotion::UserSetting.
11
+ #
12
+ # The API key is stored ENCRYPTED and never returned to a client: only whether
13
+ # one is set. Encryption stays the HOST's — key management is a deployment
14
+ # concern and a gem has no business inventing one. Chemotion supplies it as an
15
+ # `Encryptor` concern, pulled in below when present. A host that names its
16
+ # encryption differently defines `encrypt_value` / `decrypt_value` instead.
17
+ #
18
+ # settings = Labimotion::UserAiSettings.for(current_user)
19
+ # settings.model # => 'azure.gpt-4.1'
20
+ # settings.api_key? # => true (never the key itself, to a client)
21
+ # settings.update(model: 'azure.gpt-5')
22
+ #
23
+ # `store` is injectable so the shape can be exercised without a database.
24
+ class UserAiSettings
25
+ KEY = Labimotion::Constants::UserSettingKey::AI
26
+
27
+ # Reference, not `defined?`: under Zeitwerk the latter reads nil until a
28
+ # constant has been touched, while referencing it triggers the autoload.
29
+ # rubocop:disable Layout/EmptyLinesAfterModuleInclusion -- the blank line
30
+ # this wants collides with EmptyLinesAroundExceptionHandlingKeywords.
31
+ begin
32
+ include Encryptor
33
+ rescue NameError
34
+ nil
35
+ end
36
+ # rubocop:enable Layout/EmptyLinesAfterModuleInclusion
37
+
38
+ attr_reader :user_id
39
+
40
+ def initialize(user_id, store: Labimotion::UserSetting)
41
+ @user_id = user_id
42
+ @store = store
43
+ end
44
+
45
+ # From whatever the host calls a user. Accepts the record or a bare id.
46
+ def self.for(user, store: Labimotion::UserSetting)
47
+ new(user.respond_to?(:id) ? user.id : user, store: store)
48
+ end
49
+
50
+ def settings
51
+ @store.settings_for(@user_id, KEY)
52
+ end
53
+
54
+ def model
55
+ settings['model'].presence
56
+ end
57
+
58
+ # Personal provider endpoint (BYO-Provider). Honored only alongside a
59
+ # personal key, and SSRF-validated by Labimotion::AiEgressGuard before any
60
+ # request is made.
61
+ def base_url
62
+ settings['base_url'].presence
63
+ end
64
+
65
+ def api_path
66
+ settings['api_path'].presence
67
+ end
68
+
69
+ def api_key?
70
+ settings['api_key'].present?
71
+ end
72
+
73
+ # Decrypted personal API key, or nil when none is stored.
74
+ def api_key
75
+ enc = settings['api_key']
76
+ return nil if enc.blank?
77
+
78
+ decrypt_value(enc).presence
79
+ end
80
+
81
+ # What is safe to hand a client: everything but the key, which is reported
82
+ # only as set-or-not.
83
+ def summary
84
+ stored = settings
85
+ { model: stored['model'], api_key_set: stored['api_key'].present?,
86
+ base_url: stored['base_url'], api_path: stored['api_path'] }
87
+ end
88
+
89
+ # Persist the settings. For every field: nil leaves it unchanged, a blank
90
+ # string clears it, anything else is stored (the api_key encrypted).
91
+ # Returns the safe summary — never the key itself.
92
+ def update(model: nil, api_key: nil, base_url: nil, api_path: nil)
93
+ @store.update_settings(@user_id, KEY) do |ai|
94
+ set_or_clear(ai, 'model', model)
95
+ set_or_clear_api_key(ai, api_key)
96
+ set_or_clear(ai, 'base_url', base_url)
97
+ set_or_clear(ai, 'api_path', api_path)
98
+ ai
99
+ end
100
+ summary
101
+ end
102
+
103
+ private
104
+
105
+ # nil -> leave unchanged; blank string -> clear; else store the trimmed value.
106
+ def set_or_clear(stored, key, value)
107
+ return if value.nil?
108
+
109
+ trimmed = value.to_s.strip
110
+ if trimmed.blank?
111
+ stored.delete(key)
112
+ else
113
+ stored[key] = trimmed
114
+ end
115
+ end
116
+
117
+ # As set_or_clear, but the value is encrypted on the way in.
118
+ def set_or_clear_api_key(stored, value)
119
+ return if value.nil?
120
+
121
+ trimmed = value.to_s.strip
122
+ if trimmed.blank?
123
+ stored.delete('api_key')
124
+ else
125
+ stored['api_key'] = encrypt_value(trimmed)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file extends the existing CelllineSample model in the consuming app
4
+ # and defines a Labimotion::CellLine wrapper for convenient access.
5
+ # NB: the ELN host model is ::CelllineSample, but the wrapper is named
6
+ # Labimotion::CellLine so that "cell_line".camelize == "CellLine" resolves
7
+ # to it in the search_basic_by_like endpoint.
8
+
9
+ # rubocop:disable Style/RedundantConstantBase
10
+
11
+ ActiveSupport.on_load(:active_record) do
12
+ if defined?(::CelllineSample)
13
+ ::CelllineSample.class_eval do
14
+ include Labimotion::ElementFetchable
15
+
16
+ def self.element_klass_name
17
+ 'cell_line'
18
+ end
19
+ end
20
+ else
21
+ warn '[Labimotion] CelllineSample is not defined when Labimotion extension was loaded.'
22
+ end
23
+ end
24
+
25
+ # Namespace wrapper to keep your preferred call style
26
+ module Labimotion
27
+ module CellLine
28
+ class << self
29
+ # Delegate class methods to ::CelllineSample.
30
+ # ruby2_keywords is required so keyword arguments survive the *args
31
+ # round-trip on Ruby >= 3.0 (e.g. fetch_for_user(id, name:, limit:)).
32
+ ruby2_keywords def method_missing(method, *args, &block)
33
+ if ::CelllineSample.respond_to?(method)
34
+ ::CelllineSample.public_send(method, *args, &block)
35
+ else
36
+ super
37
+ end
38
+ end
39
+
40
+ def respond_to_missing?(method, include_private = false)
41
+ ::CelllineSample.respond_to?(method, include_private) || super
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ # rubocop:enable Style/RedundantConstantBase
@@ -42,6 +42,9 @@ module Labimotion
42
42
  uuid = SecureRandom.uuid
43
43
  metadata = args[:metadata] || {}
44
44
  props = args[:properties]
45
+ # Datasets do not go through SampleAssociation, so they strip their own
46
+ # default-injected linked attributes before being stored.
47
+ Labimotion::LinkedElement.strip_defaults(props)
45
48
  props['pkg'] = Labimotion::Utils.pkg(props['pkg'])
46
49
  props['identifier'] = klass.identifier if klass.identifier.present?
47
50
  props['uuid'] = uuid
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ ## The LabIMotion side of a host's UI-feature model — whatever this gem needs
5
+ # to read out of the per-feature JSON configs an admin edits.
6
+ #
7
+ # Today that is one thing: which users may reach the AI integration. Named for
8
+ # the seam rather than for that one job, so the next setting we need from a
9
+ # feature's configs lands beside it instead of in a second concern mixed into
10
+ # the same model.
11
+ #
12
+ # The division throughout: which users are allowed is the HOST's decision and
13
+ # the host's data — an admin edits the lists on the UI Features page and they
14
+ # live in the host's own table. What the answer MEANS is LabIMotion's: which
15
+ # generic types have an AI integration, that auto-fill is a sub-gate rather
16
+ # than a separate one, and that every step is closed by default. So the shape
17
+ # lives here and the storage stays there, the same division the rest of this
18
+ # gem keeps with its host.
19
+ #
20
+ # Chemotion holds the configs on its `Matrice` model, which includes this:
21
+ #
22
+ # class Matrice < ApplicationRecord
23
+ # include Labimotion::MatriceLabimotion
24
+ # end
25
+ #
26
+ # A host supplying it needs two things: the including model must answer
27
+ # `find_by(name:)` with a record exposing a `configs` hash, and its user must
28
+ # answer `matrix_check_by_name(name)` with whether that feature is on for them
29
+ # at all. Both already hold for the model this was extracted from.
30
+ #
31
+ # The configs a feature may carry:
32
+ #
33
+ # { "ai_uids": [109, 147], "fill_uids": [109] }
34
+ #
35
+ module MatriceLabimotion
36
+ extend ActiveSupport::Concern
37
+
38
+ # UI features whose JSON configs may carry an AI whitelist. Each one gates the
39
+ # LabIMotion AI integration of its own generic type (the designer's "New (AI)"
40
+ # / "Fine-tune with AI" buttons, and — for genericElement — the AI auto-fill in
41
+ # the element detail view).
42
+ AI_FEATURES = %w[genericElement segment genericDataset].freeze
43
+
44
+ # The feature carrying the auto-fill whitelist. Only this one has a
45
+ # `fill_uids` list: auto-fill is the AI action ordinary users reach, in the
46
+ # element detail view, while the designer's "New (AI)" buttons on the other
47
+ # two types are admin-only and need no second gate.
48
+ FILL_FEATURE = 'genericElement'
49
+
50
+ # rubocop:disable Metrics/BlockLength -- a concern's class_methods block is a
51
+ # namespace for the methods it contributes, not a unit of logic to keep short.
52
+ class_methods do
53
+ # User ids allowed to use the AI integration of a UI feature, read from that
54
+ # feature's JSON configs:
55
+ #
56
+ # { "ai_uids": [109] }
57
+ def ai_uids(name)
58
+ whitelist_uids(name, 'ai_uids')
59
+ end
60
+
61
+ # User ids allowed to run AI auto-fill, read from the same JSON configs:
62
+ #
63
+ # { "ai_uids": [109, 147], "fill_uids": [109] }
64
+ #
65
+ # A sub-gate on ai_uids, not a separate one — auto-fill is the AI action
66
+ # ordinary users reach, so it is narrowed to a subset of the group that sees
67
+ # the integration at all. Only genericElement consults it; the designer's
68
+ # "New (AI)" buttons are admin-only and have no equivalent.
69
+ def fill_uids(name)
70
+ whitelist_uids(name, 'fill_uids')
71
+ end
72
+
73
+ # True when `user` may use the AI integration of `name`. The whitelist is a
74
+ # sub-gate on top of normal feature visibility, so a whitelisted user still
75
+ # sees nothing when the feature itself is off for them.
76
+ def ai_enabled_for?(user, name)
77
+ return false if user.nil?
78
+
79
+ ai_uids(name).include?(user.id) && user.matrix_check_by_name(name)
80
+ end
81
+
82
+ # { 'genericElement' => true, 'segment' => false, ... } for the UI to gate on.
83
+ def ai_features_for(user)
84
+ AI_FEATURES.index_with { |name| ai_enabled_for?(user, name) }
85
+ end
86
+
87
+ # True when `user` is whitelisted for at least one generic type — the gate for
88
+ # shared surfaces such as the "AI" tab in My LabIMotion.
89
+ def ai_enabled_for_any?(user)
90
+ AI_FEATURES.any? { |name| ai_enabled_for?(user, name) }
91
+ end
92
+
93
+ # True when `user` may run AI auto-fill (the wand in the generic element
94
+ # toolbar, POST /generic_elements/ai_fill_data). Takes no feature name
95
+ # because only FILL_FEATURE has such a list.
96
+ #
97
+ # Both gates must pass: `ai_uids` decides who sees the AI integration at
98
+ # all, `fill_uids` narrows that group. It narrows rather than widens, so a
99
+ # user on fill_uids alone gets nothing.
100
+ #
101
+ # The endpoint is defined in this gem and the host reports the same answer
102
+ # to its client, both through this one method, so the button and the
103
+ # endpoint can never disagree about who may use it.
104
+ def fill_enabled_for?(user)
105
+ return false if user.nil?
106
+
107
+ ai_enabled_for?(user, FILL_FEATURE) && fill_uids(FILL_FEATURE).include?(user.id)
108
+ end
109
+
110
+ private
111
+
112
+ # Closed by default: a missing key, a non-array value or an empty list all mean
113
+ # nobody. Non-numeric entries are dropped rather than raising, so a typo in the
114
+ # admin JSON editor narrows the whitelist instead of breaking the page.
115
+ def whitelist_uids(name, key)
116
+ uids = (find_by(name: name)&.configs || {})[key]
117
+ return [] unless uids.is_a?(Array)
118
+
119
+ uids.filter_map { |uid| Integer(uid, exception: false) }
120
+ end
121
+ end
122
+ # rubocop:enable Metrics/BlockLength
123
+ end
124
+ end