labimotion 2.4.0.rc2 → 2.4.0.rc4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/lib/labimotion/apis/generic_dataset_api.rb +41 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +60 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +50 -6
  6. data/lib/labimotion/apis/klass_share_api.rb +646 -0
  7. data/lib/labimotion/apis/labimotion_api.rb +1 -0
  8. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  9. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  10. data/lib/labimotion/apis/segment_api.rb +61 -10
  11. data/lib/labimotion/constants.rb +14 -0
  12. data/lib/labimotion/entities/application_entity.rb +13 -1
  13. data/lib/labimotion/entities/generic_klass_entity.rb +125 -0
  14. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  15. data/lib/labimotion/helpers/dataset_helpers.rb +123 -1
  16. data/lib/labimotion/helpers/element_helpers.rb +206 -4
  17. data/lib/labimotion/helpers/generic_helpers.rb +276 -4
  18. data/lib/labimotion/helpers/param_helpers.rb +68 -0
  19. data/lib/labimotion/helpers/segment_helpers.rb +87 -4
  20. data/lib/labimotion/libs/ai_egress_guard.rb +84 -0
  21. data/lib/labimotion/libs/ai_template.rb +1482 -0
  22. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  23. data/lib/labimotion/libs/file_extractor.rb +210 -0
  24. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  25. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  26. data/lib/labimotion/libs/share_notifier.rb +114 -0
  27. data/lib/labimotion/libs/share_resolver.rb +369 -0
  28. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  29. data/lib/labimotion/models/dataset_klass.rb +1 -1
  30. data/lib/labimotion/models/element_klass.rb +1 -1
  31. data/lib/labimotion/models/klass_share.rb +126 -0
  32. data/lib/labimotion/models/segment_klass.rb +1 -1
  33. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  34. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  35. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  36. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  37. data/lib/labimotion/utils/prop.rb +1 -0
  38. data/lib/labimotion/version.rb +1 -1
  39. data/lib/labimotion.rb +11 -0
  40. metadata +28 -2
@@ -31,7 +31,13 @@ module Labimotion
31
31
  attributes[:properties_release] = attributes[:properties_template]
32
32
  attributes[:created_by] = current_user.id
33
33
 
34
- new_klass = Labimotion::ElementKlass.create!(attributes)
34
+ # The owner row shares the create's transaction: a klass committed without it is
35
+ # owner-less, which falls open to the legacy designer-wide gate.
36
+ new_klass = Labimotion::ElementKlass.transaction do
37
+ Labimotion::ElementKlass.create!(attributes).tap do |klz|
38
+ Labimotion::KlassShare.seed_owner!(klz, current_user.id)
39
+ end
40
+ end
35
41
  new_klass.reload
36
42
  new_klass.create_klasses_revision(current_user)
37
43
  klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
@@ -43,6 +49,184 @@ module Labimotion
43
49
  raise e
44
50
  end
45
51
 
