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
|
@@ -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
|