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
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
require 'grape'
|
|
4
4
|
require 'labimotion/conf'
|
|
5
5
|
require 'labimotion/utils/utils'
|
|
6
|
+
require 'labimotion/helpers/cover_image_helpers'
|
|
6
7
|
# require 'labimotion/models/element_klass'
|
|
7
8
|
module Labimotion
|
|
8
9
|
## ElementHelpers
|
|
9
10
|
module ElementHelpers
|
|
10
11
|
extend Grape::API::Helpers
|
|
12
|
+
# the element save validates metadata.cover_images up front through
|
|
13
|
+
# prepare_element_metadata — carry the dependency instead of relying on
|
|
14
|
+
# both modules being mounted on the same Grape endpoint
|
|
15
|
+
include Labimotion::CoverImageHelpers
|
|
11
16
|
|
|
12
17
|
def klass_list(is_generic_only)
|
|
13
18
|
if is_generic_only == true
|
|
@@ -31,7 +36,13 @@ module Labimotion
|
|
|
31
36
|
attributes[:properties_release] = attributes[:properties_template]
|
|
32
37
|
attributes[:created_by] = current_user.id
|
|
33
38
|
|
|
34
|
-
|
|
39
|
+
# The owner row shares the create's transaction: a klass committed without it is
|
|
40
|
+
# owner-less, which falls open to the legacy designer-wide gate.
|
|
41
|
+
new_klass = Labimotion::ElementKlass.transaction do
|
|
42
|
+
Labimotion::ElementKlass.create!(attributes).tap do |klz|
|
|
43
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
35
46
|
new_klass.reload
|
|
36
47
|
new_klass.create_klasses_revision(current_user)
|
|
37
48
|
klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
|
|
@@ -43,6 +54,249 @@ module Labimotion
|
|
|
43
54
|
raise e
|
|
44
55
|
end
|
|
45
56
|
|
|
57
|
+
# Create a new (inactive) element klass whose properties template is
|
|
58
|
+
# generated by an LLM from a user-provided name/label plus optional
|
|
59
|
+
# description, reference links and uploaded files. The admin reviews/edits
|
|
60
|
+
# the generated template in the designer and activates it (human-in-the-loop).
|
|
61
|
+
def create_ai_element_klass(params, current_user)
|
|
62
|
+
name = params[:name]
|
|
63
|
+
label = params[:label]
|
|
64
|
+
if Labimotion::ElementKlass.find_by(name: name).present?
|
|
65
|
+
return { status: 'error', message: "An element named [#{name}] already exists." }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
|
|
69
|
+
return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
overrides = ai_user_overrides(current_user)
|
|
73
|
+
ai = Labimotion::AiTemplate.generate(
|
|
74
|
+
kind: 'element',
|
|
75
|
+
subject: "#{params[:label]} (#{params[:name]})",
|
|
76
|
+
desc: params[:desc],
|
|
77
|
+
cols: params[:cols],
|
|
78
|
+
references: params[:references],
|
|
79
|
+
files: params[:files],
|
|
80
|
+
**overrides
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
uuid = SecureRandom.uuid
|
|
84
|
+
# property-base schema requires pkg, uuid, klass, layers, version, identifier.
|
|
85
|
+
properties_template = {
|
|
86
|
+
'uuid' => uuid,
|
|
87
|
+
'klass' => 'ElementKlass',
|
|
88
|
+
'pkg' => Labimotion::Utils.pkg(nil),
|
|
89
|
+
'version' => '1.0.0',
|
|
90
|
+
'identifier' => uuid,
|
|
91
|
+
'layers' => ai['layers'],
|
|
92
|
+
'select_options' => ai['select_options'],
|
|
93
|
+
'metadata' => ai['metadata']
|
|
94
|
+
}
|
|
95
|
+
attributes = {
|
|
96
|
+
'name' => name,
|
|
97
|
+
'label' => label,
|
|
98
|
+
'klass_prefix' => params[:klass_prefix],
|
|
99
|
+
'icon_name' => params[:icon_name],
|
|
100
|
+
'desc' => params[:desc].presence || ai['label'].presence,
|
|
101
|
+
'is_active' => false,
|
|
102
|
+
'uuid' => uuid,
|
|
103
|
+
'released_at' => DateTime.now,
|
|
104
|
+
'properties_template' => properties_template,
|
|
105
|
+
'properties_release' => properties_template,
|
|
106
|
+
'created_by' => current_user.id
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# Same as the non-AI create beside it: the owner row shares the create's
|
|
110
|
+
# transaction, because a klass committed without one is owner-less and
|
|
111
|
+
# falls OPEN to the legacy designer-wide gate — every designer of the
|
|
112
|
+
# family could then edit, release and delete it, while its creator holds
|
|
113
|
+
# no special standing at all. seed_owner! rescues only RecordNotUnique and
|
|
114
|
+
# RecordInvalid, so any other failure has to take the klass down with it;
|
|
115
|
+
# this helper turns every StandardError into { status: 'error' }, and
|
|
116
|
+
# without the transaction the caller would be told the create failed while
|
|
117
|
+
# an unowned template silently persisted.
|
|
118
|
+
new_klass = Labimotion::ElementKlass.transaction do
|
|
119
|
+
Labimotion::ElementKlass.create!(attributes).tap do |klz|
|
|
120
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
new_klass.reload
|
|
124
|
+
new_klass.create_klasses_revision(current_user)
|
|
125
|
+
klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
|
|
126
|
+
klasses = Labimotion::ElementKlass.where(is_active: true)&.pluck(:name) || []
|
|
127
|
+
File.write(klass_names_file, klasses)
|
|
128
|
+
# `record` is the row itself. The background job that calls this needs the
|
|
129
|
+
# template, not a sentence about it, to link the notification back to it.
|
|
130
|
+
{ status: 'success', record: new_klass,
|
|
131
|
+
message: "The AI element template [#{label}] has been created as inactive. Review and activate it in the designer." }
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
Labimotion.log_exception(e, current_user)
|
|
134
|
+
{ status: 'error', message: e.message }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# AI auto-fill of a generic element instance's DATA VALUES from a document
|
|
138
|
+
# (an existing attachment, an analysis, or freshly uploaded files). Reads the
|
|
139
|
+
# source text, asks the LLM to extract values for the element's OWN template
|
|
140
|
+
# fields, validates them, and returns them for the client to merge into the
|
|
141
|
+
# working copy for human review. Nothing is persisted here.
|
|
142
|
+
#
|
|
143
|
+
# Authorization: the caller must be able to READ the element, and the source
|
|
144
|
+
# must BELONG to that element (see the ownership checks below) — this endpoint
|
|
145
|
+
# reads files by id, so it must never read an Attachment/Container the user
|
|
146
|
+
# does not already have access to through this element.
|
|
147
|
+
def ai_fill_element_data(params, current_user)
|
|
148
|
+
element = Labimotion::Element.find(params[:element_id])
|
|
149
|
+
error!('401 Unauthorized', 401) unless ElementPolicy.new(current_user, element).read?
|
|
150
|
+
|
|
151
|
+
text = ai_fill_source_text(element, params)
|
|
152
|
+
overrides = ai_user_overrides(current_user)
|
|
153
|
+
ai = Labimotion::AiTemplate.fill(
|
|
154
|
+
properties: ai_fill_properties(element),
|
|
155
|
+
context_text: text,
|
|
156
|
+
instructions: ai_fill_instructions(element),
|
|
157
|
+
**overrides
|
|
158
|
+
)
|
|
159
|
+
{ status: 'success', values: ai['values'], summary: ai['summary'], usage: ai['usage'], model: ai['model'] }
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
Labimotion.log_exception(e, current_user)
|
|
162
|
+
{ status: 'error', message: e.message }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The element's properties, with the select_options its own fields point at.
|
|
166
|
+
#
|
|
167
|
+
# An element instance never stores select_options: create and update both
|
|
168
|
+
# delete the key (see create_element/update_element below), because the
|
|
169
|
+
# options belong to the template, not to the instance. The fill prompt lists
|
|
170
|
+
# a select's allowed options so the model can pick one, and reads them out
|
|
171
|
+
# of the properties it is handed — so passing element.properties straight
|
|
172
|
+
# through offered the model an EMPTY list for every select and select-multi
|
|
173
|
+
# field. Nothing to choose from, so those fields came back unfilled, which
|
|
174
|
+
# reads as the AI having ignored them.
|
|
175
|
+
#
|
|
176
|
+
# properties_release, not the klass's current properties_template: it is the
|
|
177
|
+
# release this element was actually built against, so its option keys are the
|
|
178
|
+
# ones the element's fields reference and the ones the form will accept back.
|
|
179
|
+
# Same source the UI and the exporter read for this element.
|
|
180
|
+
def ai_fill_properties(element)
|
|
181
|
+
props = element.properties
|
|
182
|
+
return props unless props.is_a?(Hash)
|
|
183
|
+
return props if props['select_options'].present?
|
|
184
|
+
|
|
185
|
+
release = element.properties_release
|
|
186
|
+
options = release.is_a?(Hash) ? release['select_options'] : nil
|
|
187
|
+
return props if options.blank?
|
|
188
|
+
|
|
189
|
+
props.merge('select_options' => options)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The template's own auto-fill guidance, from Template settings in the designer.
|
|
193
|
+
# Read from the KLASS rather than the element's copied properties, so editing it
|
|
194
|
+
# takes effect on elements that already exist instead of only on new ones.
|
|
195
|
+
def ai_fill_instructions(element)
|
|
196
|
+
settings = element.element_klass&.settings
|
|
197
|
+
return nil unless settings.is_a?(Hash)
|
|
198
|
+
|
|
199
|
+
settings[Labimotion::Constants::KlassSetting::AI_FILL_PROMPT].presence
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Resolve the requested source into plain text, enforcing ownership first.
|
|
203
|
+
def ai_fill_source_text(element, params)
|
|
204
|
+
case params[:source]
|
|
205
|
+
when 'attachment'
|
|
206
|
+
ai_fill_attachment_text(element, params[:attachment_id])
|
|
207
|
+
when 'analysis'
|
|
208
|
+
ai_fill_analysis_text(element, params[:container_id])
|
|
209
|
+
when 'upload'
|
|
210
|
+
ai_fill_upload_text(params[:files])
|
|
211
|
+
when 'text'
|
|
212
|
+
ai_fill_pasted_text(params[:text])
|
|
213
|
+
else
|
|
214
|
+
error!('400 Bad Request', 400)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The attachment must be one of the element's own attachments OR an attachment
|
|
219
|
+
# of one of the element's analysis DATASET containers. Only then is it read.
|
|
220
|
+
def ai_fill_attachment_text(element, attachment_id)
|
|
221
|
+
error!('404 Not Found', 404) if attachment_id.nil?
|
|
222
|
+
error!('404 Not Found', 404) unless ai_fill_element_attachment_ids(element).include?(attachment_id.to_i)
|
|
223
|
+
|
|
224
|
+
att = Attachment.find(attachment_id)
|
|
225
|
+
Labimotion::FileExtractor.extract_bytes(att.filename, att.read_file)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The container id must be one of the element's own analysis containers. Then
|
|
229
|
+
# concatenate the extracted text of every attachment on that analysis's dataset
|
|
230
|
+
# children, each prefixed "### <filename>", with the analysis name/description
|
|
231
|
+
# prepended as context. Cap at AiTemplate::MAX_FILES; skip unreadable files.
|
|
232
|
+
def ai_fill_analysis_text(element, container_id)
|
|
233
|
+
error!('404 Not Found', 404) if container_id.nil?
|
|
234
|
+
analysis = element.analyses.detect { |c| c.id == container_id.to_i }
|
|
235
|
+
error!('404 Not Found', 404) if analysis.nil?
|
|
236
|
+
|
|
237
|
+
parts = []
|
|
238
|
+
context = [analysis.name.presence, analysis.description.presence].compact.join(' — ')
|
|
239
|
+
parts << "Analysis: #{context}" if context.present?
|
|
240
|
+
|
|
241
|
+
ai_fill_analysis_attachments(analysis).first(Labimotion::AiTemplate::MAX_FILES).each do |att|
|
|
242
|
+
content = ai_fill_extract_attachment(att)
|
|
243
|
+
next if content.to_s.strip.empty?
|
|
244
|
+
|
|
245
|
+
parts << "### #{att.filename}\n#{content}"
|
|
246
|
+
end
|
|
247
|
+
parts.join("\n\n")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def ai_fill_upload_text(files)
|
|
251
|
+
parts = Array(files).first(Labimotion::AiTemplate::MAX_FILES).map do |file|
|
|
252
|
+
name = file[:filename] || file['filename']
|
|
253
|
+
next if name.blank?
|
|
254
|
+
|
|
255
|
+
content = Labimotion::FileExtractor.extract(name, file[:content_base64] || file['content_base64'])
|
|
256
|
+
next if content.to_s.strip.empty?
|
|
257
|
+
|
|
258
|
+
"### #{name}\n#{content}"
|
|
259
|
+
end.compact
|
|
260
|
+
parts.join("\n\n")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Text pasted straight into the dialog. Capped like an extracted file so a
|
|
264
|
+
# very long paste cannot blow the model's context or the request budget —
|
|
265
|
+
# the same MAX_FILE_CHARS the file path is held to.
|
|
266
|
+
def ai_fill_pasted_text(text)
|
|
267
|
+
body = text.to_s.strip
|
|
268
|
+
error!('400 Bad Request', 400) if body.empty?
|
|
269
|
+
|
|
270
|
+
body[0, Labimotion::AiTemplate::MAX_FILE_CHARS].to_s
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Ids of attachments this element may expose to the AI fill: its own
|
|
274
|
+
# attachments plus the attachments of its analysis dataset containers.
|
|
275
|
+
def ai_fill_element_attachment_ids(element)
|
|
276
|
+
own = element.attachments.pluck(:id)
|
|
277
|
+
dataset_ids = ai_fill_dataset_containers(element).map(&:id)
|
|
278
|
+
dataset_att = dataset_ids.empty? ? [] : Attachment.where(attachable_type: 'Container', attachable_id: dataset_ids).pluck(:id)
|
|
279
|
+
(own + dataset_att).uniq
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Dataset containers under all of the element's analysis containers.
|
|
283
|
+
def ai_fill_dataset_containers(element)
|
|
284
|
+
element.analyses.flat_map { |analysis| analysis.children.where(container_type: 'dataset').to_a }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def ai_fill_analysis_attachments(analysis)
|
|
288
|
+
analysis.children.where(container_type: 'dataset').flat_map do |dataset|
|
|
289
|
+
Attachment.where(attachable_type: 'Container', attachable_id: dataset.id).to_a
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def ai_fill_extract_attachment(att)
|
|
294
|
+
Labimotion::FileExtractor.extract_bytes(att.filename, att.read_file)
|
|
295
|
+
rescue StandardError => e
|
|
296
|
+
Labimotion.log_exception(e)
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
299
|
+
|
|
46
300
|
def update_element_klass(current_user, params)
|
|
47
301
|
place = params[:place] || 100
|
|
48
302
|
begin
|
|
@@ -51,6 +305,7 @@ module Labimotion
|
|
|
51
305
|
place = 100
|
|
52
306
|
end
|
|
53
307
|
klass = Labimotion::ElementKlass.find(params[:id])
|
|
308
|
+
authorize_klass!(klass, :write)
|
|
54
309
|
klass.label = params[:label] if params[:label].present?
|
|
55
310
|
klass.klass_prefix = params[:klass_prefix] if params[:klass_prefix].present?
|
|
56
311
|
klass.icon_name = params[:icon_name] if params[:icon_name].present?
|
|
@@ -82,7 +337,7 @@ module Labimotion
|
|
|
82
337
|
klass_uuid: klass[:uuid],
|
|
83
338
|
properties: properties,
|
|
84
339
|
properties_release: params[:properties_release],
|
|
85
|
-
metadata: params[:metadata]
|
|
340
|
+
metadata: prepare_element_metadata({}, params[:metadata]),
|
|
86
341
|
created_by: current_user.id
|
|
87
342
|
}
|
|
88
343
|
element = Labimotion::Element.new(attributes)
|
|
@@ -110,6 +365,11 @@ module Labimotion
|
|
|
110
365
|
|
|
111
366
|
def update_element_by_id(current_user, params)
|
|
112
367
|
element = Labimotion::Element.find(params[:id])
|
|
368
|
+
# Validated up front, before the container/sample-association writes run:
|
|
369
|
+
# a rejected cover selection must fail the request whole, not leave a
|
|
370
|
+
# half-applied datamodel behind. error! throws rather than raises, so it
|
|
371
|
+
# escapes this method's rescue-and-log and reaches the client as a 422.
|
|
372
|
+
metadata = prepare_element_metadata(element.metadata, params[:metadata])
|
|
113
373
|
update_datamodel(params[:container], current_user)
|
|
114
374
|
properties = update_sample_association(params[:properties], current_user, element)
|
|
115
375
|
params.delete(:container)
|
|
@@ -118,7 +378,6 @@ module Labimotion
|
|
|
118
378
|
params.delete(:user_labels)
|
|
119
379
|
attributes = declared(params.except(:segments), include_missing: false)
|
|
120
380
|
properties['pkg'] = Labimotion::Utils.pkg(properties['pkg'])
|
|
121
|
-
metadata = params[:metadata] || element.metadata || {}
|
|
122
381
|
if element.klass_uuid != properties['klass_uuid'] || element.properties != properties || element.name != params[:name] || element.metadata != metadata
|
|
123
382
|
properties['klass'] = 'Element'
|
|
124
383
|
uuid = SecureRandom.uuid
|
|
@@ -305,12 +564,25 @@ module Labimotion
|
|
|
305
564
|
end
|
|
306
565
|
|
|
307
566
|
def validate_klass(attributes)
|
|
308
|
-
|
|
567
|
+
# Guarded on presence like the segment path: a blank identifier must mean "create",
|
|
568
|
+
# never `find_by(identifier: nil)` — that would match an arbitrary legacy row whose
|
|
569
|
+
# identifier is NULL and upgrade an unrelated template.
|
|
570
|
+
element_klass = Labimotion::ElementKlass.find_by(identifier: attributes['identifier']) if attributes['identifier'].present?
|
|
309
571
|
if element_klass.present?
|
|
310
572
|
if element_klass['uuid'] == attributes['uuid'] && element_klass['version'] == attributes['version']
|
|
311
573
|
return { status: 'success', message: "This element: #{attributes['name']} has the latest version!" }
|
|
312
574
|
else
|
|
313
|
-
|
|
575
|
+
# Upgrading an existing template bumps its version and cuts a revision — publishing,
|
|
576
|
+
# which is what :release means, so it is owner-only. The gate has to live here, not
|
|
577
|
+
# in after_validation: which branch applies is unknown until this identifier lookup
|
|
578
|
+
# has run. error! throws rather than raises, so it escapes this method's rescue.
|
|
579
|
+
authorize_klass!(element_klass, :release)
|
|
580
|
+
# And it must not reassign authorship. `upload_klass` merges
|
|
581
|
+
# `created_by: current_user.id` into the attributes unconditionally
|
|
582
|
+
# (generic_element_api.rb), so without this the importer silently becomes the author
|
|
583
|
+
# of a template somebody else wrote — and that column is what the Owner column shows
|
|
584
|
+
# and what per-template ownership is seeded from.
|
|
585
|
+
element_klass.update!(attributes.except('created_by', :created_by))
|
|
314
586
|
element_klass.create_klasses_revision(current_user)
|
|
315
587
|
return { status: 'success', message: "This element: [#{attributes['name']}] has been upgraded to the version: #{attributes['version']}!" }
|
|
316
588
|
end
|
|
@@ -320,7 +592,11 @@ module Labimotion
|
|
|
320
592
|
return { status: 'error', message: "The name [#{attributes['name']}] is already in use." }
|
|
321
593
|
else
|
|
322
594
|
attributes['created_by'] = current_user.id
|
|
323
|
-
element_klass = Labimotion::ElementKlass.
|
|
595
|
+
element_klass = Labimotion::ElementKlass.transaction do
|
|
596
|
+
Labimotion::ElementKlass.create!(attributes).tap do |klz|
|
|
597
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
598
|
+
end
|
|
599
|
+
end
|
|
324
600
|
element_klass.create_klasses_revision(current_user)
|
|
325
601
|
return { status: 'success', message: "The element: #{attributes['name']} has been created using version: #{attributes['version']}!" }
|
|
326
602
|
end
|
|
@@ -13,9 +13,13 @@ module Labimotion
|
|
|
13
13
|
col_name
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
# Check if field type should be expanded (DRAG_SAMPLE or
|
|
16
|
+
# Check if field type should be expanded (DRAG_SAMPLE, DRAG_MOLECULE or DRAG_ELEMENT)
|
|
17
17
|
def expandable_field?(field_type)
|
|
18
|
-
[
|
|
18
|
+
[
|
|
19
|
+
Labimotion::FieldType::DRAG_SAMPLE,
|
|
20
|
+
Labimotion::FieldType::DRAG_MOLECULE,
|
|
21
|
+
Labimotion::FieldType::DRAG_ELEMENT
|
|
22
|
+
].include?(field_type)
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
# Generate sanitized filename for export
|
|
@@ -34,6 +38,8 @@ module Labimotion
|
|
|
34
38
|
case sub_field['type']
|
|
35
39
|
when Labimotion::FieldType::DRAG_SAMPLE, Labimotion::FieldType::DRAG_MOLECULE
|
|
36
40
|
format_drag_field_cell(sub_val, sub_field)
|
|
41
|
+
when Labimotion::FieldType::DRAG_ELEMENT
|
|
42
|
+
format_element_cell(sub_val, sub_field)
|
|
37
43
|
when Labimotion::FieldType::SELECT
|
|
38
44
|
format_select_cell(sub_val, sub_field)
|
|
39
45
|
when Labimotion::FieldType::SYSTEM_DEFINED
|
|
@@ -95,6 +101,15 @@ module Labimotion
|
|
|
95
101
|
end
|
|
96
102
|
end
|
|
97
103
|
|
|
104
|
+
# Format drag_element cell — a linked element has no structure image,
|
|
105
|
+
# so the short label is the cell text
|
|
106
|
+
def format_element_cell(sub_val, sub_field)
|
|
107
|
+
val = sub_val[sub_field['id']]['value'] || {}
|
|
108
|
+
return '' if val.blank?
|
|
109
|
+
|
|
110
|
+
val['el_label'].present? ? val['el_label'].to_s : val['el_name'].to_s
|
|
111
|
+
end
|
|
112
|
+
|
|
98
113
|
# Format select cell
|
|
99
114
|
def format_select_cell(sub_val, sub_field)
|
|
100
115
|
sub_val[sub_field['id']]['value'].to_s
|