labimotion 2.4.0.rc2 → 2.4.0.rc4
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 +9 -0
- data/lib/labimotion/apis/generic_dataset_api.rb +41 -3
- data/lib/labimotion/apis/generic_element_api.rb +60 -8
- data/lib/labimotion/apis/generic_klass_api.rb +50 -6
- data/lib/labimotion/apis/klass_share_api.rb +646 -0
- data/lib/labimotion/apis/labimotion_api.rb +1 -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/segment_api.rb +61 -10
- data/lib/labimotion/constants.rb +14 -0
- data/lib/labimotion/entities/application_entity.rb +13 -1
- data/lib/labimotion/entities/generic_klass_entity.rb +125 -0
- data/lib/labimotion/entities/klass_share_entity.rb +48 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +123 -1
- data/lib/labimotion/helpers/element_helpers.rb +206 -4
- data/lib/labimotion/helpers/generic_helpers.rb +276 -4
- data/lib/labimotion/helpers/param_helpers.rb +68 -0
- data/lib/labimotion/helpers/segment_helpers.rb +87 -4
- data/lib/labimotion/libs/ai_egress_guard.rb +84 -0
- data/lib/labimotion/libs/ai_template.rb +1482 -0
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/libs/owner_resolver.rb +50 -0
- data/lib/labimotion/libs/ownership_audit.rb +73 -0
- data/lib/labimotion/libs/share_notifier.rb +114 -0
- data/lib/labimotion/libs/share_resolver.rb +369 -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 +126 -0
- data/lib/labimotion/models/segment_klass.rb +1 -1
- 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/prop.rb +1 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +11 -0
- metadata +28 -2
|
@@ -0,0 +1,369 @@
|
|
|
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). Six 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. Values are KlassShare::LEVELS (10 viewer / 20 editor /
|
|
15
|
+
# 30 owner) as literals — dereferencing the ActiveRecord model in this class body would
|
|
16
|
+
# force it to load in any process that touches the resolver, and the numbers are already
|
|
17
|
+
# frozen into the one-owner partial index anyway.
|
|
18
|
+
REQUIRED_LEVEL = {
|
|
19
|
+
read: 10, write: 20, release: 30, deactivate: 30, destroy: 30, manage: 30
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# The requester's inbox predicate (§7), verbatim from the design, as one SQL fragment
|
|
23
|
+
# because its three conjuncts are one idea: "a grant somebody else made me, which I have
|
|
24
|
+
# not seen in its current state".
|
|
25
|
+
#
|
|
26
|
+
# permission_level >= 10
|
|
27
|
+
# A level that actually confers access. A level-0 row is the caller's own standing ask,
|
|
28
|
+
# and it belongs to the *owner's* queue, not to theirs.
|
|
29
|
+
#
|
|
30
|
+
# created_by <> shared_with_id
|
|
31
|
+
# Load-bearing, and it looks removable — it is not. Every owner row is a self-grant
|
|
32
|
+
# written by KlassShare.seed_owner! on the create path (and by the seed migration for
|
|
33
|
+
# everything older), so without this conjunct every designer would be told "you were
|
|
34
|
+
# granted owner on…" for every template they ever made. A real grant re-stamps
|
|
35
|
+
# created_by to the granter (KlassShareAPI's POST /shares), and transfer_ownership does
|
|
36
|
+
# the same, so both still announce themselves.
|
|
37
|
+
#
|
|
38
|
+
# acked_at IS NULL OR acked_at < updated_at
|
|
39
|
+
# The whole ack mechanism — see KlassShare's comment on the column. There is no reset
|
|
40
|
+
# logic: a later upgrade or transfer moves updated_at past acked_at and re-announces by
|
|
41
|
+
# itself, and a revocation is a hard delete, so the entry disappears instead.
|
|
42
|
+
#
|
|
43
|
+
# Levels as literals for the reason REQUIRED_LEVEL gives above.
|
|
44
|
+
UNACKNOWLEDGED_GRANTS = 'permission_level >= 10 AND created_by <> shared_with_id ' \
|
|
45
|
+
'AND (acked_at IS NULL OR acked_at < updated_at)'
|
|
46
|
+
|
|
47
|
+
# A refusal carries who to ask — naming the owner is what keeps a 403 from reading as an
|
|
48
|
+
# authorisation failure between equally authorised peers (§3).
|
|
49
|
+
Refusal = Struct.new(:owner_id)
|
|
50
|
+
|
|
51
|
+
# An owner entry in the context: the share row's user id survives even when the user
|
|
52
|
+
# record cannot be resolved, so a departed-and-purged owner still shows as "user #id
|
|
53
|
+
# (unknown)" instead of losing the id the transfer flow needs.
|
|
54
|
+
OwnerRef = Struct.new(:id, :user)
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
def key_for(klass)
|
|
58
|
+
Labimotion::KlassShare.key_of(klass)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# :ok — the action may proceed.
|
|
62
|
+
# :legacy — no owner row exists (or the host has not migrated): fall back to the
|
|
63
|
+
# designer-wide authenticate_admin! gate. Permanent, not transitional — an
|
|
64
|
+
# un-backfilled template degrades to "as before", never to "locked out".
|
|
65
|
+
# Refusal — the template is owned and the user's level is insufficient.
|
|
66
|
+
#
|
|
67
|
+
# Resolution order: administrator (STI type 'Admin' — owner level on everything,
|
|
68
|
+
# implicit, no rows of its own) → the user's own share, a SegmentKlass taking the
|
|
69
|
+
# higher of its own level and its parent ElementKlass's → legacy fallback when no
|
|
70
|
+
# owner row exists. Errors propagate: an owned template must never fail open just
|
|
71
|
+
# because a query failed.
|
|
72
|
+
#
|
|
73
|
+
# A departed owner's row still counts as "has an owner" — ignoring it would drop the
|
|
74
|
+
# klass into the legacy gate, which is exactly the fail-open the design forbids; the
|
|
75
|
+
# row waits for the super owner's transfer instead. The requesting user's own rows
|
|
76
|
+
# need no departed filter: a deleted or deactivated user cannot be logged in.
|
|
77
|
+
def authorize(klass, user, action)
|
|
78
|
+
required = REQUIRED_LEVEL.fetch(action)
|
|
79
|
+
return :legacy unless shares_enabled?
|
|
80
|
+
return :ok if admin?(user)
|
|
81
|
+
|
|
82
|
+
level = level_for(klass, user)
|
|
83
|
+
return :ok if level && level >= required
|
|
84
|
+
|
|
85
|
+
owner_row = Labimotion::KlassShare.owner_row_for(klass)
|
|
86
|
+
return :legacy if owner_row.nil?
|
|
87
|
+
|
|
88
|
+
Refusal.new(owner_row.shared_with_id)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Per-request preload for the grids: nil when the host has not migrated (entities then
|
|
92
|
+
# keep their pre-share behaviour), else the owner and the requesting user's level per
|
|
93
|
+
# klass key, in two share queries plus one user query however long the list is.
|
|
94
|
+
# Departed users stay in the owners map on purpose — the grid badge and the
|
|
95
|
+
# awaiting-transfer list are how anyone finds out.
|
|
96
|
+
def context_for(klasses, user)
|
|
97
|
+
return nil unless shares_enabled?
|
|
98
|
+
|
|
99
|
+
rows = Array(klasses).compact
|
|
100
|
+
shares = shares_for(rows)
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
admin: admin?(user),
|
|
104
|
+
has_owner: owned_keys(shares),
|
|
105
|
+
owners: owners_map(shares),
|
|
106
|
+
levels: levels_map(rows, shares, user),
|
|
107
|
+
# The requesting user's legacy designer rights per klass type: what an owner-less
|
|
108
|
+
# template falls back to. The ungated list endpoints serve non-designers too, so
|
|
109
|
+
# capability booleans must not assume everyone reading the grid holds them.
|
|
110
|
+
legacy: legacy_rights(user),
|
|
111
|
+
pending_requests: pending_requests_map(shares, user)
|
|
112
|
+
}
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
# The grid must render even if share resolution breaks; entities fall back to the
|
|
115
|
+
# pre-share display. Display only — `authorize` above deliberately has no such net.
|
|
116
|
+
Labimotion.log_exception(e)
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The Designer's share poller (§7), over the share rows the requesting user is involved
|
|
121
|
+
# in: the rows anybody holds on the templates they own — every collaborator's, every
|
|
122
|
+
# requester's — plus the rows granting them access on other people's templates.
|
|
123
|
+
#
|
|
124
|
+
# Three values, two questions. `count` and `latest` answer "did anything about my sharing
|
|
125
|
+
# move at all", the cheap early-out a client can use alone. `digests` answers "which
|
|
126
|
+
# templates", one entry per involved klass key, each the same pair taken over that klass's
|
|
127
|
+
# rows alone — so the client re-reads the rows whose digest changed, appeared or vanished
|
|
128
|
+
# instead of reloading the whole grid. That whole-grid reload is what destroyed a
|
|
129
|
+
# Designer's unsaved edits, since re-seeding the list re-seeds the open Work Area.
|
|
130
|
+
#
|
|
131
|
+
# Opaque change tokens, not clocks. The client compares them for inequality in *either*
|
|
132
|
+
# direction, and none of this must ever grow "newer than" semantics: a revocation is a
|
|
133
|
+
# hard delete (KlassShare is deliberately not acts_as_paranoid), so a key's `latest` moves
|
|
134
|
+
# backwards while its `count` drops, or the key disappears from the map outright —
|
|
135
|
+
# precisely the events a timestamp cursor would silently stop detecting.
|
|
136
|
+
#
|
|
137
|
+
# Aggregates, never the rows: this is polled about once a minute per open Designer page,
|
|
138
|
+
# and the owner of a busy template is involved in every row on it. The per-key map is a
|
|
139
|
+
# GROUP BY over the same scope, so it costs the same two round trips the bare pair did —
|
|
140
|
+
# and the pair is then derived from the groups rather than queried again, which also makes
|
|
141
|
+
# `count` the sum of the digests' counts by construction.
|
|
142
|
+
#
|
|
143
|
+
# The administrator is an implicit owner everywhere but holds rows only where they really
|
|
144
|
+
# own something, so their token moves for their own templates alone. Same stance as
|
|
145
|
+
# pending_requests_map: it is the owner's signal, not a global one.
|
|
146
|
+
def activity_for(user)
|
|
147
|
+
return { count: 0, latest: nil, digests: {} } unless shares_enabled?
|
|
148
|
+
|
|
149
|
+
scope = involving(user).group(:klass_type, :klass_id)
|
|
150
|
+
# Two round trips over one scope rather than a hand-written SELECT COUNT(*), MAX(...):
|
|
151
|
+
# a write landing between them yields either a token that differs from the client's (a
|
|
152
|
+
# spurious refresh) or one that matches it and is superseded by the next poll a minute
|
|
153
|
+
# later. Neither loses an event, and the query stays ActiveRecord's.
|
|
154
|
+
counts = scope.count
|
|
155
|
+
latests = scope.maximum(:updated_at)
|
|
156
|
+
{ count: counts.values.sum,
|
|
157
|
+
latest: latests.values.compact.max,
|
|
158
|
+
digests: digests_map(counts, latests) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The Designer's share inbox (§7), and the last hop of the request loop finally landing
|
|
162
|
+
# where somebody is looking: the owner's queue of standing requests, and the requester's
|
|
163
|
+
# queue of grants they have not marked seen.
|
|
164
|
+
#
|
|
165
|
+
# Both are queries over klass_shares. There is no event table and no message log by
|
|
166
|
+
# decision, so nothing can hold ack state that disagrees with the rows themselves —
|
|
167
|
+
# a second store would immediately have its own retention question and its own idea of
|
|
168
|
+
# what is outstanding.
|
|
169
|
+
#
|
|
170
|
+
# `requests` — the owner's queue: the level-0 rows on the templates the caller holds the
|
|
171
|
+
# owner row on. No column gates it and none should. A grant promotes the row off level 0
|
|
172
|
+
# and a reject hard-deletes it, so the queue empties itself; that *is* the ack, and there
|
|
173
|
+
# is deliberately no dismiss-without-acting for a column to record. It is also the same
|
|
174
|
+
# predicate pending_requests_map counts for the grid's ShareBtn badge, over the same
|
|
175
|
+
# rows — so the badge and the queue cannot disagree about what is waiting.
|
|
176
|
+
#
|
|
177
|
+
# `grants` — the requester's queue: see UNACKNOWLEDGED_GRANTS.
|
|
178
|
+
#
|
|
179
|
+
# Rows rather than aggregates, unlike activity_for on the same poll tick, and bounded by
|
|
180
|
+
# what a human can act on rather than by the size of the account: a level-0 row lasts only
|
|
181
|
+
# until somebody answers it, and a grant row only until its recipient marks it seen.
|
|
182
|
+
#
|
|
183
|
+
# At most three queries however large the account — the owned keys, then one per queue,
|
|
184
|
+
# with the requests query skipped outright for a caller who owns nothing. The caller
|
|
185
|
+
# resolves klass labels and user names on top of that, in bulk.
|
|
186
|
+
def inbox_for(user)
|
|
187
|
+
return { requests: [], grants: [] } unless shares_enabled?
|
|
188
|
+
|
|
189
|
+
{ requests: pending_requests_for(user), grants: unacknowledged_grants_for(user) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def admin?(user)
|
|
193
|
+
user.respond_to?(:type) && user.type == 'Admin'
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
# The owned keys grouped by klass_type into one exact `klass_type = ? AND klass_id IN (…)`
|
|
199
|
+
# branch each, exactly as `involving` does it — a user owns at most the three types, so the
|
|
200
|
+
# OR stays short and every branch stays indexed. The level filter lands outside the OR:
|
|
201
|
+
# `(A OR B) AND permission_level = 0`.
|
|
202
|
+
def pending_requests_for(user)
|
|
203
|
+
scope = owned_scope(owned_keys_for(user))
|
|
204
|
+
return [] if scope.nil?
|
|
205
|
+
|
|
206
|
+
scope.where(permission_level: Labimotion::KlassShare::LEVELS[:requested]).to_a
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def owned_scope(keys)
|
|
210
|
+
keys.group_by(&:first).reduce(nil) do |scope, (type, group)|
|
|
211
|
+
branch = Labimotion::KlassShare.where(klass_type: type,
|
|
212
|
+
klass_id: group.map(&:last).uniq)
|
|
213
|
+
scope.nil? ? branch : scope.or(branch)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# One query, on the shared_with_id index.
|
|
218
|
+
def unacknowledged_grants_for(user)
|
|
219
|
+
Labimotion::KlassShare.where(shared_with_id: user.id)
|
|
220
|
+
.where(UNACKNOWLEDGED_GRANTS)
|
|
221
|
+
.to_a
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The grouped aggregates, re-keyed into the `"KlassType:id"` strings KlassShare.key_of
|
|
225
|
+
# produces — the same format the batch row read takes as its `keys[]`, so an entry the
|
|
226
|
+
# client saw move is directly the argument that re-reads it.
|
|
227
|
+
#
|
|
228
|
+
# The values stay a Ruby Integer and Time: turning the pair into its wire string belongs
|
|
229
|
+
# next to where `latest` is formatted, in the API, so the millisecond precision the whole
|
|
230
|
+
# token depends on is decided in exactly one place.
|
|
231
|
+
def digests_map(counts, latests)
|
|
232
|
+
counts.each_with_object({}) do |(pair, count), map|
|
|
233
|
+
map["#{pair.first}:#{pair.last}"] = { count: count, latest: latests[pair] }
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Every share row the user is involved in, as one scope: the rows naming them ORed with
|
|
238
|
+
# the rows on the templates they own. The OR is also what de-duplicates the overlap —
|
|
239
|
+
# their own owner row belongs to both halves — so nothing has to be counted twice and
|
|
240
|
+
# subtracted back.
|
|
241
|
+
#
|
|
242
|
+
# Same tuple-IN problem as rows_matching, solved the other way round. That one queries the
|
|
243
|
+
# cross-product of types and ids and narrows back to the exact keys *in Ruby*, which needs
|
|
244
|
+
# the rows in hand; an aggregate has none. Grouping the owned keys by klass_type instead
|
|
245
|
+
# makes every branch exact in SQL — `klass_type = ? AND klass_id IN (…)` — and a user owns
|
|
246
|
+
# at most the three types, so the OR stays short. Both halves are indexed: shared_with_id
|
|
247
|
+
# has an index of its own, and (klass_type, klass_id) is the leading pair of the unique
|
|
248
|
+
# klass-and-user index.
|
|
249
|
+
def involving(user)
|
|
250
|
+
mine = Labimotion::KlassShare.where(shared_with_id: user.id)
|
|
251
|
+
owned_keys_for(user).group_by(&:first).reduce(mine) do |scope, (type, keys)|
|
|
252
|
+
scope.or(Labimotion::KlassShare.where(klass_type: type,
|
|
253
|
+
klass_id: keys.map(&:last).uniq))
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The (klass_type, klass_id) of every template the user holds the owner row on — the one
|
|
258
|
+
# thing here that is read rather than counted, and unavoidably so: these keys *are* the
|
|
259
|
+
# predicate the aggregate is taken over. Two columns, no model instances, one lookup on
|
|
260
|
+
# the shared_with_id index.
|
|
261
|
+
def owned_keys_for(user)
|
|
262
|
+
Labimotion::KlassShare
|
|
263
|
+
.where(shared_with_id: user.id,
|
|
264
|
+
permission_level: Labimotion::KlassShare::LEVELS[:owner])
|
|
265
|
+
.pluck(:klass_type, :klass_id)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def shares_enabled?
|
|
269
|
+
Labimotion::KlassShare.table_exists?
|
|
270
|
+
rescue StandardError
|
|
271
|
+
false
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# A SegmentKlass inherits from its parent ElementKlass (§4): you cannot own an element
|
|
275
|
+
# template yet be locked out of its own segments. Datasets have no parent.
|
|
276
|
+
def parent_key(klass)
|
|
277
|
+
return nil unless klass.respond_to?(:element_klass_id) && klass.element_klass_id.present?
|
|
278
|
+
|
|
279
|
+
"ElementKlass:#{klass.element_klass_id}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def key_of_row(row)
|
|
283
|
+
"#{row.klass_type}:#{row.klass_id}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def level_for(klass, user)
|
|
287
|
+
keys = [key_for(klass), parent_key(klass)].compact
|
|
288
|
+
rows_matching(keys, Labimotion::KlassShare.where(shared_with_id: user.id))
|
|
289
|
+
.map(&:level).max
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# One query over the cross-product of types and ids, narrowed back to the exact keys in
|
|
293
|
+
# Ruby — an IN-list per column instead of a tuple IN, which ActiveRecord 6.1 cannot
|
|
294
|
+
# express portably.
|
|
295
|
+
def shares_for(klasses)
|
|
296
|
+
keys = (klasses.map { |klz| key_for(klz) } +
|
|
297
|
+
klasses.filter_map { |klz| parent_key(klz) }).uniq
|
|
298
|
+
rows_matching(keys, Labimotion::KlassShare.all)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def rows_matching(keys, scope)
|
|
302
|
+
pairs = keys.uniq.map { |key| key.split(':') }
|
|
303
|
+
return [] if pairs.empty?
|
|
304
|
+
|
|
305
|
+
scope.where(klass_type: pairs.map(&:first).uniq,
|
|
306
|
+
klass_id: pairs.map { |pair| pair.last.to_i }.uniq)
|
|
307
|
+
.to_a
|
|
308
|
+
.select { |row| keys.include?(key_of_row(row)) }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def owner_rows(shares)
|
|
312
|
+
shares.select { |row| row.level == Labimotion::KlassShare::LEVELS[:owner] }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def owned_keys(shares)
|
|
316
|
+
owner_rows(shares).map { |row| key_of_row(row) }.uniq
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def owners_map(shares)
|
|
320
|
+
rows = owner_rows(shares)
|
|
321
|
+
users = Labimotion::OwnerResolver.map_for_ids(rows.map(&:shared_with_id))
|
|
322
|
+
rows.to_h do |row|
|
|
323
|
+
[key_of_row(row), OwnerRef.new(row.shared_with_id, users[row.shared_with_id])]
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Which legacy designer right in `users.generic_admin` answers for an owner-less klass
|
|
328
|
+
# of each type.
|
|
329
|
+
def legacy_rights(user)
|
|
330
|
+
grants = user.respond_to?(:generic_admin) && user.generic_admin.is_a?(Hash) ? user.generic_admin : {}
|
|
331
|
+
Labimotion::Constants::Family::FAMILY_OF.transform_values { |right| grants[right] == true }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# How many people are waiting on the requesting user, per template they own — what
|
|
335
|
+
# colours the Share button in the grid. A derivation over rows already in hand, not a
|
|
336
|
+
# query of its own: `shares` is every share row for the listed klasses, level-0 rows
|
|
337
|
+
# included, so the count is a select and a tally.
|
|
338
|
+
#
|
|
339
|
+
# Scoped to the user's own owner rows, which is the whole design of it. A count over
|
|
340
|
+
# every listed template would ship "somebody asked" to every viewer and editor in the
|
|
341
|
+
# payload — a signal only the person who can grant it can act on. Sparse on purpose:
|
|
342
|
+
# keys with nothing pending are absent and the entity reads a missing key as 0, so a
|
|
343
|
+
# grid of a hundred templates carries entries only for the ones actually waiting.
|
|
344
|
+
#
|
|
345
|
+
# The administrator is an implicit owner everywhere but holds no rows, so they see
|
|
346
|
+
# counts on the templates they really own and none on other people's. Correct: it is
|
|
347
|
+
# the owner's queue, not a global inbox.
|
|
348
|
+
def pending_requests_map(shares, user)
|
|
349
|
+
owned = owner_rows(shares).select { |row| row.shared_with_id == user.id }
|
|
350
|
+
.map { |row| key_of_row(row) }
|
|
351
|
+
return {} if owned.empty?
|
|
352
|
+
|
|
353
|
+
shares.select { |row| row.level == Labimotion::KlassShare::LEVELS[:requested] }
|
|
354
|
+
.map { |row| key_of_row(row) }
|
|
355
|
+
.select { |key| owned.include?(key) }
|
|
356
|
+
.tally
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def levels_map(klasses, shares, user)
|
|
360
|
+
by_key = shares.select { |row| row.shared_with_id == user.id }
|
|
361
|
+
.group_by { |row| key_of_row(row) }
|
|
362
|
+
klasses.to_h do |klz|
|
|
363
|
+
levels = (by_key[key_for(klz)] || []) + (by_key[parent_key(klz)] || [])
|
|
364
|
+
[key_for(klz), levels.map(&:level).max]
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# TemplateDoi concern
|
|
4
|
+
#
|
|
5
|
+
# LabIMotion template DOI logic for the host's Doi model. The host owns the DOI
|
|
6
|
+
# record and the generic DataCite plumbing (build_suffix/align_suffix, the MDS
|
|
7
|
+
# client, the DataCitePublisher concern); this adds only the LabIMotion-specific
|
|
8
|
+
# class methods that the gem's template DOI usecases and APIs call on ::Doi.
|
|
9
|
+
#
|
|
10
|
+
# The host wires it up with a single line:
|
|
11
|
+
# class Doi < ApplicationRecord
|
|
12
|
+
# include Labimotion::TemplateDoi
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Assumes the host `dois` table has a jsonb `metadata` column (the DOI version
|
|
16
|
+
# is stamped there) and that the deterministic suffix survives Doi#align_suffix
|
|
17
|
+
# unchanged (reserve sets inchikey == suffix and version_count: 0 for that).
|
|
18
|
+
module Labimotion
|
|
19
|
+
module TemplateDoi
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
|
|
22
|
+
# Literal namespace shared by every LabIMotion template DOI suffix.
|
|
23
|
+
LABIMOTION_DOI_NAMESPACE = 'labimotion'
|
|
24
|
+
|
|
25
|
+
# ActiveSupport::Concern extends this into the including class, so every
|
|
26
|
+
# method below becomes a class method on the host's Doi.
|
|
27
|
+
module ClassMethods
|
|
28
|
+
# Deterministic DOI suffix for a LabIMotion template klass. The same suffix
|
|
29
|
+
# is used at reserve and at release time, so both share one DOI:
|
|
30
|
+
# element -> labimotion/element/<name>/<identifier>/<doi_version>
|
|
31
|
+
# segment -> labimotion/segment/<element>/<identifier>/<doi_version>
|
|
32
|
+
# dataset -> labimotion/dataset/<ols_term_id>/<identifier>/<doi_version>
|
|
33
|
+
# `<identifier>` is the first UUID group (8 hex chars) of the template's
|
|
34
|
+
# identifier/uuid, falling back to the record id. `<doi_version>` is the DOI
|
|
35
|
+
# version (see .labimotion_version_segment). Callers may pass an explicit
|
|
36
|
+
# template version; otherwise the record's current version is used.
|
|
37
|
+
def build_labimotion_suffix(record, version = nil)
|
|
38
|
+
parts = labimotion_suffix_parts(record)
|
|
39
|
+
raise ArgumentError, "unsupported template: #{record.class}" if parts.nil?
|
|
40
|
+
|
|
41
|
+
segment = labimotion_version_segment(record, version)
|
|
42
|
+
return nil if segment.blank?
|
|
43
|
+
|
|
44
|
+
segments = parts.map { |part| sanitize_doi_part(part) }.reject(&:empty?)
|
|
45
|
+
"#{segments.join('/')}/#{segment}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The DOI version segment for a template version. A DOI is published per major
|
|
49
|
+
# release: the .0 release opens that version (X.0 -> "X") and later minor
|
|
50
|
+
# revisions roll into the NEXT DOI (X.y, y>=1 -> "X+1"). Sub-1.0 and
|
|
51
|
+
# unversioned/blank templates map to the first DOI (v1).
|
|
52
|
+
# 0.x -> "1" 1.0 -> "1" 1.1..2.0 -> "2" 2.1..3.0 -> "3"
|
|
53
|
+
def labimotion_version_segment(record, version = nil)
|
|
54
|
+
raw = (version.presence || record.try(:version)).to_s.strip
|
|
55
|
+
parts = raw.split('.')
|
|
56
|
+
major = parts[0].to_i
|
|
57
|
+
minor = parts[1].to_i
|
|
58
|
+
return '1' if major.zero?
|
|
59
|
+
return major.to_s if minor.zero?
|
|
60
|
+
|
|
61
|
+
(major + 1).to_s
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The DOIs reserved for a LabIMotion template, in reservation order.
|
|
65
|
+
def labimotion_dois(record)
|
|
66
|
+
where(doiable_id: record.id, doiable_type: record.class.name).order(:id).to_a
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The current (most recently reserved) DOI for a template, or nil.
|
|
70
|
+
def labimotion_latest_doi(record)
|
|
71
|
+
labimotion_dois(record).last
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The DOI version (next-major integer) recorded on a reserved template DOI.
|
|
75
|
+
def labimotion_doi_version(doi)
|
|
76
|
+
doi.metadata.to_h.dig('labimotion', 'doi_version')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Metadata stamped on a reserved template DOI so the version it represents
|
|
80
|
+
# survives on the record (used to gate reserving the next version).
|
|
81
|
+
def labimotion_doi_metadata(record, version = nil)
|
|
82
|
+
{ 'labimotion' => { 'doi_version' => labimotion_version_segment(record, version) } }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The publication metadata a released template DOI was published with.
|
|
86
|
+
# Nil while the DOI is still reserved, and for DOIs released before
|
|
87
|
+
# snapshots were kept — callers fall back to the template's current
|
|
88
|
+
# publication in both cases.
|
|
89
|
+
def labimotion_doi_publication(doi)
|
|
90
|
+
doi.metadata.to_h.dig('labimotion', 'publication')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The DOI's metadata with the publication snapshotted into it, keeping
|
|
94
|
+
# the DOI version already stamped there. Taken at release: the template's
|
|
95
|
+
# publication keeps being edited for the next version, and this version
|
|
96
|
+
# must stop tracking those edits.
|
|
97
|
+
def labimotion_metadata_with_publication(doi, publication)
|
|
98
|
+
metadata = doi.metadata.to_h
|
|
99
|
+
# Deep-duped: the snapshot must not alias the template's publication,
|
|
100
|
+
# which goes on being edited for the next version.
|
|
101
|
+
labimotion = (metadata['labimotion'] || {}).merge('publication' => publication.deep_dup)
|
|
102
|
+
metadata.merge('labimotion' => labimotion)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def labimotion_suffix_parts(record)
|
|
106
|
+
# identifier/uuid may both be blank for a draft, which would collapse the
|
|
107
|
+
# scope (colliding across templates). Fall back to the stable record id so
|
|
108
|
+
# every template gets a unique, deterministic suffix. Resolved lazily so an
|
|
109
|
+
# unsupported record (no #id) still falls through to the ArgumentError in the
|
|
110
|
+
# caller. The identifier is shortened to its first UUID group (8 hex chars).
|
|
111
|
+
# A segment's <element> is the name of the ElementKlass it belongs to.
|
|
112
|
+
identifier = lambda do
|
|
113
|
+
raw = record.try(:identifier).presence || record.try(:uuid).presence || record.id
|
|
114
|
+
raw.to_s.split('-').first
|
|
115
|
+
end
|
|
116
|
+
case record
|
|
117
|
+
when ::Labimotion::ElementKlass
|
|
118
|
+
[LABIMOTION_DOI_NAMESPACE, 'element', record.name, identifier.call]
|
|
119
|
+
when ::Labimotion::SegmentKlass
|
|
120
|
+
[LABIMOTION_DOI_NAMESPACE, 'segment', record.element_klass&.name, identifier.call]
|
|
121
|
+
when ::Labimotion::DatasetKlass
|
|
122
|
+
[LABIMOTION_DOI_NAMESPACE, 'dataset', record.ols_term_id, identifier.call]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Keeps a suffix segment DOI-safe: trims, collapses whitespace to hyphens and
|
|
127
|
+
# drops anything outside [A-Za-z0-9._-] (colons stripped, as elsewhere here).
|
|
128
|
+
def sanitize_doi_part(value)
|
|
129
|
+
value.to_s.strip.gsub(/\s+/, '-').gsub(/[^A-Za-z0-9._-]/, '')
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -18,7 +18,7 @@ module Labimotion
|
|
|
18
18
|
# Scope for displayed_in_list - select only necessary columns for list view
|
|
19
19
|
scope :for_list_display, lambda {
|
|
20
20
|
select(:id, :uuid, :label, :desc, :is_active, :version, :place, :released_at,
|
|
21
|
-
:identifier, :sync_time, :created_at, :updated_at, :ols_term_id)
|
|
21
|
+
:identifier, :sync_time, :created_at, :updated_at, :ols_term_id, :created_by)
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
def self.init_seeds
|
|
@@ -24,7 +24,7 @@ module Labimotion
|
|
|
24
24
|
scope :for_list_display, lambda {
|
|
25
25
|
select(:id, :uuid, :label, :desc, :is_active, :version, :place, :released_at,
|
|
26
26
|
:identifier, :sync_time, :created_at, :updated_at, :name, :icon_name,
|
|
27
|
-
:klass_prefix, :is_generic)
|
|
27
|
+
:klass_prefix, :is_generic, :created_by)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
def self.gen_klasses_json
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Labimotion
|
|
4
|
+
# One user's right on one template (template-sharing.md §5). The owner row
|
|
5
|
+
# (permission_level 30) is the transferable right; the klass's `created_by` column stays
|
|
6
|
+
# immutable provenance. Deliberately NOT acts_as_paranoid, and the klass models carry no
|
|
7
|
+
# `dependent:` towards it: deleting a template must leave its shares alone, so a restored
|
|
8
|
+
# template comes back owned. Wiping the rows on delete would make restore itself a
|
|
9
|
+
# privilege escalation — the template would return owner-less and fall open to the legacy
|
|
10
|
+
# designer-wide gate.
|
|
11
|
+
class KlassShare < ApplicationRecord
|
|
12
|
+
self.table_name = :klass_shares
|
|
13
|
+
|
|
14
|
+
# `acked_at` — when the user this row *names* last acknowledged the row's current state.
|
|
15
|
+
# Written only by that user (POST /generic_klass/shares/:share_id/ack), never by the owner
|
|
16
|
+
# and never by the system: it says "seen", not "happened".
|
|
17
|
+
#
|
|
18
|
+
# Unacknowledged is `acked_at IS NULL OR acked_at < updated_at`, and that comparison is the
|
|
19
|
+
# whole mechanism (ShareResolver::UNACKNOWLEDGED_GRANTS). Nothing resets the column: any
|
|
20
|
+
# later change to the row — an upgrade to editor, an ownership transfer — moves `updated_at`
|
|
21
|
+
# past `acked_at` and re-announces itself, and a revocation is a hard delete, so the entry
|
|
22
|
+
# disappears rather than needing to be un-acked. Which is also why the *owner's* queue needs
|
|
23
|
+
# no column at all: a grant promotes the level-0 row off level 0 and a reject deletes it
|
|
24
|
+
# outright, so that queue empties itself.
|
|
25
|
+
#
|
|
26
|
+
# The corollary, and the one way to break it: the ack write must not touch `updated_at`, or
|
|
27
|
+
# the row re-announces itself for ever. The ack route writes the column directly for that
|
|
28
|
+
# reason — never through save/update.
|
|
29
|
+
#
|
|
30
|
+
# Deliberately independent of ELN's own `NoticeButton` read marker: that acknowledges a host
|
|
31
|
+
# message in the main ELN, this acknowledges a share row in the Designer, and the two answer
|
|
32
|
+
# different questions in different places.
|
|
33
|
+
|
|
34
|
+
# Strictly nested ladder (requested ⊂ viewer ⊂ editor ⊂ owner), which is what makes `>=`
|
|
35
|
+
# threshold checks valid. Spaced by ten so a future level is additive; never renumber —
|
|
36
|
+
# the one-owner-per-klass partial index freezes `permission_level = 30` into the schema.
|
|
37
|
+
#
|
|
38
|
+
# `requested` is the odd one: it grants nothing. It is how an access request is stored —
|
|
39
|
+
# a row rather than a message, because the last hop of the request loop lands on the
|
|
40
|
+
# Designer, which polls no message list, so a message nobody sees is no notification at
|
|
41
|
+
# all. As a row it survives acknowledgement, granting and rejecting become ordinary
|
|
42
|
+
# state changes on it, and the unique (klass_type, klass_id, shared_with_id) index
|
|
43
|
+
# dedupes repeat asks for free. `0 >= 10` is false, so every threshold check refuses it
|
|
44
|
+
# without a special case.
|
|
45
|
+
LEVELS = { requested: 0, viewer: 10, editor: 20, owner: 30 }.freeze
|
|
46
|
+
|
|
47
|
+
# Which account types may hold a share row. `Admin` is in the list because both seeding
|
|
48
|
+
# paths have to reach the same verdict: the seed migration is raw SQL over `created_by`
|
|
49
|
+
# and does write an owner row naming an administrator, while this validation used to
|
|
50
|
+
# refuse the runtime self-grant for the same actor and leave the template owner-less —
|
|
51
|
+
# the worse of the two, since owner-less falls back to the permanent legacy gate where
|
|
52
|
+
# any designer of the family may edit and delete it. Not hypothetical: the
|
|
53
|
+
# `backfill_klass_created_columns` migration fills a NULL `created_by` with the first
|
|
54
|
+
# system administrator, so a real population of templates is admin-created.
|
|
55
|
+
# `Group` and `DeviceDeprecated` stay out: a group is not an individual, and ELN reserves
|
|
56
|
+
# `Group` for device management.
|
|
57
|
+
SHAREABLE_USER_TYPES = %w[Person Admin].freeze
|
|
58
|
+
|
|
59
|
+
# Hash form, NOT the Rails 7 positional form: the gemspec admits ActiveRecord 6.1, where
|
|
60
|
+
# the positional form raises ArgumentError in the class body and the host fails to boot.
|
|
61
|
+
enum permission_level: LEVELS # rubocop:disable Rails/EnumSyntax
|
|
62
|
+
|
|
63
|
+
validates :klass_type, inclusion: { in: Labimotion::Constants::Klass::ALL }
|
|
64
|
+
validate :shared_with_individual
|
|
65
|
+
|
|
66
|
+
# The integer, whatever the reader: the AR enum getter answers the label string, a bare
|
|
67
|
+
# test double answers the raw integer.
|
|
68
|
+
def level
|
|
69
|
+
raw = permission_level
|
|
70
|
+
raw.is_a?(Integer) ? raw : LEVELS.fetch(raw.to_sym)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
scope :for_klass, lambda { |klass_type, klass_id|
|
|
74
|
+
where(klass_type: klass_type, klass_id: klass_id)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def self.key_of(klass)
|
|
78
|
+
"#{klass.class.name.split('::').last}:#{klass.id}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.owner_row_for(klass)
|
|
82
|
+
for_klass(klass.class.name.split('::').last, klass.id)
|
|
83
|
+
.find_by(permission_level: LEVELS[:owner])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Writes the owner self-grant a fresh klass gets on every create path — bookkeeping, not
|
|
87
|
+
# enforcement, so it is unconditional but must never break the create it rides on:
|
|
88
|
+
# a host that has not migrated yet simply skips it (log only), and a concurrent duplicate
|
|
89
|
+
# means the owner row already exists, which is the goal state.
|
|
90
|
+
def self.seed_owner!(klass, user_id)
|
|
91
|
+
return unless table_exists?
|
|
92
|
+
return if owner_row_for(klass).present?
|
|
93
|
+
|
|
94
|
+
create!(klass_type: klass.class.name.split('::').last, klass_id: klass.id,
|
|
95
|
+
shared_with_id: user_id, created_by: user_id,
|
|
96
|
+
permission_level: LEVELS[:owner])
|
|
97
|
+
rescue ActiveRecord::RecordNotUnique
|
|
98
|
+
nil
|
|
99
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
100
|
+
# An Admin creator no longer lands here — SHAREABLE_USER_TYPES admits it, which is the
|
|
101
|
+
# point of the allowlist. What still can: a `created_by` naming an excluded type or an
|
|
102
|
+
# account that no longer exists at all. The klass then stays owner-less on the legacy
|
|
103
|
+
# gate, logged, rather than taking the create down with it.
|
|
104
|
+
Labimotion.log_exception(e)
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# A share targets an individual account (SHAREABLE_USER_TYPES), never a collective one.
|
|
111
|
+
#
|
|
112
|
+
# `User.persons` survives only as the probe that this host's User *is* the ELN's STI
|
|
113
|
+
# model: it is host-owned, and its absence (an older host, a bare test double) must not
|
|
114
|
+
# turn into "reject every write". It can no longer be the query — `persons` is
|
|
115
|
+
# `where(type: 'Person')`, which is exactly the row the allowlist now has to let through.
|
|
116
|
+
# So: `respond_to?` asks whether the type column is there to be trusted, `exists?` asks
|
|
117
|
+
# the allowlist question directly, the same shape ELN's own `request_access` uses.
|
|
118
|
+
def shared_with_individual
|
|
119
|
+
return if shared_with_id.blank?
|
|
120
|
+
return unless ::User.respond_to?(:persons)
|
|
121
|
+
return if ::User.exists?(type: SHAREABLE_USER_TYPES, id: shared_with_id)
|
|
122
|
+
|
|
123
|
+
errors.add(:shared_with_id, 'must be an individual user account')
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -22,7 +22,7 @@ module Labimotion
|
|
|
22
22
|
# Scope for displayed_in_list - select only necessary columns for list view
|
|
23
23
|
scope :for_list_display, lambda {
|
|
24
24
|
select(:id, :uuid, :label, :desc, :is_active, :version, :place, :released_at,
|
|
25
|
-
:identifier, :sync_time, :created_at, :updated_at, :element_klass_id)
|
|
25
|
+
:identifier, :sync_time, :created_at, :updated_at, :element_klass_id, :created_by)
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
def self.gen_klasses_json
|