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,648 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time' # Time#iso8601, for the share_activity token below
|
|
4
|
+
require 'labimotion/version'
|
|
5
|
+
|
|
6
|
+
module Labimotion
|
|
7
|
+
# Share management for generic templates (template-sharing.md §7). Its own API class, not
|
|
8
|
+
# more namespaces in GenericKlassAPI, so the spec harness can load and mount it alone.
|
|
9
|
+
#
|
|
10
|
+
# Grant and level-change accept viewer/editor/maintainer only: the owner level never moves through
|
|
11
|
+
# these routes. It moves through transfer_ownership, an update of the single owner row, so
|
|
12
|
+
# the one-owner-per-klass invariant holds at every instant and the handover is auditable.
|
|
13
|
+
#
|
|
14
|
+
# request_access is the one route a non-holder may call, and it writes the one level the
|
|
15
|
+
# others refuse: 0, which grants nothing and only records the ask.
|
|
16
|
+
#
|
|
17
|
+
# Metrics/ClassLength is disabled for the same reason Metrics/BlockLength is on the helpers
|
|
18
|
+
# below: almost none of this is logic. It is Grape route DSL — a `desc`, a `params` block and
|
|
19
|
+
# a body per endpoint — and the cop counts every declaration and rescue clause as a class
|
|
20
|
+
# line. Splitting the routes across classes to satisfy it would scatter one coherent surface
|
|
21
|
+
# (`/generic_klass/shares…`) over several files.
|
|
22
|
+
class KlassShareAPI < Grape::API # rubocop:disable Metrics/ClassLength
|
|
23
|
+
helpers Labimotion::GenericHelpers
|
|
24
|
+
|
|
25
|
+
# How many rows one batch read may ask for. The poller is the caller, so the bound is about
|
|
26
|
+
# the payload rather than the client's patience: fifty klass entities carry fifty
|
|
27
|
+
# properties_templates, which is already a large response, and a Designer whose diff names
|
|
28
|
+
# more keys than that has effectively had its whole grid change — it can fall back to the
|
|
29
|
+
# list endpoint it was trying to avoid. Refused rather than truncated: a silently short
|
|
30
|
+
# answer would leave the client believing the missing keys were deleted.
|
|
31
|
+
MAX_ROW_KEYS = 50
|
|
32
|
+
|
|
33
|
+
# rubocop:disable Metrics/BlockLength
|
|
34
|
+
helpers do
|
|
35
|
+
def find_share!(share_id)
|
|
36
|
+
row = Labimotion::KlassShare.find_by(id: share_id)
|
|
37
|
+
error!('Share is invalid. Please refresh.', 404) if row.nil?
|
|
38
|
+
row
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def share_klass!(row)
|
|
42
|
+
fetch_klass(row.klass_type, row.klass_id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The owner row is undeletable and undowngradeable by construction: removing it leaves
|
|
46
|
+
# the klass owner-less, which falls open to the legacy designer-wide gate. 409 pointing
|
|
47
|
+
# at transfer_ownership, mirroring Docs — an owner transfers before stepping off.
|
|
48
|
+
def guard_owner_row!(row)
|
|
49
|
+
return unless row.level == Labimotion::KlassShare::LEVELS[:owner]
|
|
50
|
+
|
|
51
|
+
error!({ mc: Labimotion::GenericHelpers::OWNER_ROW_MC,
|
|
52
|
+
msg: 'The owner cannot be removed or downgraded. ' \
|
|
53
|
+
'Transfer ownership first (transfer_ownership).' }, 409)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Person-only, deliberately narrower than the model's allowlist
|
|
57
|
+
# (KlassShare::SHAREABLE_USER_TYPES, which also admits Admin). The model has to accept
|
|
58
|
+
# the admin owner rows the seed paths write; nothing should mint a *new* one here. A
|
|
59
|
+
# grant comes from the Share modal's Person picker, and transfer_ownership is the route
|
|
60
|
+
# that moves a template *off* an admin — it checks the target, never the current owner,
|
|
61
|
+
# so an admin-owned template still transfers away under this rule.
|
|
62
|
+
#
|
|
63
|
+
# Validated here as well as on the model, so the refusal is a clean 404 naming the id
|
|
64
|
+
# rather than a validation error.
|
|
65
|
+
def person!(user_id)
|
|
66
|
+
scope = ::User.respond_to?(:persons) ? ::User.persons : ::User
|
|
67
|
+
person = scope.find_by(id: user_id)
|
|
68
|
+
error!("User #{user_id} is not a valid share target.", 404) if person.nil?
|
|
69
|
+
person
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A caller who already holds viewer/editor/owner has nothing to request. 409 rather
|
|
73
|
+
# than a silent success: the only way to get here is a grid that has not seen the grant
|
|
74
|
+
# yet, and the client needs to know to refresh rather than to show "asked".
|
|
75
|
+
def guard_access_held!(row)
|
|
76
|
+
return if row.level < Labimotion::KlassShare::LEVELS[:viewer]
|
|
77
|
+
|
|
78
|
+
error!({ mc: Labimotion::GenericHelpers::ACCESS_ALREADY_HELD_MC,
|
|
79
|
+
msg: 'You already have access to this template. Reload the list.' }, 409)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Two lists, not one. A level-0 row is somebody *waiting* for access, and listing them
|
|
83
|
+
# under "People with access" says the opposite of what they are. `shares` keeps its
|
|
84
|
+
# contents and owner-first ordering for everything that confers access, the owner row
|
|
85
|
+
# included — the modal filters that one out itself.
|
|
86
|
+
#
|
|
87
|
+
# Both lists go to anyone who may `:read` the template, so viewers and editors see
|
|
88
|
+
# other people's pending requests. Intended: the request is a fact about the template,
|
|
89
|
+
# and hiding it would need a second authorization pass over a list this small.
|
|
90
|
+
def represent_shares(klz)
|
|
91
|
+
rows = Labimotion::KlassShare.for_klass(klz.class.name.split('::').last, klz.id).to_a
|
|
92
|
+
users = Labimotion::OwnerResolver.map_for_ids(rows.map(&:shared_with_id))
|
|
93
|
+
owner_row = rows.find { |row| row.level == Labimotion::KlassShare::LEVELS[:owner] }
|
|
94
|
+
granted, requested = rows.partition do |row|
|
|
95
|
+
row.level >= Labimotion::KlassShare::LEVELS[:viewer]
|
|
96
|
+
end
|
|
97
|
+
{
|
|
98
|
+
owner: owner_row && Labimotion::KlassShareEntity.represent(owner_row, users: users),
|
|
99
|
+
shares: Labimotion::KlassShareEntity.represent(
|
|
100
|
+
granted.sort_by { |row| -row.level }, users: users
|
|
101
|
+
),
|
|
102
|
+
# Oldest ask first: the modal is a queue, and whoever waited longest reads first.
|
|
103
|
+
requests: Labimotion::KlassShareEntity.represent(
|
|
104
|
+
requested.sort_by(&:id), users: users
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The two inbox queues in wire form. Two shapes rather than one entity, because the two
|
|
110
|
+
# queues answer different questions: a request names the person *waiting* (shared_with_id)
|
|
111
|
+
# and when they asked, a grant names the person who *granted* it (created_by), what they
|
|
112
|
+
# granted, and when it last moved. One shared shape would have meant a `user` field whose
|
|
113
|
+
# meaning flipped with the list it happened to be in.
|
|
114
|
+
#
|
|
115
|
+
# Both bulk resolutions happen here, once over both queues together: the klass labels
|
|
116
|
+
# (grouped by type) and the user names (one query), never a lookup per row — this route
|
|
117
|
+
# sits on the same once-a-minute poll tick as share_activity.
|
|
118
|
+
def represent_inbox(inbox)
|
|
119
|
+
labels = klass_labels(inbox[:requests] + inbox[:grants])
|
|
120
|
+
users = Labimotion::OwnerResolver.map_for_ids(
|
|
121
|
+
inbox[:requests].map(&:shared_with_id) + inbox[:grants].map(&:created_by)
|
|
122
|
+
)
|
|
123
|
+
{ requests: request_entries(inbox[:requests], labels, users),
|
|
124
|
+
grants: grant_entries(inbox[:grants], labels, users) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Oldest ask first: the owner's queue is a queue, and whoever waited longest reads first —
|
|
128
|
+
# the same ordering the Share modal's `requests` list uses.
|
|
129
|
+
def request_entries(rows, labels, users)
|
|
130
|
+
inbox_entries(rows.sort_by(&:id), labels) do |row, entry|
|
|
131
|
+
entry.merge(user: inbox_user(row.shared_with_id, users),
|
|
132
|
+
requested_at: row.created_at&.iso8601)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Newest change first: this half is an announcement list, so the freshest news reads first.
|
|
137
|
+
# `changed_at` is `updated_at`, not `created_at`, because updated_at is what `acked_at` is
|
|
138
|
+
# compared against — the entry describes the row's *current* state, which an upgrade or a
|
|
139
|
+
# transfer moves long after the row was first written. Ties broken by id so the order is
|
|
140
|
+
# deterministic when a batch grant stamps several rows in the same instant.
|
|
141
|
+
def grant_entries(rows, labels, users)
|
|
142
|
+
ordered = rows.sort_by { |row| [row.updated_at, row.id] }.reverse
|
|
143
|
+
inbox_entries(ordered, labels) do |row, entry|
|
|
144
|
+
entry.merge(permission: level_label(row), user: inbox_user(row.created_by, users),
|
|
145
|
+
changed_at: row.updated_at&.iso8601)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A row whose template cannot be resolved is dropped rather than sent with a null label:
|
|
150
|
+
# every entry exists to be clicked through to a grid row, and a deleted template has none
|
|
151
|
+
# (acts_as_paranoid puts a soft-deleted one outside the default scope by itself). Nothing
|
|
152
|
+
# is lost by dropping it — the share row survives the delete, since KlassShare carries no
|
|
153
|
+
# `dependent:` so a restored template comes back owned, and neither created_at nor
|
|
154
|
+
# updated_at moved, so the entry reappears exactly as it was.
|
|
155
|
+
def inbox_entries(rows, labels)
|
|
156
|
+
rows.filter_map do |row|
|
|
157
|
+
key = "#{row.klass_type}:#{row.klass_id}"
|
|
158
|
+
next if labels[key].nil?
|
|
159
|
+
|
|
160
|
+
yield(row, { share_id: row.id, klass_type: row.klass_type, klass_id: row.klass_id,
|
|
161
|
+
klass_label: labels[key] })
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# One query per klass type present in the two queues, never one per row. `pluck` rather
|
|
166
|
+
# than loading records: an ElementKlass carries its whole properties_template, and all the
|
|
167
|
+
# inbox wants is a label.
|
|
168
|
+
def klass_labels(rows)
|
|
169
|
+
rows.group_by(&:klass_type).each_with_object({}) do |(type, group), map|
|
|
170
|
+
"Labimotion::#{type}".constantize
|
|
171
|
+
.where(id: group.map(&:klass_id).uniq)
|
|
172
|
+
.pluck(:id, :label)
|
|
173
|
+
.each { |id, label| map["#{type}:#{id}"] = label }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# No `state` field, unlike KlassShareEntity's user block: that one renders a live
|
|
178
|
+
# collaborator list, where a departed account has to grey out. The inbox names the other
|
|
179
|
+
# party of something that already happened, and their leaving since does not change it.
|
|
180
|
+
def inbox_user(user_id, users)
|
|
181
|
+
resolved = users[user_id]
|
|
182
|
+
{ id: user_id, name: resolved&.name, name_abbreviation: resolved&.name_abbreviation }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def level_label(row)
|
|
186
|
+
value = row.permission_level
|
|
187
|
+
value.is_a?(Integer) ? Labimotion::KlassShare::LEVELS.key(value)&.to_s : value.to_s
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Wire form of one key's change token: `"<row count>|<newest updated_at>"`, in the same
|
|
191
|
+
# millisecond precision `latest` uses, because two changes inside one second have to be
|
|
192
|
+
# two different digests. Opaque to the client, which compares the whole string for
|
|
193
|
+
# inequality and never parses it — so the separator and the field order are free to change
|
|
194
|
+
# as long as they change for every key at once.
|
|
195
|
+
def share_digests(digests)
|
|
196
|
+
(digests || {}).each_with_object({}) do |(key, digest), map|
|
|
197
|
+
map[key] = "#{digest[:count]}|#{digest[:latest]&.iso8601(3)}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# `"KlassType:id"` — the format KlassShare.key_of produces and share_activity's digest map
|
|
202
|
+
# is keyed by, so a key the client saw move is directly the argument that re-reads it.
|
|
203
|
+
# Malformed keys are refused rather than guessed at or dropped: a key nobody meant is a
|
|
204
|
+
# client bug, and skipping it silently would surface much later as a grid row that never
|
|
205
|
+
# refreshes. The cap is checked on what was *asked for*, before de-duplication, so a poll
|
|
206
|
+
# cannot buy itself a bigger payload by repeating keys.
|
|
207
|
+
def parse_row_keys!(keys)
|
|
208
|
+
if keys.size > Labimotion::KlassShareAPI::MAX_ROW_KEYS
|
|
209
|
+
error!({ mc: 'se00',
|
|
210
|
+
msg: "At most #{Labimotion::KlassShareAPI::MAX_ROW_KEYS} keys per request." }, 400)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
keys.uniq.map do |key|
|
|
214
|
+
type, id = key.to_s.split(':', 2)
|
|
215
|
+
unless Labimotion::Constants::Klass::ALL.include?(type) && id.to_s.match?(/\A[1-9]\d*\z/)
|
|
216
|
+
error!({ mc: 'se00', msg: "Malformed key: #{key}" }, 400)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
[type, id.to_i]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# One query per klass type, never one per key — the diff the poller hands over can name
|
|
224
|
+
# all three types and fifty ids, and this route sits on the same once-a-minute path as
|
|
225
|
+
# share_activity.
|
|
226
|
+
#
|
|
227
|
+
# A key whose record is gone is skipped rather than raised on: a template deleted between
|
|
228
|
+
# the poll and the read is an ordinary race (and acts_as_paranoid puts a soft-deleted one
|
|
229
|
+
# outside the default scope by itself), and the client's own row goes away on its next
|
|
230
|
+
# natural refresh.
|
|
231
|
+
def batch_rows(pairs)
|
|
232
|
+
pairs.group_by(&:first).each_with_object({ keys: [], data: [] }) do |(type, group), acc|
|
|
233
|
+
records = row_records(type, group.map(&:last))
|
|
234
|
+
next if records.empty?
|
|
235
|
+
|
|
236
|
+
acc[:keys].concat(records.map { |record| "#{type}:#{record.id}" })
|
|
237
|
+
acc[:data].concat(Array(represent_rows(type, records)))
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def row_records(type, ids)
|
|
242
|
+
scope = "Labimotion::#{type}".constantize.where(id: ids)
|
|
243
|
+
# SegmentKlassEntity nests ElementKlassEntity, so the parent is read either way; ask for
|
|
244
|
+
# it in this query rather than one per row, exactly as list_segment_klass does.
|
|
245
|
+
scope = scope.preload(:element_klass) if type == Labimotion::Constants::Klass::SEGMENT
|
|
246
|
+
scope.to_a
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The options list_element_klass / list_segment_klass / list_dataset_klass pass, and no
|
|
250
|
+
# others: "same shape as the list" *is* the contract here. The client drops one of these
|
|
251
|
+
# into a grid row, and `owner`, `can_write` … `can_manage`, `current_user_permission` and
|
|
252
|
+
# `pending_request_count` all have to keep meaning what they meant in the list — the
|
|
253
|
+
# library reads an absent capability field as permissive, so a row missing them would come
|
|
254
|
+
# back looking editable to a viewer.
|
|
255
|
+
#
|
|
256
|
+
# `displayed_in_list: false` is what the three Designer grids get. Note the direction:
|
|
257
|
+
# DISPLAYED_IN_LIST_CONDITION is `unless:`, so false is the *full* payload,
|
|
258
|
+
# properties_template included — a row refresh that dropped the template body would be no
|
|
259
|
+
# use to the Work Area this route exists to keep alive.
|
|
260
|
+
#
|
|
261
|
+
# For segments the parent element klasses join the owners map and the share context, the
|
|
262
|
+
# same way list_segment_klass does it: Grape hands serialization options down into nested
|
|
263
|
+
# entities, so the nested ElementKlassEntity resolves an owner too, and without them every
|
|
264
|
+
# nested row falls back to a user query of its own.
|
|
265
|
+
def represent_rows(type, records)
|
|
266
|
+
related = records
|
|
267
|
+
related += records.map(&:element_klass) if type == Labimotion::Constants::Klass::SEGMENT
|
|
268
|
+
related = related.compact
|
|
269
|
+
"Labimotion::#{type}Entity".constantize.represent(
|
|
270
|
+
records,
|
|
271
|
+
displayed_in_list: false, with_ownership: true,
|
|
272
|
+
owners: Labimotion::OwnerResolver.map_for(related),
|
|
273
|
+
share_context: Labimotion::ShareResolver.context_for(related, current_user)
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
# rubocop:enable Metrics/BlockLength
|
|
278
|
+
|
|
279
|
+
resource :generic_klass do
|
|
280
|
+
namespace :shares do
|
|
281
|
+
desc 'list the shares of one template'
|
|
282
|
+
params do
|
|
283
|
+
requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
|
|
284
|
+
requires :id, type: Integer, desc: 'Klass ID'
|
|
285
|
+
end
|
|
286
|
+
get do
|
|
287
|
+
klz = fetch_klass(params[:klass], params[:id])
|
|
288
|
+
authorize_klass!(klz, :read)
|
|
289
|
+
{ mc: 'ss00' }.merge(represent_shares(klz))
|
|
290
|
+
rescue StandardError => e
|
|
291
|
+
Labimotion.log_exception(e, current_user)
|
|
292
|
+
{ mc: 'se00', msg: e.message }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
desc 'grant viewer/editor/maintainer access to users (upsert)'
|
|
296
|
+
params do
|
|
297
|
+
requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
|
|
298
|
+
requires :id, type: Integer, desc: 'Klass ID'
|
|
299
|
+
# Grape's array-of-type coercion syntax, not a redundant constructor.
|
|
300
|
+
# rubocop:disable Style/RedundantArrayConstructor
|
|
301
|
+
requires :user_ids, type: Array[Integer], desc: 'users to share with (Persons only)'
|
|
302
|
+
# rubocop:enable Style/RedundantArrayConstructor
|
|
303
|
+
# Literal 10/20/25 (viewer/editor/maintainer), not KlassShare::LEVELS: params blocks
|
|
304
|
+
# run while the routes are built, and referencing the ActiveRecord model there would
|
|
305
|
+
# force it to load in any process that only mounts the API. Owner (30) is absent on
|
|
306
|
+
# purpose.
|
|
307
|
+
requires :permission_level, type: Integer, desc: 'viewer, editor or maintainer', values: [10, 20, 25]
|
|
308
|
+
end
|
|
309
|
+
post do
|
|
310
|
+
klz = fetch_klass(params[:klass], params[:id])
|
|
311
|
+
authorize_klass!(klz, :manage)
|
|
312
|
+
# Resolve and guard every target before writing anything: one bad id in the batch
|
|
313
|
+
# must refuse the whole grant, not leave the first half written.
|
|
314
|
+
rows = params[:user_ids].uniq.map do |user_id|
|
|
315
|
+
person!(user_id)
|
|
316
|
+
row = Labimotion::KlassShare.find_or_initialize_by(
|
|
317
|
+
klass_type: params[:klass], klass_id: klz.id, shared_with_id: user_id
|
|
318
|
+
)
|
|
319
|
+
# Re-granting over the owner's own row must not silently demote the owner.
|
|
320
|
+
guard_owner_row!(row) unless row.new_record?
|
|
321
|
+
row
|
|
322
|
+
end
|
|
323
|
+
Labimotion::KlassShare.transaction do
|
|
324
|
+
rows.each do |row|
|
|
325
|
+
row.created_by = current_user.id
|
|
326
|
+
row.permission_level = params[:permission_level]
|
|
327
|
+
row.save!
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
# After the commit, and fire-and-forget: ShareNotifier rescues
|
|
331
|
+
# internally, so an undeliverable toast can never turn a written
|
|
332
|
+
# grant into an se00 (labimotion#255 — moved in from the host).
|
|
333
|
+
Labimotion::ShareNotifier.grant(
|
|
334
|
+
klass: klz, actor: current_user, user_ids: params[:user_ids].uniq,
|
|
335
|
+
level: { 10 => 'viewer', 20 => 'editor', 25 => 'maintainer' }.fetch(params[:permission_level])
|
|
336
|
+
)
|
|
337
|
+
{ mc: 'ss00' }.merge(represent_shares(klz))
|
|
338
|
+
rescue StandardError => e
|
|
339
|
+
Labimotion.log_exception(e, current_user)
|
|
340
|
+
{ mc: 'se00', msg: e.message }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
desc 'change the level of one share (viewer/editor/maintainer only)'
|
|
344
|
+
params do
|
|
345
|
+
requires :share_id, type: Integer, desc: 'Share ID'
|
|
346
|
+
# Literal 10/20/25 (viewer/editor/maintainer), not KlassShare::LEVELS: params blocks
|
|
347
|
+
# run while the routes are built, and referencing the ActiveRecord model there would
|
|
348
|
+
# force it to load in any process that only mounts the API. Owner (30) is absent on
|
|
349
|
+
# purpose.
|
|
350
|
+
requires :permission_level, type: Integer, desc: 'viewer, editor or maintainer', values: [10, 20, 25]
|
|
351
|
+
end
|
|
352
|
+
put ':share_id' do
|
|
353
|
+
row = find_share!(params[:share_id])
|
|
354
|
+
klz = share_klass!(row)
|
|
355
|
+
authorize_klass!(klz, :manage)
|
|
356
|
+
guard_owner_row!(row)
|
|
357
|
+
row.update!(permission_level: params[:permission_level], created_by: current_user.id)
|
|
358
|
+
# No notification here, deliberately: the Share modal's level changes
|
|
359
|
+
# ride the POST upsert above (which announces them), and this route
|
|
360
|
+
# has no library caller — matching the host layer it replaced, which
|
|
361
|
+
# never notified for it either.
|
|
362
|
+
{ mc: 'ss00' }.merge(represent_shares(klz))
|
|
363
|
+
rescue StandardError => e
|
|
364
|
+
Labimotion.log_exception(e, current_user)
|
|
365
|
+
{ mc: 'se00', msg: e.message }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
desc 'revoke one share'
|
|
369
|
+
params do
|
|
370
|
+
requires :share_id, type: Integer, desc: 'Share ID'
|
|
371
|
+
end
|
|
372
|
+
delete ':share_id' do
|
|
373
|
+
row = find_share!(params[:share_id])
|
|
374
|
+
klz = share_klass!(row)
|
|
375
|
+
authorize_klass!(klz, :manage)
|
|
376
|
+
guard_owner_row!(row)
|
|
377
|
+
# Revoking on an ElementKlass leaves explicit SegmentKlass shares standing —
|
|
378
|
+
# inheritance is highest-wins, not a substitute — and revocations do not notify.
|
|
379
|
+
row.destroy!
|
|
380
|
+
{ mc: 'ss00' }.merge(represent_shares(klz))
|
|
381
|
+
rescue StandardError => e
|
|
382
|
+
Labimotion.log_exception(e, current_user)
|
|
383
|
+
{ mc: 'se00', msg: e.message }
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# The requester's half of the inbox, and the only write in this class that is not an
|
|
387
|
+
# owner's: marking one grant seen. Idempotent — re-acking simply re-stamps the column,
|
|
388
|
+
# and the row stays hidden either way.
|
|
389
|
+
desc 'mark one share row seen (self only)'
|
|
390
|
+
params do
|
|
391
|
+
requires :share_id, type: Integer, desc: 'Share ID'
|
|
392
|
+
end
|
|
393
|
+
post ':share_id/ack' do
|
|
394
|
+
row = find_share!(params[:share_id])
|
|
395
|
+
# Self only. Acking is the person the row *names* saying "seen", so nobody may say it
|
|
396
|
+
# on their behalf — not the owner, not an administrator, and authorize_klass! is
|
|
397
|
+
# therefore the wrong question entirely: holding :manage on the template does not make
|
|
398
|
+
# somebody else's grant yours to acknowledge.
|
|
399
|
+
#
|
|
400
|
+
# 404 with find_share!'s own message rather than 403: the inbox is the only place a
|
|
401
|
+
# share id reaches this user, so a row that does not name them is indistinguishable
|
|
402
|
+
# from one that does not exist, and a 403 would confirm that somebody else's share
|
|
403
|
+
# sits at that id.
|
|
404
|
+
error!('Share is invalid. Please refresh.', 404) unless row.shared_with_id == current_user.id
|
|
405
|
+
# update_column, NOT update! — deliberate, and the whole reason this is not a one-liner.
|
|
406
|
+
# `updated_at` is the other half of the unacknowledged comparison
|
|
407
|
+
# (acked_at IS NULL OR acked_at < updated_at), so an ack that bumped it would land
|
|
408
|
+
# acked_at and updated_at on the same write and leave the row announcing itself for
|
|
409
|
+
# ever. update_column writes the one column and skips the timestamp entirely, which
|
|
410
|
+
# also keeps the row out of share_activity's change token: marking a message read is
|
|
411
|
+
# not a change to anybody's sharing, and it must not make every other Designer session
|
|
412
|
+
# re-read the grid row.
|
|
413
|
+
#
|
|
414
|
+
# `Time.now.utc`, not `Time.current`: this is a machine timestamp whose only job is to
|
|
415
|
+
# be compared against `updated_at`, and that is exactly the clock and zone ActiveRecord
|
|
416
|
+
# itself writes `updated_at` from (default_timezone :utc). It also keeps the file free
|
|
417
|
+
# of ActiveSupport's zone machinery, which the gem's own spec process does not boot.
|
|
418
|
+
row.update_column(:acked_at, Time.now.utc) # rubocop:disable Rails/SkipsModelValidations
|
|
419
|
+
# 200, not Grape's default 201 on a POST: nothing was created.
|
|
420
|
+
status 200
|
|
421
|
+
{ mc: 'ss00' }
|
|
422
|
+
rescue StandardError => e
|
|
423
|
+
Labimotion.log_exception(e, current_user)
|
|
424
|
+
{ mc: 'se00', msg: e.message }
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# What the Designer polls to learn that its share state moved (§7), and the whole of what
|
|
429
|
+
# it needs: two aggregates over the share rows involving the caller. It used to ask the
|
|
430
|
+
# *host* instead — a poll of `/api/v1/messages/list?is_ack=0` — which could not answer
|
|
431
|
+
# this question: that list carries every kind of notification and the library cannot name
|
|
432
|
+
# the host's channels to filter it, so an unrelated spectra message refreshed the grid; it
|
|
433
|
+
# is a host route no embedder is obliged to mount, and the library's only reach outside
|
|
434
|
+
# gem-owned API surface; and reading it acknowledges message rows as a side effect. The
|
|
435
|
+
# question is about klass_shares, so it is answered here, from data the gem already holds.
|
|
436
|
+
#
|
|
437
|
+
# Authentication only, and deliberately no further gate. authorize_klass! cannot apply —
|
|
438
|
+
# there is no single klass in the question — and neither can authenticate_admin!, which
|
|
439
|
+
# needs a family. What makes that safe is the payload: a row count and a timestamp over
|
|
440
|
+
# the caller's *own* involvement, naming no template, no user and no level, and disclosing
|
|
441
|
+
# nothing about anybody else's sharing. A gate here would refuse the very callers the
|
|
442
|
+
# route exists for — a grantee holds no designer right and owns nothing to authorize on.
|
|
443
|
+
namespace :share_activity do
|
|
444
|
+
desc 'change token for the share rows involving the current user'
|
|
445
|
+
get do
|
|
446
|
+
activity = Labimotion::ShareResolver.activity_for(current_user)
|
|
447
|
+
# `latest` may move *backwards*: a revocation is a hard delete, so the newest
|
|
448
|
+
# surviving row can be older than the one that just went away. The client therefore
|
|
449
|
+
# compares the pair for inequality in either direction and never as "newer than" —
|
|
450
|
+
# a timestamp cursor would stop detecting revocations. Milliseconds, so that two
|
|
451
|
+
# changes within the same second are still two different tokens.
|
|
452
|
+
#
|
|
453
|
+
# `digests` is the same token taken per klass key, and it is what lets the client
|
|
454
|
+
# stop reloading its whole grid: it diffs the map and re-reads only the keys whose
|
|
455
|
+
# digest changed, appeared or vanished, through `GET /generic_klass/rows` below. Same
|
|
456
|
+
# inequality-in-either-direction rule, and it matters more here — a revoked template's
|
|
457
|
+
# key drops out of the map entirely, and a key's `count`/`latest` both move down.
|
|
458
|
+
# `count` and `latest` stay: they are the cheap early-out, and a client that only
|
|
459
|
+
# wants "did anything change" need not walk the map at all.
|
|
460
|
+
{ mc: 'ss00', count: activity[:count], latest: activity[:latest]&.iso8601(3),
|
|
461
|
+
digests: share_digests(activity[:digests]) }
|
|
462
|
+
rescue StandardError => e
|
|
463
|
+
Labimotion.log_exception(e, current_user)
|
|
464
|
+
{ mc: 'se00', msg: e.message }
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# The Designer's share inbox (§7): the two queues that make the request loop's last hop
|
|
469
|
+
# visible to somebody. The owner gets what is waiting on their templates; the requester
|
|
470
|
+
# gets the grants nobody has told them about — until now the grant landed as a host
|
|
471
|
+
# message on a page the Designer does not watch, so the person who asked was never told.
|
|
472
|
+
#
|
|
473
|
+
# Polled on the same tick as share_activity above: one timer, two endpoints. Which rows go
|
|
474
|
+
# into each queue is ShareResolver's question and its spec's; this route's job is the wire
|
|
475
|
+
# shape, and the two bulk resolutions (klass labels, user names) that turn rows into lines
|
|
476
|
+
# somebody can read.
|
|
477
|
+
#
|
|
478
|
+
# Authentication only, and deliberately no further gate — the same reasoning as
|
|
479
|
+
# share_activity above, so nobody should "fix" it here either. authorize_klass! has no
|
|
480
|
+
# single klass to take and authenticate_admin! no family, and what makes it safe is that
|
|
481
|
+
# every row returned either names the caller or sits on a template the caller holds the
|
|
482
|
+
# owner row on. A gate would refuse the very callers the route exists for: a grantee holds
|
|
483
|
+
# no designer right and owns nothing to authorize on.
|
|
484
|
+
namespace :share_inbox do
|
|
485
|
+
desc "the current user's two share queues: requests to answer, grants to acknowledge"
|
|
486
|
+
get do
|
|
487
|
+
inbox = Labimotion::ShareResolver.inbox_for(current_user)
|
|
488
|
+
{ mc: 'ss00' }.merge(represent_inbox(inbox))
|
|
489
|
+
rescue StandardError => e
|
|
490
|
+
Labimotion.log_exception(e, current_user)
|
|
491
|
+
{ mc: 'se00', msg: e.message, requests: [], grants: [] }
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# The other half of the poller's contract, and the reason it exists: knowing *which*
|
|
496
|
+
# templates moved is only useful if a client can re-read those rows alone. Every entry is
|
|
497
|
+
# what the Designer list endpoints return for the same record — same entity, the same
|
|
498
|
+
# `with_ownership`, `owners:` and `share_context:` options — so a row can be dropped
|
|
499
|
+
# straight into the grid and every capability field still means what it meant in the list.
|
|
500
|
+
#
|
|
501
|
+
# It lives here rather than beside `GET /generic_klass/fetch`, which it superficially
|
|
502
|
+
# resembles, for the reason this whole class exists: `fetch` represents with neither
|
|
503
|
+
# `share_context` nor `with_ownership`, so a row read through it carries no `owner`, no
|
|
504
|
+
# `can_*` and no `current_user_permission` — and the library reads absent capability
|
|
505
|
+
# fields as permissive (that is how an un-upgraded gem stays usable), so patching a grid
|
|
506
|
+
# row from `fetch` would quietly turn a viewer's row editable. Widening `fetch` instead
|
|
507
|
+
# would change a payload other callers already depend on.
|
|
508
|
+
#
|
|
509
|
+
# Authorization: deliberately none, matching the list endpoints this mirrors. Those are
|
|
510
|
+
# ungated by decision — every designer lists every template, and it is the *actions* that
|
|
511
|
+
# gate — and a row read has to answer to exactly the same callers, including the grantee
|
|
512
|
+
# who holds no designer right at all. What constrains the caller is inside the payload:
|
|
513
|
+
# the capability booleans and `current_user_permission` are resolved for *this* user, and
|
|
514
|
+
# every mutating route re-checks with authorize_klass! regardless. So do not "fix" the
|
|
515
|
+
# missing gate here: it would break the poller for the users sharing exists for, without
|
|
516
|
+
# protecting anything the list endpoints do not already disclose.
|
|
517
|
+
namespace :rows do
|
|
518
|
+
desc 'batch-read grid rows for the keys share_activity flagged'
|
|
519
|
+
params do
|
|
520
|
+
# `?keys[]=ElementKlass:12&keys[]=SegmentKlass:3` — Grape's array-of-type coercion
|
|
521
|
+
# syntax, not a redundant constructor.
|
|
522
|
+
# rubocop:disable Style/RedundantArrayConstructor
|
|
523
|
+
requires :keys, type: Array[String], desc: '"KlassType:id" keys, at most 50'
|
|
524
|
+
# rubocop:enable Style/RedundantArrayConstructor
|
|
525
|
+
end
|
|
526
|
+
get do
|
|
527
|
+
rows = batch_rows(parse_row_keys!(params[:keys]))
|
|
528
|
+
# `keys` runs parallel to `data`, and is not decoration: the klass entities carry no
|
|
529
|
+
# type field, so an id alone cannot say whether a row is ElementKlass:12 or
|
|
530
|
+
# DatasetKlass:12. It is also how the client learns which requested keys were skipped
|
|
531
|
+
# — set-difference the two — without having to guess from the array's length.
|
|
532
|
+
{ mc: 'ss00', data: rows[:data], keys: rows[:keys] }
|
|
533
|
+
rescue StandardError => e
|
|
534
|
+
Labimotion.log_exception(e, current_user)
|
|
535
|
+
{ mc: 'se00', msg: e.message, data: [], keys: [] }
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
namespace :transfer_ownership do
|
|
540
|
+
desc 'hand the owner row to another user'
|
|
541
|
+
params do
|
|
542
|
+
requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
|
|
543
|
+
requires :id, type: Integer, desc: 'Klass ID'
|
|
544
|
+
requires :user_id, type: Integer, desc: 'the new owner (a Person)'
|
|
545
|
+
end
|
|
546
|
+
post do
|
|
547
|
+
klz = fetch_klass(params[:klass], params[:id])
|
|
548
|
+
# :manage admits the owner and the administrator (implicit super owner) — the
|
|
549
|
+
# latter is how a template whose owner departed gets a new one. On an owner-less
|
|
550
|
+
# template :manage falls back to the legacy designer gate, which makes this route
|
|
551
|
+
# double as adoption: assigning a first owner creates the row.
|
|
552
|
+
authorize_klass!(klz, :manage)
|
|
553
|
+
person!(params[:user_id])
|
|
554
|
+
owner_row = Labimotion::KlassShare.owner_row_for(klz)
|
|
555
|
+
Labimotion::KlassShare.transaction do
|
|
556
|
+
# The new owner may already hold a viewer/editor row; the unique (klass, user)
|
|
557
|
+
# index would collide with the handover, so that share is absorbed into the
|
|
558
|
+
# ownership first.
|
|
559
|
+
existing = Labimotion::KlassShare.for_klass(params[:klass], klz.id).to_a.find do |row|
|
|
560
|
+
row.shared_with_id == params[:user_id] &&
|
|
561
|
+
row.level != Labimotion::KlassShare::LEVELS[:owner]
|
|
562
|
+
end
|
|
563
|
+
existing&.destroy!
|
|
564
|
+
if owner_row.nil?
|
|
565
|
+
Labimotion::KlassShare.create!(
|
|
566
|
+
klass_type: params[:klass], klass_id: klz.id,
|
|
567
|
+
shared_with_id: params[:user_id], created_by: current_user.id,
|
|
568
|
+
permission_level: Labimotion::KlassShare::LEVELS[:owner]
|
|
569
|
+
)
|
|
570
|
+
else
|
|
571
|
+
# An UPDATE, not delete-and-insert: the one-owner partial index stays satisfied
|
|
572
|
+
# throughout, and created_by records who performed the handover.
|
|
573
|
+
owner_row.update!(shared_with_id: params[:user_id], created_by: current_user.id)
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
# After the commit; rescues internally, never fails the transfer, and
|
|
577
|
+
# drops the actor — a self-transfer announces nothing (#255).
|
|
578
|
+
Labimotion::ShareNotifier.transfer(klass: klz, actor: current_user,
|
|
579
|
+
user_id: params[:user_id])
|
|
580
|
+
{ mc: 'ss00' }.merge(represent_shares(klz))
|
|
581
|
+
rescue StandardError => e
|
|
582
|
+
Labimotion.log_exception(e, current_user)
|
|
583
|
+
{ mc: 'se00', msg: e.message }
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# The requester's own route, and the only one that writes a level-0 row. `POST /shares`
|
|
588
|
+
# cannot serve them: it is gated `:manage`, which only the owner and the administrator
|
|
589
|
+
# hold, and the entire premise of a request is that the caller holds nothing.
|
|
590
|
+
#
|
|
591
|
+
# There is no matching "grant" or "reject" route, on purpose. Granting is `POST /shares`
|
|
592
|
+
# unchanged — its find_or_initialize_by lands on this very row and lifts it to 10/20,
|
|
593
|
+
# and guard_owner_row! only protects level 30 — and rejecting is `DELETE /shares/:id`,
|
|
594
|
+
# a hard delete because KlassShare is deliberately not acts_as_paranoid. So "no" does
|
|
595
|
+
# not stick: the row's disappearance makes the requester askable again, and there is no
|
|
596
|
+
# "stop asking". Accepted — a tombstone would contradict the hard delete.
|
|
597
|
+
namespace :request_access do
|
|
598
|
+
desc 'ask the owner for access to one template (self, level 0, idempotent)'
|
|
599
|
+
params do
|
|
600
|
+
requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
|
|
601
|
+
requires :id, type: Integer, desc: 'Klass ID'
|
|
602
|
+
end
|
|
603
|
+
post do
|
|
604
|
+
klz = fetch_klass(params[:klass], params[:id])
|
|
605
|
+
# Deliberately NOT authorize_klass!(klz, :read): `:read` demands level >= 10 and a
|
|
606
|
+
# requester has no level at all, so the share gate would refuse every caller this
|
|
607
|
+
# route exists for. The legacy family designer right gates it instead — the grid
|
|
608
|
+
# the request is made from is behind that same right, so everyone who can see the
|
|
609
|
+
# template well enough to ask about it passes here, and nobody else does.
|
|
610
|
+
authenticate_admin!(klass_family(klz))
|
|
611
|
+
# Self only, level 0 only. Neither the target user nor the level is a parameter, so
|
|
612
|
+
# no shape of this request escalates anybody: the worst a caller can do is ask.
|
|
613
|
+
row = Labimotion::KlassShare.find_or_initialize_by(
|
|
614
|
+
klass_type: params[:klass], klass_id: klz.id, shared_with_id: current_user.id
|
|
615
|
+
)
|
|
616
|
+
created = row.new_record?
|
|
617
|
+
guard_access_held!(row) unless created
|
|
618
|
+
if created
|
|
619
|
+
row.created_by = current_user.id
|
|
620
|
+
row.permission_level = Labimotion::KlassShare::LEVELS[:requested]
|
|
621
|
+
row.save!
|
|
622
|
+
end
|
|
623
|
+
# `created` is the notify switch, and the server owns it outright now
|
|
624
|
+
# (labimotion#255): the unique index dedupes rows, not messages, so
|
|
625
|
+
# only the ask that wrote the row pings the owner — ten asks are one
|
|
626
|
+
# toast. ShareNotifier rescues internally; an undeliverable ping
|
|
627
|
+
# never fails the request, whose success is the row.
|
|
628
|
+
Labimotion::ShareNotifier.request(klass: klz, actor: current_user) if created
|
|
629
|
+
# 200 either way, not Grape's default 201 on the create: the ping
|
|
630
|
+
# decision is `created` in the body, and a client that read it off
|
|
631
|
+
# the status code would be reading the wrong thing. One status keeps
|
|
632
|
+
# that unambiguous.
|
|
633
|
+
status 200
|
|
634
|
+
{ mc: 'ss00', created: created }
|
|
635
|
+
rescue ActiveRecord::RecordNotUnique
|
|
636
|
+
# Two concurrent asks collide on the unique (klass, user) index. The row exists
|
|
637
|
+
# either way, which is the goal state, and the insert that won already earned the
|
|
638
|
+
# ping — so this one answers created: false rather than 500.
|
|
639
|
+
status 200
|
|
640
|
+
{ mc: 'ss00', created: false }
|
|
641
|
+
rescue StandardError => e
|
|
642
|
+
Labimotion.log_exception(e, current_user)
|
|
643
|
+
{ mc: 'se00', msg: e.message }
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
end
|