52
+ # Create a new (inactive) element klass whose properties template is
53
+ # generated by an LLM from a user-provided name/label plus optional
54
+ # description, reference links and uploaded files. The admin reviews/edits
55
+ # the generated template in the designer and activates it (human-in-the-loop).
56
+ def create_ai_element_klass(params, current_user)
57
+ name = params[:name]
58
+ label = params[:label]
59
+ if Labimotion::ElementKlass.find_by(name: name).present?
60
+ return { status: 'error', message: "An element named [#{name}] already exists." }
61
+ end
62
+
63
+ if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
64
+ return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
65
+ end
66
+
67
+ overrides = ai_user_overrides(current_user)
68
+ ai = Labimotion::AiTemplate.generate(
69
+ kind: 'element',
70
+ subject: "#{params[:label]} (#{params[:name]})",
71
+ desc: params[:desc],
72
+ cols: params[:cols],
73
+ references: params[:references],
74
+ files: params[:files],
75
+ **overrides
76
+ )
77
+
78
+ uuid = SecureRandom.uuid
79
+ # property-base schema requires pkg, uuid, klass, layers, version, identifier.
80
+ properties_template = {
81
+ 'uuid' => uuid,
82
+ 'klass' => 'ElementKlass',
83
+ 'pkg' => Labimotion::Utils.pkg(nil),
84
+ 'version' => '1.0.0',
85
+ 'identifier' => uuid,
86
+ 'layers' => ai['layers'],
87
+ 'select_options' => ai['select_options'],
88
+ 'metadata' => ai['metadata']
89
+ }
90
+ attributes = {
91
+ 'name' => name,
92
+ 'label' => label,
93
+ 'klass_prefix' => params[:klass_prefix],
94
+ 'icon_name' => params[:icon_name],
95
+ 'desc' => params[:desc].presence || ai['label'].presence,
96
+ 'is_active' => false,
97
+ 'uuid' => uuid,
98
+ 'released_at' => DateTime.now,
99
+ 'properties_template' => properties_template,
100
+ 'properties_release' => properties_template,
101
+ 'created_by' => current_user.id
102
+ }
103
+
104
+ new_klass = Labimotion::ElementKlass.create!(attributes)
105
+ new_klass.reload
106
+ new_klass.create_klasses_revision(current_user)
107
+ klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
108
+ klasses = Labimotion::ElementKlass.where(is_active: true)&.pluck(:name) || []
109
+ File.write(klass_names_file, klasses)
110
+ { status: 'success',
111
+ message: "The AI element template [#{label}] has been created as inactive. Review and activate it in the designer." }
112
+ rescue StandardError => e
113
+ Labimotion.log_exception(e, current_user)
114
+ { status: 'error', message: e.message }
115
+ end
116
+
117
+ # AI auto-fill of a generic element instance's DATA VALUES from a document
118
+ # (an existing attachment, an analysis, or freshly uploaded files). Reads the
119
+ # source text, asks the LLM to extract values for the element's OWN template
120
+ # fields, validates them, and returns them for the client to merge into the
121
+ # working copy for human review. Nothing is persisted here.
122
+ #
123
+ # Authorization: the caller must be able to READ the element, and the source
124
+ # must BELONG to that element (see the ownership checks below) — this endpoint
125
+ # reads files by id, so it must never read an Attachment/Container the user
126
+ # does not already have access to through this element.
127
+ def ai_fill_element_data(params, current_user)
128
+ element = Labimotion::Element.find(params[:element_id])
129
+ error!('401 Unauthorized', 401) unless ElementPolicy.new(current_user, element).read?
130
+
131
+ text = ai_fill_source_text(element, params)
132
+ overrides = ai_user_overrides(current_user)
133
+ ai = Labimotion::AiTemplate.fill(
134
+ properties: element.properties,
135
+ context_text: text,
136
+ **overrides
137
+ )
138
+ { status: 'success', values: ai['values'], summary: ai['summary'] }
139
+ rescue StandardError => e
140
+ Labimotion.log_exception(e, current_user)
141
+ { status: 'error', message: e.message }
142
+ end
143
+
144
+ # Resolve the requested source into plain text, enforcing ownership first.
145
+ def ai_fill_source_text(element, params)
146
+ case params[:source]
147
+ when 'attachment'
148
+ ai_fill_attachment_text(element, params[:attachment_id])
149
+ when 'analysis'
150
+ ai_fill_analysis_text(element, params[:container_id])
151
+ when 'upload'
152
+ ai_fill_upload_text(params[:files])
153
+ else
154
+ error!('400 Bad Request', 400)
155
+ end
156
+ end
157
+
158
+ # The attachment must be one of the element's own attachments OR an attachment
159
+ # of one of the element's analysis DATASET containers. Only then is it read.
160
+ def ai_fill_attachment_text(element, attachment_id)
161
+ error!('404 Not Found', 404) if attachment_id.nil?
162
+ error!('404 Not Found', 404) unless ai_fill_element_attachment_ids(element).include?(attachment_id.to_i)
163
+
164
+ att = Attachment.find(attachment_id)
165
+ Labimotion::FileExtractor.extract_bytes(att.filename, att.read_file)
166
+ end
167
+
168
+ # The container id must be one of the element's own analysis containers. Then
169
+ # concatenate the extracted text of every attachment on that analysis's dataset
170
+ # children, each prefixed "### <filename>", with the analysis name/description
171
+ # prepended as context. Cap at AiTemplate::MAX_FILES; skip unreadable files.
172
+ def ai_fill_analysis_text(element, container_id)
173
+ error!('404 Not Found', 404) if container_id.nil?
174
+ analysis = element.analyses.detect { |c| c.id == container_id.to_i }
175
+ error!('404 Not Found', 404) if analysis.nil?
176
+
177
+ parts = []
178
+ context = [analysis.name.presence, analysis.description.presence].compact.join(' — ')
179
+ parts << "Analysis: #{context}" if context.present?
180
+
181
+ ai_fill_analysis_attachments(analysis).first(Labimotion::AiTemplate::MAX_FILES).each do |att|
182
+ content = ai_fill_extract_attachment(att)
183
+ next if content.to_s.strip.empty?
184
+
185
+ parts << "### #{att.filename}\n#{content}"
186
+ end
187
+ parts.join("\n\n")
188
+ end
189
+
190
+ def ai_fill_upload_text(files)
191
+ parts = Array(files).first(Labimotion::AiTemplate::MAX_FILES).map do |file|
192
+ name = file[:filename] || file['filename']
193
+ next if name.blank?
194
+
195
+ content = Labimotion::FileExtractor.extract(name, file[:content_base64] || file['content_base64'])
196
+ next if content.to_s.strip.empty?
197
+
198
+ "### #{name}\n#{content}"
199
+ end.compact
200
+ parts.join("\n\n")
201
+ end
202
+
203
+ # Ids of attachments this element may expose to the AI fill: its own
204
+ # attachments plus the attachments of its analysis dataset containers.
205
+ def ai_fill_element_attachment_ids(element)
206
+ own = element.attachments.pluck(:id)
207
+ dataset_ids = ai_fill_dataset_containers(element).map(&:id)
208
+ dataset_att = dataset_ids.empty? ? [] : Attachment.where(attachable_type: 'Container', attachable_id: dataset_ids).pluck(:id)
209
+ (own + dataset_att).uniq
210
+ end
211
+
212
+ # Dataset containers under all of the element's analysis containers.
213
+ def ai_fill_dataset_containers(element)
214
+ element.analyses.flat_map { |analysis| analysis.children.where(container_type: 'dataset').to_a }
215
+ end
216
+
217
+ def ai_fill_analysis_attachments(analysis)
218
+ analysis.children.where(container_type: 'dataset').flat_map do |dataset|
219
+ Attachment.where(attachable_type: 'Container', attachable_id: dataset.id).to_a
220
+ end
221
+ end
222
+
223
+ def ai_fill_extract_attachment(att)
224
+ Labimotion::FileExtractor.extract_bytes(att.filename, att.read_file)
225
+ rescue StandardError => e
226
+ Labimotion.log_exception(e)
227
+ nil
228
+ end
229
+
46
230
  def update_element_klass(current_user, params)
