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,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'erb'
|
|
5
|
+
|
|
6
|
+
module Labimotion
|
|
7
|
+
# Resolve the selectable vocabulary behind an "ontology-select" field.
|
|
8
|
+
#
|
|
9
|
+
# A template field pins ONE ontology root (its `ontology_term`); the picker in
|
|
10
|
+
# chem-generic-ui then offers that root and everything below it. The AI data-fill
|
|
11
|
+
# needs the same list — otherwise the model invents a plausible-sounding term that
|
|
12
|
+
# is not in the ontology and the field renders a value the user could never have
|
|
13
|
+
# chosen themselves.
|
|
14
|
+
#
|
|
15
|
+
# The whole subtree arrives in ONE request via `?hierarchicalAncestor=`, rather
|
|
16
|
+
# than the dozens of round trips a /children walk costs; the result set is the
|
|
17
|
+
# same terms, de-duplicated (a term with two parents appears twice in a walk).
|
|
18
|
+
#
|
|
19
|
+
# What comes back is handed to Labimotion::OntologyStore, which is what actually
|
|
20
|
+
# keeps it — so the picker and the auto-fill read one list instead of resolving
|
|
21
|
+
# the same subtree separately.
|
|
22
|
+
#
|
|
23
|
+
# Every failure path returns [] — no vocabulary simply means the field falls back
|
|
24
|
+
# to the free-text behaviour it had before, never a failed fill.
|
|
25
|
+
class OntologyTerms
|
|
26
|
+
DEFAULT_BASE_URL = 'https://api.terminology.tib.eu/api/v2'
|
|
27
|
+
# One page, sized past the largest root anyone points a field at (the biggest
|
|
28
|
+
# CHMO branch is ~650 terms).
|
|
29
|
+
PAGE_SIZE = 1000
|
|
30
|
+
REQUEST_TIMEOUT = 20
|
|
31
|
+
# Per-field and per-request budgets. A field aimed at a very general root would
|
|
32
|
+
# otherwise push tens of thousands of labels into the prompt and crowd out the
|
|
33
|
+
# document the model is supposed to be reading. Both limits SKIP a field rather
|
|
34
|
+
# than truncate its list: a partial vocabulary is the worst of both worlds — the
|
|
35
|
+
# missing terms become unproposable AND a correct answer naming one gets thrown
|
|
36
|
+
# away by validation. Sized well past any real root (the largest CHMO branch is
|
|
37
|
+
# ~650 terms).
|
|
38
|
+
MAX_TERMS_PER_FIELD = 1500
|
|
39
|
+
MAX_TERMS_TOTAL = 3000
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Selectable labels for one field's `ontology_term`, root included, sorted and
|
|
43
|
+
# de-duplicated. Accepts the shapes the designer writes: a { 'label', 'value' }
|
|
44
|
+
# hash, a bare string, or an ARRAY of either (a field may pin several roots).
|
|
45
|
+
#
|
|
46
|
+
# @return [Array<String>] [] when nothing is configured, the subtree is beyond
|
|
47
|
+
# MAX_TERMS_PER_FIELD, or the service fails
|
|
48
|
+
def labels_for(ontology_term)
|
|
49
|
+
roots = parse(ontology_term)
|
|
50
|
+
return [] if roots.empty?
|
|
51
|
+
|
|
52
|
+
labels = roots.flat_map { |root| Labimotion::OntologyStore.labels(root) }
|
|
53
|
+
.uniq(&:downcase).sort_by(&:downcase)
|
|
54
|
+
return labels if labels.size <= MAX_TERMS_PER_FIELD
|
|
55
|
+
|
|
56
|
+
log("subtree of #{roots.map { |r| r[:iri] }.join(', ')} has #{labels.size} terms " \
|
|
57
|
+
"(over #{MAX_TERMS_PER_FIELD}); leaving the field unconstrained")
|
|
58
|
+
[]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Every node of one root's subtree, the root included, as the store keeps
|
|
62
|
+
# them: { 'iri', 'label', 'description', 'parent' }. The root is selectable
|
|
63
|
+
# in the picker, so it has to be in the list the model is offered too.
|
|
64
|
+
#
|
|
65
|
+
# Two requests: the root is not in its own descendant query.
|
|
66
|
+
def subtree(root)
|
|
67
|
+
node = root_node(root)
|
|
68
|
+
return [] if node.nil?
|
|
69
|
+
|
|
70
|
+
nodes = ([node] + descendant_nodes(root)).uniq { |n| n['iri'] }
|
|
71
|
+
resolve_parents(nodes, node['iri'])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Split a configured ontology_term into [{ ontology:, iri: }, ...]. Mirrors
|
|
75
|
+
# parseOntologyTerm in chem-generic-ui/src/utils/ontologyApi.js, which is what
|
|
76
|
+
# actually wrote these strings.
|
|
77
|
+
def parse(ontology_term)
|
|
78
|
+
wrap_terms(ontology_term).filter_map { |term| parse_one(term) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Public because the store records which service a file came from, so a
|
|
82
|
+
# subtree stored against one host can be told apart from another's.
|
|
83
|
+
def base_url
|
|
84
|
+
cfg = (Rails.configuration.labimotion_ai if defined?(Rails) && Rails.respond_to?(:configuration))
|
|
85
|
+
(cfg.is_a?(Hash) && cfg[:ontology_base_url].presence) ||
|
|
86
|
+
ENV['LABIMOTION_ONTOLOGY_BASE_URL'].presence ||
|
|
87
|
+
DEFAULT_BASE_URL
|
|
88
|
+
rescue StandardError
|
|
89
|
+
DEFAULT_BASE_URL
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# A field may pin one term or a list of them. `Array()` would tear the
|
|
95
|
+
# { 'label', 'value' } hash apart into pairs, so branch explicitly.
|
|
96
|
+
def wrap_terms(value)
|
|
97
|
+
return [] if value.nil?
|
|
98
|
+
return value if value.is_a?(Array)
|
|
99
|
+
|
|
100
|
+
[value]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parse_one(term)
|
|
104
|
+
str = term_to_s(term)
|
|
105
|
+
return nil if str.empty?
|
|
106
|
+
|
|
107
|
+
ontology, iri = split_term(str)
|
|
108
|
+
return nil if ontology.to_s.empty? || iri.to_s.empty?
|
|
109
|
+
|
|
110
|
+
{ ontology: ontology.downcase, iri: iri }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A term is stored either as the { 'label' => .., 'value' => .. } hash the
|
|
114
|
+
# designer's picker writes, or as a bare string.
|
|
115
|
+
def term_to_s(term)
|
|
116
|
+
raw = term.is_a?(Hash) ? (term['value'] || term[:value]) : term
|
|
117
|
+
raw.to_s.strip
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# "bao:class:<iri>" (what the designer writes) | "ont|<iri>" | a bare OBO IRI.
|
|
121
|
+
def split_term(str)
|
|
122
|
+
if (m = str.match(/\A([^:]+):class:(.+)\z/))
|
|
123
|
+
[m[1], m[2]]
|
|
124
|
+
elsif (parts = str.split('|')).size == 2
|
|
125
|
+
[parts[0].strip, parts[1].strip]
|
|
126
|
+
elsif (m = str.match(%r{obo/([A-Za-z]+)_}))
|
|
127
|
+
[m[1], str]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def root_node(root)
|
|
132
|
+
body = get("#{base_url}/ontologies/#{root[:ontology]}/classes/#{double_encode(root[:iri])}")
|
|
133
|
+
return nil unless body.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
node_from(body, root[:iri])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def descendant_nodes(root)
|
|
139
|
+
url = "#{base_url}/ontologies/#{root[:ontology]}/classes" \
|
|
140
|
+
"?hierarchicalAncestor=#{encode(root[:iri])}&size=#{PAGE_SIZE}"
|
|
141
|
+
body = get(url)
|
|
142
|
+
elements = body.is_a?(Hash) ? body['elements'] : nil
|
|
143
|
+
return [] unless elements.is_a?(Array)
|
|
144
|
+
|
|
145
|
+
elements.filter_map do |entry|
|
|
146
|
+
next unless entry.is_a?(Hash)
|
|
147
|
+
# An obsolete term is still served but must not be proposed: the picker
|
|
148
|
+
# does not offer it either.
|
|
149
|
+
next if entry['isObsolete']
|
|
150
|
+
|
|
151
|
+
node_from(entry)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# `parents` is carried only as far as resolve_parents, which turns the
|
|
156
|
+
# graph edges into the single one the picker can draw.
|
|
157
|
+
def node_from(entry, fallback_iri = nil)
|
|
158
|
+
label = normalize_label(entry['label'])
|
|
159
|
+
iri = entry['iri'].presence || fallback_iri
|
|
160
|
+
return nil if label.empty? || iri.blank?
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
'iri' => iri.to_s,
|
|
164
|
+
'label' => label,
|
|
165
|
+
'description' => normalize_label(entry['definition']),
|
|
166
|
+
'parents' => Array(entry['hierarchicalParent']).map(&:to_s)
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# An ontology is a graph, not a tree: a term can have several parents, and
|
|
171
|
+
# only some of them are inside the subtree being stored. Keep the first one
|
|
172
|
+
# that IS inside so the picker has something to draw, and hang anything
|
|
173
|
+
# else off the root rather than dropping it — a term reachable from the
|
|
174
|
+
# root is selectable, whichever way it is reached.
|
|
175
|
+
def resolve_parents(nodes, root_iri)
|
|
176
|
+
inside = nodes.to_h { |n| [n['iri'], true] }
|
|
177
|
+
nodes.map do |node|
|
|
178
|
+
parents = node.delete('parents')
|
|
179
|
+
parent =
|
|
180
|
+
if node['iri'] == root_iri
|
|
181
|
+
nil
|
|
182
|
+
else
|
|
183
|
+
Array(parents).find { |p| p != node['iri'] && inside[p] } || root_iri
|
|
184
|
+
end
|
|
185
|
+
node.merge('parent' => parent)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# v2 returns a label as a string, a one-element array, or a language-tagged
|
|
190
|
+
# object — the same three shapes normalizeLabel handles on the client.
|
|
191
|
+
def normalize_label(raw)
|
|
192
|
+
first = raw.is_a?(Array) ? raw.first : raw
|
|
193
|
+
return '' if first.nil?
|
|
194
|
+
return first.to_s if first.is_a?(String) || first.is_a?(Numeric)
|
|
195
|
+
|
|
196
|
+
first.is_a?(Hash) ? (first['value'] || first[:value]).to_s : ''
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Best-effort note in log/labimotion.log; never raises, never blocks a fill.
|
|
200
|
+
def log(message)
|
|
201
|
+
Labimotion.logger.warn("OntologyTerms #{message}")
|
|
202
|
+
rescue StandardError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def get(url)
|
|
207
|
+
response = HTTParty.get(url, timeout: REQUEST_TIMEOUT, follow_redirects: false)
|
|
208
|
+
return nil unless response.code == 200
|
|
209
|
+
|
|
210
|
+
JSON.parse(response.body)
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
Labimotion.log_exception(e)
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# A path segment carries the IRI double-encoded (TIB decodes once itself); a
|
|
217
|
+
# query parameter is encoded exactly once.
|
|
218
|
+
def double_encode(iri)
|
|
219
|
+
encode(encode(iri))
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def encode(str)
|
|
223
|
+
ERB::Util.url_encode(str.to_s)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# `index_by` below is an ActiveSupport core extension, not plain Ruby. A booted host always
|
|
4
|
+
# has it, so the omission was invisible in production, but relying on the host to have loaded
|
|
5
|
+
# it is luck rather than contract -- and in the gem's own spec process it made this file
|
|
6
|
+
# depend on whether some other spec had required grape first.
|
|
7
|
+
require 'active_support/core_ext/enumerable'
|
|
8
|
+
|
|
9
|
+
module Labimotion
|
|
10
|
+
## OwnerResolver
|
|
11
|
+
# Builds the `owners:` option the klass entities read, so rendering a grid of N templates
|
|
12
|
+
# costs one user query instead of N. Deleted users are included on purpose: a template whose
|
|
13
|
+
# author has left still has to name somebody, otherwise the grid can only say "unknown" and
|
|
14
|
+
# an administrator has nothing to act on.
|
|
15
|
+
class OwnerResolver
|
|
16
|
+
def self.map_for(klasses)
|
|
17
|
+
map_for_ids(Array(klasses).filter_map { |klass| klass.try(:created_by) })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Same map, from bare user ids — what ShareResolver feeds it, where the ids come from
|
|
21
|
+
# owner share rows rather than a created_by column.
|
|
22
|
+
def self.map_for_ids(ids)
|
|
23
|
+
ids = Array(ids).compact.uniq
|
|
24
|
+
return {} if ids.empty?
|
|
25
|
+
|
|
26
|
+
user_scope.where(id: ids).index_by(&:id)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
# Ownership is decoration on a list that must still render.
|
|
29
|
+
Labimotion.log_exception(e)
|
|
30
|
+
{}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Single-user fallback for the call sites that ask for ownership without preloading a map.
|
|
34
|
+
# Same soft-delete scope and same "never break the caller" contract as `map_for`.
|
|
35
|
+
def self.find(user_id)
|
|
36
|
+
return nil if user_id.blank?
|
|
37
|
+
|
|
38
|
+
user_scope.find_by(id: user_id)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
Labimotion.log_exception(e)
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# `with_deleted` comes from acts_as_paranoid, which the host's User model may not use.
|
|
45
|
+
def self.user_scope
|
|
46
|
+
::User.respond_to?(:with_deleted) ? ::User.with_deleted : ::User
|
|
47
|
+
end
|
|
48
|
+
private_class_method :user_scope
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# `constantize` and `underscore` below are ActiveSupport core extensions, not plain Ruby.
|
|
4
|
+
# A booted host always has them, so the omission is invisible in production, but the gem's
|
|
5
|
+
# own spec process does not load them on its own.
|
|
6
|
+
require 'active_support/core_ext/string/inflections'
|
|
7
|
+
require 'labimotion/constants'
|
|
8
|
+
|
|
9
|
+
module Labimotion
|
|
10
|
+
## OwnershipAudit
|
|
11
|
+
# Reports templates whose `created_by` may no longer name the person who wrote them.
|
|
12
|
+
#
|
|
13
|
+
# Why this exists: until the fix in this same change, `upload_klass` merged
|
|
14
|
+
# `created_by: current_user.id` into the attributes it handed to `validate_klass`, whose
|
|
15
|
+
# *update* branch then wrote them wholesale — so every re-import of an existing template
|
|
16
|
+
# reassigned its authorship to whoever re-imported it. The fix stops new damage; it cannot
|
|
17
|
+
# undo what already happened, and `created_by` is both what the Owner column displays and
|
|
18
|
+
# what per-template ownership will be seeded from.
|
|
19
|
+
#
|
|
20
|
+
# What it can and cannot tell you: a klass revision records `created_by` as the user who
|
|
21
|
+
# *released* that revision, not the one who created the klass. So a mismatch between a
|
|
22
|
+
# klass's `created_by` and its earliest revision's is a **signal** that authorship moved,
|
|
23
|
+
# not proof — a template legitimately first released by a colleague looks the same. This is
|
|
24
|
+
# a report for a human to read. It gates nothing, changes nothing, and writes nothing.
|
|
25
|
+
#
|
|
26
|
+
# Run it from the host:
|
|
27
|
+
# bundle exec rails runner 'Labimotion::OwnershipAudit.report'
|
|
28
|
+
class OwnershipAudit
|
|
29
|
+
def self.report(io = $stdout)
|
|
30
|
+
findings = Labimotion::Constants::Klass::ALL.flat_map { |klass_name| findings_for(klass_name) }
|
|
31
|
+
io.puts(format_report(findings))
|
|
32
|
+
findings
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.findings_for(klass_name)
|
|
36
|
+
model = "Labimotion::#{klass_name}".constantize
|
|
37
|
+
revision_model = "Labimotion::#{klass_name}esRevision".constantize
|
|
38
|
+
foreign_key = "#{klass_name.underscore}_id"
|
|
39
|
+
|
|
40
|
+
model.all.filter_map do |klass|
|
|
41
|
+
first_revision = revision_model.where(foreign_key => klass.id).order(:created_at).first
|
|
42
|
+
next if first_revision.nil?
|
|
43
|
+
next if first_revision.created_by.blank? || klass.created_by == first_revision.created_by
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
klass: klass_name,
|
|
47
|
+
id: klass.id,
|
|
48
|
+
label: klass.label,
|
|
49
|
+
created_by: klass.created_by,
|
|
50
|
+
first_release_by: first_revision.created_by,
|
|
51
|
+
first_released_at: first_revision.created_at
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
Labimotion.log_exception(e)
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.format_report(findings)
|
|
60
|
+
lines = ["Labimotion ownership audit — #{findings.size} template(s) to look at"]
|
|
61
|
+
lines << 'created_by differs from the user who released the earliest revision. ' \
|
|
62
|
+
'Expected for templates a colleague first released; suspicious for the rest.'
|
|
63
|
+
lines << ''
|
|
64
|
+
findings.each do |f|
|
|
65
|
+
lines << "#{f[:klass].ljust(13)} ##{f[:id].to_s.ljust(5)} " \
|
|
66
|
+
"#{f[:label].to_s[0, 40].ljust(40)} created_by=#{f[:created_by]} " \
|
|
67
|
+
"first_release_by=#{f[:first_release_by]} (#{f[:first_released_at]})"
|
|
68
|
+
end
|
|
69
|
+
lines.join("\n")
|
|
70
|
+
end
|
|
71
|
+
private_class_method :format_report
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -70,7 +70,50 @@ module Labimotion
|
|
|
70
70
|
[]
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
## Link the generic elements referenced by drag_element table columns to the
|
|
74
|
+
## owning element, mirroring what layer-level drag_element fields do below.
|
|
75
|
+
def self.build_table_element(field_tables, current_user, element = nil)
|
|
76
|
+
els = []
|
|
77
|
+
return els if element.nil?
|
|
78
|
+
|
|
79
|
+
field_tables.each do |field|
|
|
80
|
+
next unless field['sub_values'].present? && field[Labimotion::Prop::SUBFIELDS].present?
|
|
81
|
+
|
|
82
|
+
field_table_elements = field[Labimotion::Prop::SUBFIELDS].select { |ss| ss['type'] == Labimotion::FieldType::DRAG_ELEMENT }
|
|
83
|
+
next unless field_table_elements.present?
|
|
84
|
+
|
|
85
|
+
col_ids = field_table_elements.map { |x| x.values[0] }
|
|
86
|
+
col_ids.each do |col_id|
|
|
87
|
+
field['sub_values'].each do |sub_value|
|
|
88
|
+
next unless sub_value[col_id].present? && sub_value[col_id]['value'].present? && sub_value[col_id]['value']['el_id'].present?
|
|
89
|
+
|
|
90
|
+
svalue = sub_value[col_id]['value']
|
|
91
|
+
sid = svalue['el_id']
|
|
92
|
+
next if sid == element.id
|
|
93
|
+
|
|
94
|
+
# drag_element may link a standard element (reaction, cell_line, ...);
|
|
95
|
+
# el_id is then NOT a Labimotion::Element id, so an ElementsElement row
|
|
96
|
+
# would associate an unrelated generic element that shares the id.
|
|
97
|
+
next if Labimotion::LinkedElement::MODELS.key?(svalue['el_klass'].to_s)
|
|
98
|
+
|
|
99
|
+
el = Labimotion::Element.find_by(id: sid)
|
|
100
|
+
next if el.nil?
|
|
101
|
+
|
|
102
|
+
Labimotion::ElementsElement.find_or_create_by(parent_id: element.id, element_id: el.id)
|
|
103
|
+
els << el.id
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
els
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
Labimotion.log_exception(e, current_user)
|
|
110
|
+
[]
|
|
111
|
+
end
|
|
112
|
+
|
|
73
113
|
def self.update_sample_association(properties, current_user, element = nil)
|
|
114
|
+
# Every element and segment properties save funnels through here, which
|
|
115
|
+
# makes it the one place guaranteed to see the payload before it is stored.
|
|
116
|
+
Labimotion::LinkedElement.strip_defaults(properties)
|
|
74
117
|
sds = []
|
|
75
118
|
els = []
|
|
76
119
|
properties[Labimotion::Prop::LAYERS].keys.each do |key|
|
|
@@ -99,7 +142,10 @@ module Labimotion
|
|
|
99
142
|
Labimotion::ElementsSample.find_or_create_by(element_id: element.id, sample_id: subsample.id) if element.present?
|
|
100
143
|
end
|
|
101
144
|
field_tables = properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS].select { |ss| ss['type'] == Labimotion::FieldType::TABLE }
|
|
102
|
-
|
|
145
|
+
if element.present?
|
|
146
|
+
sds << Labimotion::SampleAssociation.build_table_sample(field_tables, current_user, element)
|
|
147
|
+
els << Labimotion::SampleAssociation.build_table_element(field_tables, current_user, element)
|
|
148
|
+
end
|
|
103
149
|
field_elements = layer[Labimotion::Prop::FIELDS].select { |ss| ss['type'] == Labimotion::FieldType::DRAG_ELEMENT }
|
|
104
150
|
field_elements.each do |field|
|
|
105
151
|
idx = properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS].index(field)
|
|
@@ -109,6 +155,11 @@ module Labimotion
|
|
|
109
155
|
sid = field.dig('value', 'el_id')
|
|
110
156
|
next if element.nil? || sid.blank? || sid == element.id
|
|
111
157
|
|
|
158
|
+
# drag_element may link a standard element (reaction, cell_line, ...);
|
|
159
|
+
# el_id is then NOT a Labimotion::Element id, so an ElementsElement row
|
|
160
|
+
# would associate an unrelated generic element that shares the id.
|
|
161
|
+
next if Labimotion::LinkedElement::MODELS.key?(field.dig('value', 'el_klass').to_s)
|
|
162
|
+
|
|
112
163
|
el = Labimotion::Element.find_by(id: sid)
|
|
113
164
|
next if el.nil?
|
|
114
165
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Labimotion
|
|
4
|
+
# Courtesy notifications for share events (template-sharing.md §7), moved in
|
|
5
|
+
# from the host's Chemotion::TemplateShareAPI (chem-generic-ui#1040): the gem
|
|
6
|
+
# writes the share rows, so the gem announces them, and the two-call dance the
|
|
7
|
+
# Designer used to do (write to the gem, then call the host back to notify)
|
|
8
|
+
# collapses into the write.
|
|
9
|
+
#
|
|
10
|
+
# These messages buy awareness *outside* the Designer only — the toast in the
|
|
11
|
+
# main ELN. klass_shares is the record and the Designer reads the rows; it
|
|
12
|
+
# never reads or acks a Message. Nothing here may therefore fail a share:
|
|
13
|
+
# every public method rescues to nil, and an undeliverable notification is a
|
|
14
|
+
# notification not sent, never an error. Callers fire after the successful
|
|
15
|
+
# write and ignore the return value.
|
|
16
|
+
#
|
|
17
|
+
# The host models are reached by their bare names and only if they exist:
|
|
18
|
+
# a host without Message/Channel machinery (or one that has not run the
|
|
19
|
+
# channel migration) simply sends nothing. The channel is looked up by its
|
|
20
|
+
# literal subject — referencing Channel::TEMPLATE_SHARE_NOTIFICATION would
|
|
21
|
+
# raise on hosts whose Channel model predates the constant.
|
|
22
|
+
module ShareNotifier
|
|
23
|
+
# Must match the subject the host ELN's channel migration seeds
|
|
24
|
+
# (add_template_share_notification_channel; channel_type 8, the targeted
|
|
25
|
+
# per-user delivery branch of generate_notifications).
|
|
26
|
+
CHANNEL_SUBJECT = 'Template Share Notification'
|
|
27
|
+
|
|
28
|
+
# Fired for level changes too — the grant endpoint is an upsert and cannot
|
|
29
|
+
# tell a fresh share from a level change, so "granted you X access" stays
|
|
30
|
+
# honest for both.
|
|
31
|
+
def self.grant(klass:, actor:, user_ids:, level:)
|
|
32
|
+
label = template_label(klass)
|
|
33
|
+
ping(
|
|
34
|
+
actor: actor, user_ids: user_ids,
|
|
35
|
+
text: "#{actor.name} granted you #{level} access to the template '#{label}'."
|
|
36
|
+
)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
Labimotion.log_exception(e, actor)
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.transfer(klass:, actor:, user_id:)
|
|
43
|
+
label = template_label(klass)
|
|
44
|
+
ping(
|
|
45
|
+
actor: actor, user_ids: [user_id],
|
|
46
|
+
text: "#{actor.name} transferred ownership of the template '#{label}' to you."
|
|
47
|
+
)
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
Labimotion.log_exception(e, actor)
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The owner to ask: the klass_shares owner row, falling back to the
|
|
54
|
+
# immutable created_by for an owner-less template. A blank owner matches no
|
|
55
|
+
# user and channel_type-8 fan-out reaches Persons only, so an Admin owner
|
|
56
|
+
# gets no toast — they learn about the request when they next open the
|
|
57
|
+
# Designer, where the rows are the truth. The caller gates on `created`:
|
|
58
|
+
# the unique index dedupes rows, not messages, so only the ask that wrote
|
|
59
|
+
# the row earns a ping.
|
|
60
|
+
def self.request(klass:, actor:)
|
|
61
|
+
owner_id = Labimotion::KlassShare.owner_row_for(klass)&.shared_with_id ||
|
|
62
|
+
klass.created_by
|
|
63
|
+
return if owner_id.nil?
|
|
64
|
+
|
|
65
|
+
label = template_label(klass)
|
|
66
|
+
ping(
|
|
67
|
+
actor: actor, user_ids: [owner_id],
|
|
68
|
+
text: "#{actor.name} asks for access to your template '#{label}'. " \
|
|
69
|
+
'You can grant it from the Share dialog in the Designer.'
|
|
70
|
+
)
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
Labimotion.log_exception(e, actor)
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Plain Ruby, no ActiveSupport (`presence`/`try`): the host models are
|
|
77
|
+
# optional here, so the module must not assume the host's core extensions
|
|
78
|
+
# either — with them absent, a notification would silently never send.
|
|
79
|
+
def self.template_label(klass)
|
|
80
|
+
read_name(klass, :label) || read_name(klass, :name) || "##{klass.id}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.read_name(klass, attribute)
|
|
84
|
+
return nil unless klass.respond_to?(attribute)
|
|
85
|
+
|
|
86
|
+
value = klass.public_send(attribute).to_s
|
|
87
|
+
value.strip.empty? ? nil : value
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.ping(actor:, user_ids:, text:)
|
|
91
|
+
return unless defined?(::Message) && defined?(::Channel)
|
|
92
|
+
|
|
93
|
+
# Persons only (the fan-out inserts notifications for Persons alone), and
|
|
94
|
+
# never the actor: granting yourself a toast about your own click is
|
|
95
|
+
# noise, and a self-transfer is a no-op to announce.
|
|
96
|
+
recipients = ::User.where(type: 'Person', id: user_ids).ids - [actor.id]
|
|
97
|
+
return if recipients.empty?
|
|
98
|
+
|
|
99
|
+
channel = ::Channel.find_by(subject: CHANNEL_SUBJECT)
|
|
100
|
+
return if channel.nil?
|
|
101
|
+
|
|
102
|
+
::Message.create_msg_notification(
|
|
103
|
+
channel_id: channel.id,
|
|
104
|
+
# The level rides inside message_content: create_msg_notification uses
|
|
105
|
+
# the content verbatim on this path, so a bare level: kwarg would be
|
|
106
|
+
# dropped and the toast would fall back to warning styling.
|
|
107
|
+
message_content: { data: text, level: 'info' },
|
|
108
|
+
message_from: actor.id,
|
|
109
|
+
message_to: recipients
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
private_class_method :ping, :template_label, :read_name
|
|
113
|
+
end
|
|
114
|
+
end
|