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,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'labimotion/version'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
# Per-user LabIMotion AI settings: the model, an optional personal API key and
|
|
7
|
+
# an optional provider endpoint of one's own.
|
|
8
|
+
#
|
|
9
|
+
# They live in our own user_settings table under the 'ai' key
|
|
10
|
+
# (Labimotion::UserAiSettings). The routes stay under /profiles, where clients
|
|
11
|
+
# already reach them, but nothing is stored on the host's profile any more.
|
|
12
|
+
#
|
|
13
|
+
# The API key is stored encrypted and never returned; a read only reports
|
|
14
|
+
# whether one is set.
|
|
15
|
+
class LabimotionAiAPI < Grape::API
|
|
16
|
+
# Fallback model choices when the server config (config/labimotion_ai.yml
|
|
17
|
+
# :models) lists none. A representative subset of the chat-capable KI-Toolbox
|
|
18
|
+
# models (ki-toolbox.scc.kit.edu); the full list lives in the yml.
|
|
19
|
+
DEFAULT_MODELS = %w[
|
|
20
|
+
models/DeepSeek-R1-Distill-Qwen-1.5B-Q4_K_M.gguf
|
|
21
|
+
kit.mistral-small-4-119b-a8b
|
|
22
|
+
azure.gpt-4.1-mini
|
|
23
|
+
azure.gpt-4.1
|
|
24
|
+
azure.gpt-5
|
|
25
|
+
azure.gpt-5-mini
|
|
26
|
+
google.claude-sonnet-4.6
|
|
27
|
+
google.claude-haiku-4.5
|
|
28
|
+
google.gemini-2.5-pro
|
|
29
|
+
kit.gpt-oss-120b
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# rubocop:disable Metrics/BlockLength
|
|
33
|
+
resource :profiles do
|
|
34
|
+
namespace :labimotion_ai do
|
|
35
|
+
# Per-user LabIMotion AI settings (model + personal API key) for the
|
|
36
|
+
# AI dataset-template feature. The API key is stored encrypted and is
|
|
37
|
+
# never returned — only whether one is set.
|
|
38
|
+
|
|
39
|
+
# These settings only exist to serve the AI integration, so they follow
|
|
40
|
+
# the same whitelist (matrices.configs :ai_uids). Users off the list get
|
|
41
|
+
# a 403 rather than a settings page they cannot act on.
|
|
42
|
+
before do
|
|
43
|
+
error!({ status: false, error: 'AI integration is not enabled for this account.' }, 403) unless
|
|
44
|
+
Matrice.ai_enabled_for_any?(current_user)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
helpers do
|
|
48
|
+
# Normalize a configured model entry (an id String, or a Hash with
|
|
49
|
+
# :id/:label) into { id:, label: }. Label falls back to the id.
|
|
50
|
+
def normalize_ai_model(entry)
|
|
51
|
+
if entry.respond_to?(:[]) && !entry.is_a?(String)
|
|
52
|
+
id = (entry[:id] || entry['id']).to_s
|
|
53
|
+
label = (entry[:label] || entry['label']).to_s
|
|
54
|
+
else
|
|
55
|
+
id = entry.to_s
|
|
56
|
+
label = ''
|
|
57
|
+
end
|
|
58
|
+
return nil if id.blank?
|
|
59
|
+
|
|
60
|
+
{ id: id, label: label.presence || id }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Add id (with a label) to models unless already present. Mutates models.
|
|
64
|
+
def append_ai_model(models, id)
|
|
65
|
+
return if id.blank? || models.any? { |m| m[:id] == id }
|
|
66
|
+
|
|
67
|
+
models << { id: id, label: id }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The admin-approved models [{ id:, label: }] for the SHARED server key:
|
|
71
|
+
# the configured :models pick-list (or the built-in default) plus the
|
|
72
|
+
# server default (:model). No per-user selection — safe to enforce.
|
|
73
|
+
def labimotion_ai_server_models
|
|
74
|
+
configured = Rails.configuration.respond_to?(:labimotion_ai) ? Rails.configuration.labimotion_ai : nil
|
|
75
|
+
models = Array(configured && configured[:models]).filter_map { |e| normalize_ai_model(e) }
|
|
76
|
+
models = Labimotion::LabimotionAiAPI::DEFAULT_MODELS.map { |id| { id: id, label: id } } if models.empty?
|
|
77
|
+
append_ai_model(models, (configured && configured[:model]).to_s)
|
|
78
|
+
models
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The selectable models as [{ id:, label: }]. Extends the server models
|
|
82
|
+
# with the user's saved model so their current selection is always valid.
|
|
83
|
+
def labimotion_ai_models
|
|
84
|
+
models = labimotion_ai_server_models
|
|
85
|
+
append_ai_model(models, ai_settings.model.to_s)
|
|
86
|
+
models
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# This user's stored AI settings. Memoized per request: several of the
|
|
90
|
+
# routes below read it more than once.
|
|
91
|
+
def ai_settings
|
|
92
|
+
@ai_settings ||= Labimotion::UserAiSettings.for(current_user)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Model ids the SHARED server key permits — the server models plus the
|
|
96
|
+
# ENV default. Unlike available_models this excludes the user's own saved
|
|
97
|
+
# model, so it is safe to enforce a keyless user's selection against.
|
|
98
|
+
def labimotion_ai_admin_model_ids
|
|
99
|
+
ids = labimotion_ai_server_models.pluck(:id)
|
|
100
|
+
(ids << ENV['KI_TOOLBOX_MODEL'].to_s).compact_blank.uniq
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A well-formed URL with a host, https only. In non-production envs
|
|
104
|
+
# (dev/test against local providers) http is also accepted. The deep
|
|
105
|
+
# SSRF check (host resolves to a public address) runs server-side in
|
|
106
|
+
# the gem at request time; this is only a fast format check for
|
|
107
|
+
# immediate UI feedback.
|
|
108
|
+
def valid_ai_endpoint?(url)
|
|
109
|
+
uri = URI.parse(url.to_s.strip)
|
|
110
|
+
return false unless uri.host.present?
|
|
111
|
+
|
|
112
|
+
Rails.env.production? ? uri.is_a?(URI::HTTPS) : uri.is_a?(URI::HTTP)
|
|
113
|
+
rescue URI::InvalidURIError
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A personal provider endpoint is only usable with a personal key, and
|
|
118
|
+
# must be a valid https URL (http allowed outside production). Raises
|
|
119
|
+
# a 422 otherwise; no-op when unset.
|
|
120
|
+
def validate_ai_endpoint!(base_url_arg, will_have_key)
|
|
121
|
+
return if base_url_arg.blank?
|
|
122
|
+
|
|
123
|
+
unless will_have_key
|
|
124
|
+
error!(
|
|
125
|
+
{ status: false, error: 'Add your personal API key to use your own AI provider endpoint.' },
|
|
126
|
+
422
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
return if valid_ai_endpoint?(base_url_arg)
|
|
130
|
+
|
|
131
|
+
scheme_msg = Rails.env.production? ? 'https://' : 'http:// or https://'
|
|
132
|
+
error!({ status: false, error: "The AI provider endpoint must be a valid #{scheme_msg} URL." }, 422)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
desc 'get the current user LabIMotion AI settings (never returns the key)'
|
|
137
|
+
get do
|
|
138
|
+
settings = ai_settings
|
|
139
|
+
configured = Rails.configuration.respond_to?(:labimotion_ai) ? Rails.configuration.labimotion_ai : nil
|
|
140
|
+
{
|
|
141
|
+
model: settings.model,
|
|
142
|
+
api_key_set: settings.api_key?,
|
|
143
|
+
base_url: settings.base_url,
|
|
144
|
+
api_path: settings.api_path,
|
|
145
|
+
available_models: labimotion_ai_models,
|
|
146
|
+
default_model: (configured && configured[:model]).presence || DEFAULT_MODELS.first,
|
|
147
|
+
default_base_url: (configured && configured[:base_url]).presence,
|
|
148
|
+
server_api_key_set: (configured && configured[:api_key]).present?
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
desc 'update the current user LabIMotion AI settings'
|
|
153
|
+
params do
|
|
154
|
+
optional :model, type: String, desc: 'Selected model id'
|
|
155
|
+
optional :api_key, type: String, desc: 'Personal API key (stored encrypted)'
|
|
156
|
+
optional :clear_api_key, type: Boolean, default: false, desc: 'Remove the stored API key'
|
|
157
|
+
optional :base_url, type: String, desc: 'Personal AI provider base URL (https; needs a personal key)'
|
|
158
|
+
optional :api_path, type: String, desc: 'Personal AI provider chat-completions path'
|
|
159
|
+
optional :clear_endpoint, type: Boolean, default: false, desc: 'Remove the stored provider endpoint'
|
|
160
|
+
end
|
|
161
|
+
put do
|
|
162
|
+
settings = ai_settings
|
|
163
|
+
api_key_arg =
|
|
164
|
+
if params[:clear_api_key]
|
|
165
|
+
'' # blank -> clear
|
|
166
|
+
elsif params[:api_key].present?
|
|
167
|
+
params[:api_key]
|
|
168
|
+
end
|
|
169
|
+
# nil -> keep the stored key
|
|
170
|
+
|
|
171
|
+
# Whether the user will hold a personal key after this save.
|
|
172
|
+
will_have_key =
|
|
173
|
+
if params[:clear_api_key]
|
|
174
|
+
false
|
|
175
|
+
elsif params[:api_key].present?
|
|
176
|
+
true
|
|
177
|
+
else
|
|
178
|
+
settings.api_key?
|
|
179
|
+
end
|
|
180
|
+
# A keyless user's AI calls run on the shared server key, so the chosen
|
|
181
|
+
# model must be one the admin approved. A user on their own key may pick
|
|
182
|
+
# any model. Enforce only when an allowlist is actually configured.
|
|
183
|
+
if params[:model].present? && !will_have_key
|
|
184
|
+
allowed = labimotion_ai_admin_model_ids
|
|
185
|
+
if allowed.any? && allowed.exclude?(params[:model])
|
|
186
|
+
error!(
|
|
187
|
+
{ status: false,
|
|
188
|
+
error: 'That model is not available on the shared server key. Pick a listed ' \
|
|
189
|
+
'model, or add your personal API key in My LabIMotion to use it.' },
|
|
190
|
+
422
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# base_url/api_path: '' clears (via clear_endpoint), a value sets, nil keeps.
|
|
196
|
+
base_url_arg = params[:clear_endpoint] ? '' : params[:base_url]
|
|
197
|
+
api_path_arg = params[:clear_endpoint] ? '' : params[:api_path]
|
|
198
|
+
validate_ai_endpoint!(base_url_arg, will_have_key)
|
|
199
|
+
|
|
200
|
+
summary = settings.update(
|
|
201
|
+
model: params[:model].presence, api_key: api_key_arg,
|
|
202
|
+
base_url: base_url_arg, api_path: api_path_arg
|
|
203
|
+
)
|
|
204
|
+
{
|
|
205
|
+
model: summary[:model],
|
|
206
|
+
api_key_set: summary[:api_key_set],
|
|
207
|
+
base_url: summary[:base_url],
|
|
208
|
+
api_path: summary[:api_path],
|
|
209
|
+
available_models: labimotion_ai_models
|
|
210
|
+
}
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
error!({ status: false, error: e.message }, 422)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
desc 'fetch the live model list from the AI provider with the user personal key'
|
|
216
|
+
post 'models' do
|
|
217
|
+
settings = ai_settings
|
|
218
|
+
# Deliberately the PERSONAL key only: the shared server key must not be
|
|
219
|
+
# sent to a user-supplied provider URL, and a keyless user's model choice
|
|
220
|
+
# is restricted to labimotion_ai_admin_model_ids anyway.
|
|
221
|
+
result = Labimotion::AiModels.new(
|
|
222
|
+
api_key: settings.api_key,
|
|
223
|
+
base_url: settings.base_url,
|
|
224
|
+
api_path: settings.api_path
|
|
225
|
+
).call
|
|
226
|
+
{ status: true, models: result[:models], endpoint: result[:endpoint] }
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
error!({ status: false, error: e.message }, 422)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
desc 'test the current user LabIMotion AI connection (nothing is saved)'
|
|
232
|
+
post 'test' do
|
|
233
|
+
settings = ai_settings
|
|
234
|
+
# Same values the runtime override path reads, so the test exercises the
|
|
235
|
+
# real config: base_url/api_path are honored by the gem only alongside a
|
|
236
|
+
# personal key, and a custom endpoint is SSRF-validated there.
|
|
237
|
+
result = Labimotion::AiTemplate.ping(
|
|
238
|
+
model: settings.model,
|
|
239
|
+
api_key: settings.api_key,
|
|
240
|
+
base_url: settings.base_url,
|
|
241
|
+
api_path: settings.api_path
|
|
242
|
+
)
|
|
243
|
+
{ status: true, ok: true, model: result['model'],
|
|
244
|
+
endpoint: result['endpoint'], ms: result['ms'] }
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
error!({ status: false, ok: false, error: e.message }, 422)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
# rubocop:enable Metrics/BlockLength
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -12,7 +12,11 @@ module Labimotion
|
|
|
12
12
|
mount Labimotion::LabimotionHubAPI
|
|
13
13
|
mount Labimotion::StandardLayerAPI
|
|
14
14
|
mount Labimotion::VocabularyAPI
|
|
15
|
+
mount Labimotion::OntologyRootAPI
|
|
15
16
|
mount Labimotion::UserAPI
|
|
17
|
+
mount Labimotion::LabimotionAiAPI
|
|
18
|
+
mount Labimotion::UserKlassSettingsAPI
|
|
19
|
+
mount Labimotion::KlassShareAPI
|
|
16
20
|
mount Labimotion::MttAPI
|
|
17
21
|
mount Labimotion::ElementVariationAPI
|
|
18
22
|
mount Labimotion::LabimotionDoiAPI
|
|
@@ -7,6 +7,10 @@ module Labimotion
|
|
|
7
7
|
# rubocop:disable Metrics/BlockLength
|
|
8
8
|
# Grape route and helper DSL blocks are necessarily large.
|
|
9
9
|
KLASS_TYPES = Labimotion::TemplateDoiHelpers::KLASS_BY_TYPE.keys.freeze
|
|
10
|
+
# Publication fields exposed on the public (anonymous) DOI endpoints.
|
|
11
|
+
# `contributor` (singular) is kept for DOIs released before contributors
|
|
12
|
+
# became a list — their snapshot still carries it.
|
|
13
|
+
PUBLIC_PUBLICATION_KEYS = %w[title description authors contributors contributor references license].freeze
|
|
10
14
|
|
|
11
15
|
helpers do
|
|
12
16
|
def resolve_klass!(type, id)
|
|
@@ -41,25 +45,36 @@ module Labimotion
|
|
|
41
45
|
)
|
|
42
46
|
end
|
|
43
47
|
|
|
44
|
-
# Public, read-only view of one released DOI version
|
|
45
|
-
|
|
48
|
+
# Public, read-only view of one released DOI version, carrying the
|
|
49
|
+
# publication metadata that version was released with.
|
|
50
|
+
def released_doi_version(record, doi)
|
|
46
51
|
full = doi.full_doi
|
|
47
52
|
{
|
|
48
53
|
doi: full,
|
|
49
54
|
doi_url: "https://dx.doi.org/#{full}",
|
|
50
55
|
version: ::Doi.labimotion_doi_version(doi),
|
|
51
|
-
minted_at: doi.minted_at
|
|
56
|
+
minted_at: doi.minted_at,
|
|
57
|
+
publication: released_publication(record, doi)
|
|
52
58
|
}
|
|
53
59
|
end
|
|
54
60
|
|
|
61
|
+
# The snapshot taken when this version was released. DOIs released before
|
|
62
|
+
# snapshots were kept have none, and fall back to the template's current
|
|
63
|
+
# publication — the best available answer for them.
|
|
64
|
+
def released_publication(record, doi)
|
|
65
|
+
publication = ::Doi.labimotion_doi_publication(doi).presence ||
|
|
66
|
+
(record.properties_template || {})['publication']
|
|
67
|
+
publication.is_a?(Hash) ? publication.slice(*PUBLIC_PUBLICATION_KEYS) : {}
|
|
68
|
+
end
|
|
69
|
+
|
|
55
70
|
# Public payload for a template that has at least one released DOI: the
|
|
56
71
|
# latest released DOI promoted to the top level, plus its publication
|
|
57
|
-
# metadata, hub deep-link and every released version.
|
|
58
|
-
#
|
|
72
|
+
# metadata, hub deep-link and every released version. Each version brings
|
|
73
|
+
# its own publication, so the top-level one is the latest release's rather
|
|
74
|
+
# than the template's work-in-progress. `record_id` matches the hub grid's
|
|
75
|
+
# row id (identifier || uuid) so the frontend can merge it.
|
|
59
76
|
def released_template_doi(record, dois)
|
|
60
|
-
versions = dois.sort_by(&:id).map { |doi| released_doi_version(doi) }
|
|
61
|
-
publication = (record.properties_template || {})['publication']
|
|
62
|
-
publication = publication.is_a?(Hash) ? publication.slice('title', 'description', 'authors', 'license') : {}
|
|
77
|
+
versions = dois.sort_by(&:id).map { |doi| released_doi_version(record, doi) }
|
|
63
78
|
versions.last.merge(
|
|
64
79
|
record_id: record.try(:identifier).presence || record.try(:uuid).presence || record.id,
|
|
65
80
|
identifier: record.try(:identifier),
|
|
@@ -68,7 +83,6 @@ module Labimotion
|
|
|
68
83
|
template_version: record.try(:version),
|
|
69
84
|
next_doi_version: ::Doi.labimotion_version_segment(record),
|
|
70
85
|
template_url: Labimotion::TemplateDoiHelpers.template_url(record),
|
|
71
|
-
publication: publication,
|
|
72
86
|
versions: versions
|
|
73
87
|
)
|
|
74
88
|
end
|
|
@@ -125,7 +139,7 @@ module Labimotion
|
|
|
125
139
|
version: ::Doi.labimotion_doi_version(doi),
|
|
126
140
|
full_doi: doi.full_doi,
|
|
127
141
|
minted: doi.minted == true,
|
|
128
|
-
xml: Labimotion::BuildTemplateDoiXml.new(record, doi
|
|
142
|
+
xml: Labimotion::BuildTemplateDoiXml.new(record, doi).call
|
|
129
143
|
}
|
|
130
144
|
end
|
|
131
145
|
{ versions: versions }
|
|
@@ -9,6 +9,10 @@ module Labimotion
|
|
|
9
9
|
class LabimotionTemplateBrowseAPI < Grape::API
|
|
10
10
|
KLASS_BY_TYPE = Labimotion::TemplateDoiHelpers::KLASS_BY_TYPE
|
|
11
11
|
KLASS_TYPES = KLASS_BY_TYPE.keys.freeze
|
|
12
|
+
# Publication fields exposed alongside a template / its released DOIs.
|
|
13
|
+
# `contributor` (singular) is kept for DOIs released before contributors
|
|
14
|
+
# became a list — their snapshot still carries it.
|
|
15
|
+
PUBLICATION_KEYS = %w[title description authors contributors contributor references license].freeze
|
|
12
16
|
# type => [revision model, foreign key to the klass]
|
|
13
17
|
REVISION_BY_TYPE = {
|
|
14
18
|
'element' => [::Labimotion::ElementKlassesRevision, :element_klass_id],
|
|
@@ -76,7 +80,14 @@ module Labimotion
|
|
|
76
80
|
# The template's DOI publication metadata (shown alongside the DOI).
|
|
77
81
|
def template_publication(record)
|
|
78
82
|
pub = (record.properties_template || {})['publication']
|
|
79
|
-
pub.is_a?(Hash) ? pub.slice(
|
|
83
|
+
pub.is_a?(Hash) ? pub.slice(*PUBLICATION_KEYS) : {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The publication a released DOI was published with. Falls back to the
|
|
87
|
+
# template's current metadata for DOIs released before snapshots were kept.
|
|
88
|
+
def released_publication(record, doi)
|
|
89
|
+
pub = ::Doi.labimotion_doi_publication(doi)
|
|
90
|
+
pub.is_a?(Hash) && pub.any? ? pub.slice(*PUBLICATION_KEYS) : template_publication(record)
|
|
80
91
|
end
|
|
81
92
|
|
|
82
93
|
# Released (minted) DOIs of the template, each tagged with its DOI version
|
|
@@ -88,6 +99,7 @@ module Labimotion
|
|
|
88
99
|
full = doi.full_doi
|
|
89
100
|
{ version: ::Doi.labimotion_doi_version(doi), doi: full,
|
|
90
101
|
doi_url: "https://dx.doi.org/#{full}", minted_at: doi.minted_at,
|
|
102
|
+
publication: released_publication(record, doi),
|
|
91
103
|
xml: Labimotion::BuildTemplateDoiXml.new(record, doi).call }
|
|
92
104
|
end
|
|
93
105
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'labimotion/version'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
# Serves the stored subtree behind one "ontology-select" root, and lets a user
|
|
7
|
+
# refresh it.
|
|
8
|
+
#
|
|
9
|
+
# The client sends the `ontology_term` string its field already holds and the
|
|
10
|
+
# server derives the file path from it. Nothing about the naming scheme is
|
|
11
|
+
# public, so it can change later without touching the frontend or migrating
|
|
12
|
+
# anything — worst case the files are fetched again.
|
|
13
|
+
#
|
|
14
|
+
# One root per request. A field may pin several, and the picker already walks
|
|
15
|
+
# them one at a time.
|
|
16
|
+
class OntologyRootAPI < Grape::API
|
|
17
|
+
helpers Labimotion::GenericHelpers
|
|
18
|
+
|
|
19
|
+
resource :ontology_roots do
|
|
20
|
+
helpers do
|
|
21
|
+
# The one root a request is about, or a 400 naming what was unusable.
|
|
22
|
+
def ontology_root!(term)
|
|
23
|
+
root = Labimotion::OntologyTerms.parse(term).first
|
|
24
|
+
error!({ status: 'error', message: 'not a usable ontology term' }, 400) if root.nil?
|
|
25
|
+
|
|
26
|
+
root
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def refresher
|
|
30
|
+
current_user.respond_to?(:name_abbreviation) ? current_user.name_abbreviation : nil
|
|
31
|
+
rescue StandardError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
desc "One root's subtree, as the picker and the auto-fill both read it"
|
|
37
|
+
params do
|
|
38
|
+
requires :term, type: String, desc: "The field's ontology_term value"
|
|
39
|
+
end
|
|
40
|
+
get do
|
|
41
|
+
root = ontology_root!(params[:term])
|
|
42
|
+
doc = Labimotion::OntologyStore.document(root, synced_by: refresher)
|
|
43
|
+
if doc.nil?
|
|
44
|
+
# The store could not be filled and the service could not be reached.
|
|
45
|
+
# The picker falls back to what it can, rather than showing an empty
|
|
46
|
+
# vocabulary as though the ontology genuinely had no terms.
|
|
47
|
+
{ status: 'error', message: 'this ontology could not be reached' }
|
|
48
|
+
else
|
|
49
|
+
{ status: 'success', root: doc }
|
|
50
|
+
end
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
Labimotion.log_exception(e, current_user)
|
|
53
|
+
{ status: 'error', message: e.message }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
desc 'Re-fetch this root from the ontology service and store what comes back'
|
|
57
|
+
params do
|
|
58
|
+
requires :term, type: String, desc: "The field's ontology_term value"
|
|
59
|
+
end
|
|
60
|
+
post :refresh do
|
|
61
|
+
root = ontology_root!(params[:term])
|
|
62
|
+
{ status: 'success', root: Labimotion::OntologyStore.refresh!(root, synced_by: refresher) }
|
|
63
|
+
rescue Labimotion::OntologyStore::Error => e
|
|
64
|
+
# Expected refusals — already refreshed a moment ago, someone else is
|
|
65
|
+
# refreshing, the result came back empty or collapsed. The person who
|
|
66
|
+
# pressed the button gets the reason; what is stored is untouched.
|
|
67
|
+
{ status: 'error', message: e.message }
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
Labimotion.log_exception(e, current_user)
|
|
70
|
+
{ status: 'error', message: e.message }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -25,8 +25,21 @@ module Labimotion
|
|
|
25
25
|
optional :displayed_in_list, type: Boolean, desc: 'Display in list format', default: false
|
|
26
26
|
end
|
|
27
27
|
get do
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
# SegmentKlassEntity nests ElementKlassEntity, and Grape hands serialization options
|
|
29
|
+
# down into nested entities — so the element klasses resolve an owner too. Their
|
|
30
|
+
# authors have to go into the same map, or every row falls back to its own user
|
|
31
|
+
# query and the preload this option exists for buys nothing. `klass_list` only
|
|
32
|
+
# preloads the association when filtering by element, which this call does not, so
|
|
33
|
+
# ask for it here or `map(&:element_klass)` is itself one query per row.
|
|
34
|
+
list = klass_list(nil, params[:is_active], false).preload(:element_klass)
|
|
35
|
+
owners = Labimotion::OwnerResolver.map_for(list.to_a + list.map(&:element_klass))
|
|
36
|
+
# The nested element klasses go into the same share context for the same reason
|
|
37
|
+
# they share the owners map; ShareResolver folds a segment's parent in by itself.
|
|
38
|
+
share_context = Labimotion::ShareResolver.context_for(
|
|
39
|
+
list.to_a + list.map(&:element_klass), current_user
|
|
40
|
+
)
|
|
41
|
+
present list, with: Labimotion::SegmentKlassEntity, root: 'klass', displayed_in_list: false,
|
|
42
|
+
with_ownership: true, owners: owners, share_context: share_context
|
|
30
43
|
end
|
|
31
44
|
end
|
|
32
45
|
|
|
@@ -36,7 +49,7 @@ module Labimotion
|
|
|
36
49
|
use :create_segment_klass_params
|
|
37
50
|
end
|
|
38
51
|
after_validation do
|
|
39
|
-
authenticate_admin!(
|
|
52
|
+
authenticate_admin!(Labimotion::Constants::Family::SEGMENT)
|
|
40
53
|
@klass = fetch_klass('ElementKlass', params[:element_klass])
|
|
41
54
|
end
|
|
42
55
|
post do
|
|
@@ -46,14 +59,47 @@ module Labimotion
|
|
|
46
59
|
end
|
|
47
60
|
end
|
|
48
61
|
|
|
49
|
-
namespace :
|
|
50
|
-
desc '
|
|
62
|
+
namespace :create_ai_klass do
|
|
63
|
+
desc 'create a Generic Segment Klass from an AI-generated template'
|
|
51
64
|
params do
|
|
52
|
-
use :
|
|
65
|
+
use :create_ai_segment_klass_params
|
|
53
66
|
end
|
|
54
67
|
after_validation do
|
|
55
68
|
authenticate_admin!('segments')
|
|
69
|
+
@klass = fetch_klass('ElementKlass', params[:element_klass])
|
|
70
|
+
end
|
|
71
|
+
post do
|
|
72
|
+
# Settle the cheap objections BEFORE spending a provider call on this:
|
|
73
|
+
# a taken name is knowable now, and finding out after generation means
|
|
74
|
+
# 15-90s and a token spend for an answer a lookup could have given.
|
|
75
|
+
validation = Labimotion::AiKlassValidator.error_for(kind: 'segment', params: params, element_klass: @klass)
|
|
76
|
+
next { status: 'error', message: validation } if validation
|
|
77
|
+
|
|
78
|
+
# Generation is a long provider call, so it is handed to the host's worker
|
|
79
|
+
# and the request answers at once; the result arrives as a notification
|
|
80
|
+
# linking back into the designer. A host with no such worker gets the
|
|
81
|
+
# old inline behaviour rather than an error.
|
|
82
|
+
if Labimotion::AiKlassQueue.queue(kind: 'segment', params: params, user: current_user,
|
|
83
|
+
element_klass_id: @klass&.id)
|
|
84
|
+
next { status: 'queued',
|
|
85
|
+
message: 'Generating the template in the background. You will be notified when it is ready.' }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
msg = create_ai_segment_klass(current_user, params)
|
|
89
|
+
{ status: msg[:status], message: msg[:message],
|
|
90
|
+
klass: Labimotion::SegmentKlassEntity.represent(Labimotion::SegmentKlass.all) }
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
Labimotion.log_exception(e, current_user)
|
|
93
|
+
{ error: e.message }
|
|
56
94
|
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
namespace :update_segment_klass do
|
|
98
|
+
desc 'update Generic Segment Klass'
|
|
99
|
+
params do
|
|
100
|
+
use :update_segment_klass_params
|
|
101
|
+
end
|
|
102
|
+
# authorize_klass!(:write) runs inside the helper, on the loaded record.
|
|
57
103
|
post do
|
|
58
104
|
update_segment_klass(current_user, params)
|
|
59
105
|
rescue StandardError => e
|
|
@@ -67,7 +113,10 @@ module Labimotion
|
|
|
67
113
|
params do
|
|
68
114
|
requires :identifier, type: String, desc: 'identifier'
|
|
69
115
|
end
|
|
70
|
-
post do
|
|
116
|
+
post do # rubocop:disable Metrics/BlockLength
|
|
117
|
+
# Pulling from the repository creates or overwrites a local template — the same
|
|
118
|
+
# create/upgrade pair the import endpoints gate, so it takes the same two gates.
|
|
119
|
+
authenticate_admin!(Labimotion::Constants::Family::SEGMENT)
|
|
71
120
|
sk_obj = fetch_repo_generic_template('Segment', params[:identifier])
|
|
72
121
|
sk_obj = sk_obj.deep_symbolize_keys[:generic_template]
|
|
73
122
|
return { error: 'No template data found' } unless sk_obj.present?
|
|
@@ -75,9 +124,17 @@ module Labimotion
|
|
|
75
124
|
ek_obj = Labimotion::ElementKlass.find_by(name: sk_obj.dig(:element_klass, :klass_name))
|
|
76
125
|
return { error: 'No related element data found' } unless ek_obj.present?
|
|
77
126
|
|
|
78
|
-
segment_klass = Labimotion::SegmentKlass.
|
|
79
|
-
|
|
80
|
-
|
|
127
|
+
segment_klass = Labimotion::SegmentKlass.find_by(identifier: sk_obj.dig(:identifier))
|
|
128
|
+
# Syncing over an existing template replaces its released properties — :release,
|
|
129
|
+
# owner-only, exactly like the upgrade branch of validate_klass.
|
|
130
|
+
authorize_klass!(segment_klass, :release) if segment_klass
|
|
131
|
+
newly_created = segment_klass.nil?
|
|
132
|
+
# created_by belongs in this INSERT, not only in the update below: the column is
|
|
133
|
+
# NOT NULL since tighten_klass_created_columns, so a bare create is one relaxed
|
|
134
|
+
# label validation away from an ActiveRecord::NotNullViolation that would break
|
|
135
|
+
# every Hub pull of a new segment template.
|
|
136
|
+
segment_klass ||= Labimotion::SegmentKlass.create(identifier: sk_obj.dig(:identifier),
|
|
137
|
+
created_by: current_user.id)
|
|
81
138
|
segment_klass.update(sk_obj.slice(
|
|
82
139
|
:label,
|
|
83
140
|
:desc,
|
|
@@ -92,6 +149,10 @@ module Labimotion
|
|
|
92
149
|
released_at: DateTime.now,
|
|
93
150
|
sync_time: DateTime.now,
|
|
94
151
|
))
|
|
152
|
+
# Owner bookkeeping only for a template this pull just created. An existing
|
|
153
|
+
# owner-less row stays owner-less: assigning it a first owner is the
|
|
154
|
+
# administrator's transfer_ownership call, not a side effect of syncing.
|
|
155
|
+
Labimotion::KlassShare.seed_owner!(segment_klass, current_user.id) if newly_created
|
|
95
156
|
|
|
96
157
|
present segment_klass, with: Labimotion::SegmentKlassEntity, root: 'klass'
|
|
97
158
|
rescue StandardError => e
|
|
@@ -126,6 +187,9 @@ module Labimotion
|
|
|
126
187
|
requires :identifier, type: String, desc: 'Identifier'
|
|
127
188
|
end
|
|
128
189
|
post do
|
|
190
|
+
# Importing needs the global create right; upgrading an existing template is gated
|
|
191
|
+
# owner-only inside validate_klass, where the create/update branch is decided.
|
|
192
|
+
authenticate_admin!(Labimotion::Constants::Family::SEGMENT)
|
|
129
193
|
msg = create_repo_klass(params, current_user, request.headers['Origin'])
|
|
130
194
|
klass = Labimotion::SegmentKlassEntity.represent(Labimotion::SegmentKlass.all)
|
|
131
195
|
{ status: msg[:status], message: msg[:message], klass: klass }
|
|
@@ -142,6 +206,9 @@ module Labimotion
|
|
|
142
206
|
use :upload_segment_klass_params
|
|
143
207
|
end
|
|
144
208
|
post do
|
|
209
|
+
# Same split as create_repo_klass: create right here, owner-only upgrade inside
|
|
210
|
+
# validate_klass. This route previously had no gate at all.
|
|
211
|
+
authenticate_admin!(Labimotion::Constants::Family::SEGMENT)
|
|
145
212
|
declared_params = declared(params, include_missing: false)
|
|
146
213
|
attributes = declared_params.merge(
|
|
147
214
|
created_by: current_user.id,
|