47
231
  place = params[:place] || 100
48
232
  begin
@@ -51,6 +235,7 @@ module Labimotion
51
235
  place = 100
52
236
  end
53
237
  klass = Labimotion::ElementKlass.find(params[:id])
238
+ authorize_klass!(klass, :write)
54
239
  klass.label = params[:label] if params[:label].present?
55
240
  klass.klass_prefix = params[:klass_prefix] if params[:klass_prefix].present?
56
241
  klass.icon_name = params[:icon_name] if params[:icon_name].present?
@@ -305,12 +490,25 @@ module Labimotion
305
490
  end
306
491
 
307
492
  def validate_klass(attributes)
308
- element_klass = Labimotion::ElementKlass.find_by(identifier: attributes['identifier'])
493
+ # Guarded on presence like the segment path: a blank identifier must mean "create",
494
+ # never `find_by(identifier: nil)` — that would match an arbitrary legacy row whose
495
+ # identifier is NULL and upgrade an unrelated template.
496
+ element_klass = Labimotion::ElementKlass.find_by(identifier: attributes['identifier']) if attributes['identifier'].present?
309
497
  if element_klass.present?
310
498
  if element_klass['uuid'] == attributes['uuid'] && element_klass['version'] == attributes['version']
311
499
  return { status: 'success', message: "This element: #{attributes['name']} has the latest version!" }
312
500
  else
313
- element_klass.update!(attributes)
501
+ # Upgrading an existing template bumps its version and cuts a revision — publishing,
502
+ # which is what :release means, so it is owner-only. The gate has to live here, not
503
+ # in after_validation: which branch applies is unknown until this identifier lookup
504
+ # has run. error! throws rather than raises, so it escapes this method's rescue.
505
+ authorize_klass!(element_klass, :release)
506
+ # And it must not reassign authorship. `upload_klass` merges
507
+ # `created_by: current_user.id` into the attributes unconditionally
508
+ # (generic_element_api.rb), so without this the importer silently becomes the author
509
+ # of a template somebody else wrote — and that column is what the Owner column shows
510
+ # and what per-template ownership is seeded from.
511
+ element_klass.update!(attributes.except('created_by', :created_by))
314
512
  element_klass.create_klasses_revision(current_user)
315
513
  return { status: 'success', message: "This element: [#{attributes['name']}] has been upgraded to the version: #{attributes['version']}!" }
316
514
  end
@@ -320,7 +518,11 @@ module Labimotion
320
518
  return { status: 'error', message: "The name [#{attributes['name']}] is already in use." }
321
519
  else
322
520
  attributes['created_by'] = current_user.id
323
- element_klass = Labimotion::ElementKlass.create!(attributes)
521
+ element_klass = Labimotion::ElementKlass.transaction do
522
+ Labimotion::ElementKlass.create!(attributes).tap do |klz|
523
+ Labimotion::KlassShare.seed_owner!(klz, current_user.id)
524
+ end
525
+ end
324
526
  element_klass.create_klasses_revision(current_user)
