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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +198 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +74 -8
  6. data/lib/labimotion/apis/klass_share_api.rb +648 -0
  7. data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
  8. data/lib/labimotion/apis/labimotion_api.rb +4 -0
  9. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  10. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  11. data/lib/labimotion/apis/ontology_root_api.rb +74 -0
  12. data/lib/labimotion/apis/segment_api.rb +77 -10
  13. data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
  14. data/lib/labimotion/conf.rb +5 -0
  15. data/lib/labimotion/constants.rb +64 -0
  16. data/lib/labimotion/entities/application_entity.rb +8 -0
  17. data/lib/labimotion/entities/eln_element_entity.rb +6 -0
  18. data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
  19. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  20. data/lib/labimotion/entities/properties_entity.rb +78 -2
  21. data/lib/labimotion/entities/segment_entity.rb +8 -0
  22. data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
  23. data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
  24. data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
  25. data/lib/labimotion/helpers/element_helpers.rb +282 -6
  26. data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
  27. data/lib/labimotion/helpers/generic_helpers.rb +293 -4
  28. data/lib/labimotion/helpers/param_helpers.rb +105 -0
  29. data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
  30. data/lib/labimotion/helpers/segment_helpers.rb +102 -4
  31. data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
  32. data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
  33. data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
  34. data/lib/labimotion/libs/ai_models.rb +201 -0
  35. data/lib/labimotion/libs/ai_template.rb +2285 -0
  36. data/lib/labimotion/libs/converter.rb +5 -43
  37. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  38. data/lib/labimotion/libs/export_element.rb +128 -13
  39. data/lib/labimotion/libs/file_extractor.rb +210 -0
  40. data/lib/labimotion/libs/linked_element.rb +313 -0
  41. data/lib/labimotion/libs/ontology_store.rb +226 -0
  42. data/lib/labimotion/libs/ontology_terms.rb +227 -0
  43. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  44. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  45. data/lib/labimotion/libs/sample_association.rb +52 -1
  46. data/lib/labimotion/libs/share_notifier.rb +114 -0
  47. data/lib/labimotion/libs/share_resolver.rb +373 -0
  48. data/lib/labimotion/libs/user_ai_settings.rb +129 -0
  49. data/lib/labimotion/models/cellline.rb +47 -0
  50. data/lib/labimotion/models/concerns/datasetable.rb +3 -0
  51. data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
  52. data/lib/labimotion/models/concerns/segmentable.rb +2 -0
  53. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  54. data/lib/labimotion/models/dataset_klass.rb +1 -1
  55. data/lib/labimotion/models/element_klass.rb +1 -1
  56. data/lib/labimotion/models/klass_share.rb +129 -0
  57. data/lib/labimotion/models/segment_klass.rb +1 -1
  58. data/lib/labimotion/models/user_klass_setting.rb +58 -0
  59. data/lib/labimotion/models/user_setting.rb +60 -0
  60. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  61. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  62. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  63. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  64. data/lib/labimotion/utils/export_utils.rb +1 -0
  65. data/lib/labimotion/utils/import_utils.rb +20 -3
  66. data/lib/labimotion/utils/serializer.rb +27 -0
  67. data/lib/labimotion/utils/units.rb +32 -67
  68. data/lib/labimotion/version.rb +1 -1
  69. data/lib/labimotion.rb +27 -0
  70. 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
- def released_doi_version(doi)
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. `record_id` matches
58
- # the hub grid's row id (identifier || uuid) so the frontend can merge it.
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, current_user).call
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('title', 'description', 'authors', 'license') : {}
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
- list = klass_list(nil, params[:is_active], false)
29
- present list, with: Labimotion::SegmentKlassEntity, root: 'klass', displayed_in_list: false
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!('segments')
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 :update_segment_klass do
50
- desc 'update Generic Segment Klass'
62
+ namespace :create_ai_klass do
63
+ desc 'create a Generic Segment Klass from an AI-generated template'
51
64
  params do
52
- use :update_segment_klass_params
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.find_or_create_by(
79
- identifier: sk_obj.dig(:identifier),
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,