325
527
  return { status: 'success', message: "The element: #{attributes['name']} has been created using version: #{attributes['version']}!" }
326
528
  end
@@ -1,22 +1,105 @@
1
1
  # frozen_string_literal: true
2
2
  require 'grape'
3
3
  require 'labimotion/conf'
4
+ require 'labimotion/constants'
4
5
  require 'labimotion/utils/utils'
6
+ require 'labimotion/libs/owner_resolver'
5
7
  # Helper for associated sample
6
8
  module Labimotion
7
9
  ## Generic Helpers
8
10
  module GenericHelpers
9
11
  extend Grape::API::Helpers
10
12
 
13
+ # Message code for "somebody else saved this template while you were editing it".
14
+ STALE_TEMPLATE_MC = 'sc09'
15
+ # Message code for "an active template already holds the identity you are restoring".
16
+ RESTORE_CONFLICT_MC = 'sc10'
17
+ # Message code for "this template is maintained by somebody else" — the 403 a share-gated
18
+ # action answers with. Deliberately not the legacy 401: nobody here is unauthorised, they
19
+ # are asking about a colleague's template (template-sharing.md §3).
20
+ AUTHORIZATION_REFUSAL_MC = 'sc11'
21
+ # Message code for "the owner row cannot be removed or downgraded — transfer instead".
22
+ OWNER_ROW_MC = 'sc12'
23
+ # Message code for "you already hold access to this template, so there is nothing to
24
+ # request". The 409 request_access answers a caller whose share row is already at
25
+ # viewer/editor/owner — a stale grid, not a mistake worth an error banner.
26
+ ACCESS_ALREADY_HELD_MC = 'sc13'
27
+
28
+ # How a refusal names the action it refused, and which actions are owner-only for the
29
+ # wording. Kept as plain symbols here rather than reading ShareResolver::REQUIRED_LEVEL —
30
+ # the wording must not chain-load the ActiveRecord model in a process that only wanted
31
+ # the helpers. ShareResolver stays the enforcement authority.
32
+ ACTION_PHRASE = {
33
+ read: 'view', write: 'edit', release: 'release',
34
+ deactivate: 'activate or deactivate', destroy: 'delete', manage: 'manage sharing for'
35
+ }.freeze
36
+ OWNER_ONLY_ACTIONS = %i[release deactivate destroy manage].freeze
37
+
38
+ # What makes a klass unique among the *active* rows. These are model validations only —
39
+ # there is no DB unique index behind any of them — and `restore!` writes through
40
+ # `update_columns`, so nothing re-checks them on the way back in.
41
+ KLASS_IDENTITY = {
42
+ 'ElementKlass' => %i[name],
43
+ 'SegmentKlass' => %i[label element_klass_id],
44
+ 'DatasetKlass' => %i[ols_term_id]
45
+ }.freeze
46
+
47
+ # How the refusal names the identity that is taken.
48
+ KLASS_IDENTITY_NOUN = {
49
+ 'ElementKlass' => 'name',
50
+ 'SegmentKlass' => 'label',
51
+ 'DatasetKlass' => 'ontology term'
52
+ }.freeze
53
+
54
+ # Seconds, symmetric around the deleted template's own `deleted_at`, within which a
55
+ # soft-deleted child counts as "deleted together with it" (see Paranoia#restore!).
56
+ # Wide enough to cover a slow cascade over a template with many elements, narrow enough
57
+ # that rows binned on their own, on another day, stay binned.
58
+ RESTORE_RECOVERY_WINDOW = 300
59
+
11
60
  def authenticate_admin!(type)
12
61
  unauthorized = -> { error!('401 Unauthorized', 401) }
13
62
  if %w[standard_layers vocabularies].include?(type)
14
- unauthorized.call unless current_user.generic_admin.values_at('elements', 'segments', 'datasets').any?
63
+ unauthorized.call unless current_user.generic_admin.values_at(*Labimotion::Constants::Family::ALL).any?
15
64
  else
16
65
  unauthorized.call unless current_user.generic_admin[type]
17
66
  end
18
67
  end
19
68
 
69
+ # Family string (Constants::Family) for a loaded klass record — what the legacy gate
70
+ # keys on.
71
+ def klass_family(klz)
72
+ Labimotion::Constants::Family::FAMILY_OF[klz.class.name.split('::').last]
73
+ end
74
+
75
+ # The per-template gate (template-sharing.md §6). Takes the record, not a family string:
76
+ # resolution needs that klass's share rows and, for a SegmentKlass, its parent element's.
77
+ # Owner-less templates (and hosts that have not migrated) fall back to the legacy
78
+ # designer-wide gate — permanent behaviour, not a transition. A refusal is a 403 naming
79
+ # the owner, so the person refused knows who to ask, never the legacy bare 401.
80
+ def authorize_klass!(klz, action)
81
+ result = Labimotion::ShareResolver.authorize(klz, current_user, action)
82
+ return if result == :ok
83
+ return authenticate_admin!(klass_family(klz)) if result == :legacy
84
+
85
+ owner = Labimotion::OwnerResolver.find(result.owner_id)
86
+ name = owner.respond_to?(:name) && owner.name.present? ? owner.name : 'another user'
87
+ error!({ mc: AUTHORIZATION_REFUSAL_MC,
88
+ msg: authorization_refusal_msg(name, action),
89
+ owner: { id: result.owner_id, name: name == 'another user' ? nil : name } }, 403)
90
+ end
91
+
92
+ def authorization_refusal_msg(name, action)
93
+ requirement = if OWNER_ONLY_ACTIONS.include?(action)
94
+ "Only the owner can #{ACTION_PHRASE[action]} this template."
95
+ elsif action == :read
96
+ "You need shared access to #{ACTION_PHRASE[action]} this template."
97
+ else
98
+ "You need edit access to #{ACTION_PHRASE[action]} this template."
99
+ end
100
+ "This template is maintained by #{name}. #{requirement}"
101
+ end
102
+
20
103
  def fetch_klass(name, id)
21
104
  klz = "Labimotion::#{name}".constantize.find_by(id: id)
22
105
  error!("#{name.gsub(/(Klass)/, '')} is invalid. Please re-select.", 500) if klz.nil?
@@ -26,8 +109,71 @@ module Labimotion
26
109
  raise e
27
110
  end
28
111
 
112
+ # Per-user AI overrides (model + API key) resolved from the host app's user
113
+ # profile. Returns {} when the host doesn't expose them, so AiTemplate falls
114
+ # back to the server-wide yml/ENV configuration. Guarded with respond_to? to
115
+ # keep the gem decoupled from the host's Profile implementation. Shared by the
116
+ # dataset / element / segment AI helpers.
117
+ def ai_user_overrides(current_user)
118
+ profile = current_user.respond_to?(:profile) ? current_user.profile : nil
119
+ return {} if profile.nil?
120
+
121
+ model = (profile.labimotion_ai_model if profile.respond_to?(:labimotion_ai_model))
122
+ api_key = (profile.labimotion_ai_api_key if profile.respond_to?(:labimotion_ai_api_key))
123
+ model = ai_shared_key_model(model) if api_key.blank?
124
+
125
+ { model: model, api_key: api_key }.merge(ai_provider_overrides(profile, api_key)).compact
126
+ rescue StandardError => e
127
+ Labimotion.log_exception(e, current_user)
128
+ {}
129
+ end
130
+
131
+ # Personal provider endpoint (base_url/api_path) — honored only ALONGSIDE a
132
+ # personal key, so the shared server key is never sent to a user-supplied URL.
133
+ # The base_url itself is SSRF-validated later, in AiTemplate.
134
+ def ai_provider_overrides(profile, api_key)
135
+ return {} if api_key.blank?
136
+
137
+ {
138
+ base_url: (profile.labimotion_ai_base_url if profile.respond_to?(:labimotion_ai_base_url)),
139
+ api_path: (profile.labimotion_ai_api_path if profile.respond_to?(:labimotion_ai_api_path))
140
+ }
141
+ end
142
+
143
+ # On the SHARED server key (user has no personal key), keep the user's model
144
+ # only if the admin allowed it; otherwise return nil so AiTemplate falls back
145
+ # to the server default. Returns the model unchanged when no allowlist is
146
+ # configured (fail open). Guards an off-list model being billed to the shared key.
147
+ def ai_shared_key_model(model)
148
+ return model if model.blank?
149
+
150
+ allow = ai_server_model_allowlist
151
+ allow.any? && !allow.include?(model.to_s) ? nil : model
152
+ end
153
+
154
+ # Model ids the server permits on the SHARED key: the admin pick-list
155
+ # (:models) plus the server default (:model / KI_TOOLBOX_MODEL), read from the
156
+ # host config the same way Labimotion::AiTemplate does. Empty when the admin
157
+ # configured neither — the caller then skips the clamp (fail open), matching
158
+ # the gem's decoupled, non-blocking handling of overrides.
159
+ def ai_server_model_allowlist
160
+ cfg = Rails.configuration.labimotion_ai || {}
161
+ list = cfg[:models].is_a?(Array) ? cfg[:models] : []
162
+ ids = list.map { |entry| ai_model_entry_id(entry) }
163
+ (ids << cfg[:model].to_s << ENV['KI_TOOLBOX_MODEL'].to_s).reject(&:blank?).uniq
164
+ rescue StandardError
165
+ []
166
+ end
167
+
168
+ def ai_model_entry_id(entry)
169
+ return entry.to_s unless entry.is_a?(Hash)
170
+
171
+ (entry[:id] || entry['id']).to_s
172
+ end
173
+
29
174
  def deactivate_klass(params)
30
175
  klz = fetch_klass(params[:klass], params[:id])
176
+ authorize_klass!(klz, :deactivate)
31
177
  klz&.update!(is_active: params[:is_active])
32
178
  generate_klass_file unless klz.class.name != 'Labimotion::ElementKlass'
33
179
  klz
@@ -38,6 +184,7 @@ module Labimotion
38
184
 
39
185
  def update_klass_settings(params)
40
186
  klz = fetch_klass(params[:klass], params[:id])
187
+ authorize_klass!(klz, :write)
41
188
  klz&.update!(settings: (klz.settings || {}).merge(params[:settings] || {}))
42
189
  klz
43
190
  rescue StandardError => e
@@ -46,8 +193,8 @@ module Labimotion
46
193
  end
47
194
 
48
195
  def delete_klass(params)
49
- authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
50
196
  klz = fetch_klass(params[:klass], params[:id])
197
+ authorize_klass!(klz, :destroy)
51
198
  klz&.destroy!
52
199
  generate_klass_file unless klz.class.name != 'Labimotion::ElementKlass'
53
200
  status 201
@@ -58,6 +205,11 @@ module Labimotion
58
205
 
59
206
  def update_template(params, current_user)
60
207
  klz = fetch_klass(params[:klass], params[:id])
208
+ # The split that makes `editor` mean "drafting only": a draft save needs :write, while
209
+ # anything else bumps the version and cuts a revision — publishing, which is :release.
210
+ # Authorization before the stale check: who may act comes before whether the copy is fresh.
211
+ authorize_klass!(klz, params[:release] == 'draft' ? :write : :release)
212
+ guard_stale_template!(klz, params[:properties_template])
61
213
  uuid = SecureRandom.uuid
62
214
  properties = params[:properties_template]
63
215
  properties['uuid'] = uuid
@@ -79,12 +231,133 @@ module Labimotion
79
231
  raise e
80
232
  end
81
233
 
234
+ # A template save replaces `properties_template` wholesale, so two designers holding the
235
+ # same template open used to overwrite each other with no warning and nothing to recover
236
+ # from: drafts are never snapshotted (`Utils.next_version` is a no-op for 'draft' and
237
+ # `create_klasses_revision` is skipped), and making drafts cut revisions is not an option
238
+ # because cutting a revision writes `properties_release` — it publishes.
239
+ #
240
+ # `update_template` already mints a fresh uuid into the template on every save, so the
241
+ # stored uuid is a ready-made optimistic-locking token: the client posts back the uuid it
242
+ # was handed, and a difference means somebody saved in between.
243
+ #
244
+ # Blank on either side means we cannot tell — a template stored before this guard existed,
245
+ # or a client that dropped the field — and a template with no stored uuid must not start
246
+ # refusing saves.
247
+ def guard_stale_template!(klz, properties)
248
+ held = klz.properties_template.is_a?(Hash) ? klz.properties_template['uuid'] : nil
249
+ sent = properties.is_a?(Hash) ? properties['uuid'] : nil
250
+ return if held.blank? || sent.blank? || held == sent
251
+
252
+ error!(stale_template_error(klz), 409)
253
+ end
254
+
255
+ # Naming the other designer is what turns a refusal into something a user can act on, but
256
+ # it is decoration: `OwnerResolver.find` returns nil rather than raising, so a lookup that
257
+ # fails costs the name and leaves the refusal itself intact.
258
+ def stale_template_error(klz)
259
+ saver = Labimotion::OwnerResolver.find(klz.updated_by)
260
+ name = saver.respond_to?(:name) ? saver.name : nil
261
+ name = nil if name.blank?
262
+ { mc: STALE_TEMPLATE_MC,
263
+ msg: stale_template_msg(name, klz.updated_at),
264
+ saved_by: { id: klz.updated_by, name: name },
265
+ saved_at: klz.updated_at.blank? ? nil : klz.updated_at.iso8601 }
266
+ end
267
+
268
+ def stale_template_msg(name, saved_at)
269
+ who = name.blank? ? 'Another user' : name
270
+ moment = saved_at.blank? ? 'after you opened it' : "at #{saved_at.strftime('%Y-%m-%d %H:%M %Z')}"
271
+ "#{who} saved this template #{moment}, so your copy is out of date; " \
272
+ 'reload the template and re-apply your changes.'
273
+ end
274
+
275
+ # `delete_klass` soft-deletes (the klasses are `acts_as_paranoid`), but nothing could list
276
+ # or restore the result, so recovery meant direct database access.
277
+ def deleted_klasses(params)
278
+ "Labimotion::#{params[:klass]}".constantize.only_deleted.order(deleted_at: :desc)
279
+ rescue StandardError => e
280
+ Labimotion.log_exception(e, current_user)
281
+ raise e
282
+ end
283
+
284
+ def restore_klass(params)
285
+ klz = fetch_deleted_klass(params[:klass], params[:id])
286
+ # Restore is delete's inverse and carries the same cascade into elements, so it takes
287
+ # the same :destroy level. Shares survive deletion, so the owner row is still there to
288
+ # answer this.
289
+ authorize_klass!(klz, :destroy)
290
+ guard_restore_conflict!(params[:klass], klz)
291
+ # `recursive` is what brings the template's elements/segments back with it; without it
292
+ # paranoia restores the klass row alone and every child stays deleted. The window keeps
293
+ # that cascade to the children deleted *with* the template.
294
+ klz.restore!(recursive: true, recovery_window: RESTORE_RECOVERY_WINDOW)
295
+ generate_klass_file if params[:klass] == 'ElementKlass'
296
+ klz.reload
297
+ rescue StandardError => e
298
+ Labimotion.log_exception(e, current_user)
299
+ raise e
300
+ end
301
+
302
+ def fetch_deleted_klass(name, id)
303
+ klz = "Labimotion::#{name}".constantize.only_deleted.find_by(id: id)
304
+ error!("#{name.gsub(/(Klass)/, '')} is not in the deleted list. Please refresh.", 404) if klz.nil?
305
+ klz
306
+ end
307
+
308
+ # `restore!` writes through `update_columns`, so the uniqueness validations do not run, and
309
+ # no DB unique index backs them either. Restoring onto a taken identity would leave two
310
+ # active rows with the same one, and the ten `find_by(name:)` call sites in the gem — two of
311
+ # which pick the `properties_release` that renders real elements — would then resolve it
312
+ # arbitrarily. Refuse instead, and let a human decide which row keeps the name.
313
+ def guard_restore_conflict!(name, klz)
314
+ keys = KLASS_IDENTITY[name]
315
+ other = conflicting_active_klass(name, keys, klz)
316
+ return if other.nil?
317
+
318
+ error!({ mc: RESTORE_CONFLICT_MC,
319
+ msg: restore_conflict_msg(name, other, keys),
320
+ blocked_by: { id: other.id, label: klass_label(other, keys) } }, 409)
321
+ end
322
+
323
+ def conflicting_active_klass(name, keys, klz)
324
+ return nil if keys.nil?
325
+
326
+ identity = keys.to_h { |key| [key, klz.public_send(key)] }
327
+ return nil if identity[keys.first].blank?
328
+
329
+ # The default scope of a paranoid model excludes deleted rows, so this can only match an
330
+ # active one — never the row being restored.
331
+ "Labimotion::#{name}".constantize.where(identity).first
332
+ end
333
+
334
+ def restore_conflict_msg(name, other, keys)
335
+ "Cannot restore: the active #{name.gsub(/(Klass)/, '')} \"#{klass_label(other, keys)}\" " \
336
+ "(id #{other.id}) already uses this #{KLASS_IDENTITY_NOUN[name]}."
337
+ end
338
+
339
+ def klass_label(klz, keys)
340
+ label = klz.respond_to?(:label) ? klz.label : nil
341
+ label.blank? ? klz.public_send(keys.first).to_s : label
342
+ end
343
+
344
+ # The revision must belong to the klass the caller named: the klass row is what
345
+ # authorize_klass! (and the active-revision guard) runs on, so accepting a mismatched
346
+ # pair would let ownership of one template authorize deleting — even the active —
347
+ # revisions of another.
348
+ def revision_of_klass?(revision, klass, klass_name)
349
+ foreign_key = "#{klass_name.sub('Klass', '_klass').downcase}_id"
350
+ !klass.nil? && revision.public_send(foreign_key) == klass.id
351
+ end
352
+
82
353
  def delete_klass_revision(params)
83
354
  revision = "Labimotion::#{params[:klass]}esRevision".constantize.find(params[:id])
84
355
  klass = "Labimotion::#{params[:klass]}".constantize.find_by(id: params[:klass_id]) unless revision.nil?
85
356
  error!('Revision is invalid.', 404) if revision.nil?
357
+ error!('Revision is invalid.', 404) unless revision_of_klass?(revision, klass, params[:klass])
358
+ authorize_klass!(klass, :destroy)
86
359
  error!('Can not delete the active revision.', 405) if revision.uuid == klass.uuid
87
- revision&.destroy!
360
+ revision.destroy!
88
361
  rescue StandardError => e
89
362
  Labimotion.log_exception(e, current_user)
90
363
  raise e
@@ -110,7 +383,6 @@ module Labimotion
110
383
  raise e
111
384
  end
112
385
 
113
-
114
386
  ###############
115
387
  def generate_klass_file
116
388
  klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
@@ -35,6 +35,74 @@ module Labimotion
35
35
  optional :properties_template, type: Hash, desc: 'Element Klass properties template'
36
36
  end
37
37
 
38
+ params :create_ai_dataset_klass_params do
39
+ requires :ols_term_id, type: String, desc: 'CHMO ontology term (e.g. "CHMO:0000470 | mass spectrometry (MS)")'
40
+ optional :desc, type: String, desc: 'Description / intent of the new dataset template'
41
+ optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Default columns per row for generated layers'
42
+ optional :references, type: Array[String], desc: 'Reference links (publications / standards)'
43
+ optional :files, type: Array, desc: 'Reference files for context' do
44
+ optional :filename, type: String, desc: 'File name'
45
+ optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
46
+ optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
47
+ end
48
+ end
49
+
50
+ params :create_ai_element_klass_params do
51
+ requires :name, type: String, desc: 'Element Klass Name'
52
+ requires :label, type: String, desc: 'Element Klass Label'
53
+ requires :klass_prefix, type: String, desc: 'Element Klass Short Label Prefix'
54
+ optional :icon_name, type: String, desc: 'Element Klass Icon Name'
55
+ optional :desc, type: String, desc: 'Description / intent of the new element template'
56
+ optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Default columns per row for generated layers'
57
+ optional :references, type: Array[String], desc: 'Reference links (publications / standards)'
58
+ optional :files, type: Array, desc: 'Reference files for context' do
59
+ optional :filename, type: String, desc: 'File name'
60
+ optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
61
+ optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
62
+ end
63
+ end
64
+
65
+ # AI auto-fill of a generic element instance's DATA VALUES from a document.
66
+ # `files` mirrors the create_ai_*_klass_params file block (source == 'upload').
67
+ params :ai_fill_element_data_params do
68
+ requires :element_id, type: Integer, desc: 'Generic element id whose data values to fill'
69
+ requires :source, type: String, values: %w[attachment analysis upload], desc: 'Value source'
70
+ optional :attachment_id, type: Integer, desc: 'Attachment id (required when source == attachment)'
71
+ optional :container_id, type: Integer, desc: 'Analysis container id (required when source == analysis)'
72
+ optional :files, type: Array, desc: 'Uploaded files (source == upload)' do
73
+ optional :filename, type: String, desc: 'File name'
74
+ optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
75
+ optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
76
+ end
77
+ end
78
+
79
+ params :create_ai_segment_klass_params do
80
+ requires :label, type: String, desc: 'Segment Klass Label'
81
+ requires :element_klass, type: Integer, desc: 'Parent Element Klass Id'
82
+ optional :metadata, type: Hash, desc: 'Klass metadata (segment render kind, e.g. { is_properties })'
83
+ optional :desc, type: String, desc: 'Description / intent of the new segment template'
84
+ optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Default columns per row for generated layers'
85
+ optional :references, type: Array[String], desc: 'Reference links (publications / standards)'
86
+ optional :files, type: Array, desc: 'Reference files for context' do
87
+ optional :filename, type: String, desc: 'File name'
88
+ optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
89
+ optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
90
+ end
91
+ end
92
+
93
+ params :refine_ai_dataset_klass_params do
94
+ requires :instruction, type: String, desc: 'Natural-language change to apply to the template'
95
+ optional :label, type: String, desc: 'Current template label'
96
+ optional :ols_term_id, type: String, desc: 'CHMO ontology term (context only)'
97
+ optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Fallback columns per row when a layer omits its own'
98
+ optional :layers, type: Hash, desc: 'Current template layers'
99
+ optional :select_options, type: Hash, desc: 'Current template select options'
100
+ optional :history, type: Array, desc: 'Prior chat turns for continuity' do
101
+ optional :role, type: String, desc: 'user | assistant'
102
+ optional :content, type: String, desc: 'Message content'
103
+ end
104
+ end
105
+
38
106
  params :update_element_klass_params do
39
107
  requires :id, type: Integer, desc: 'Element Klass ID'
40
108
  optional :label, type: String, desc: 'Element Klass Label'