labimotion 2.3.0 → 2.4.0.rc11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -1
- data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
- data/lib/labimotion/apis/generic_element_api.rb +198 -8
- data/lib/labimotion/apis/generic_klass_api.rb +74 -8
- data/lib/labimotion/apis/klass_share_api.rb +648 -0
- data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
- data/lib/labimotion/apis/labimotion_api.rb +4 -0
- data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
- data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
- data/lib/labimotion/apis/ontology_root_api.rb +74 -0
- data/lib/labimotion/apis/segment_api.rb +77 -10
- data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
- data/lib/labimotion/conf.rb +5 -0
- data/lib/labimotion/constants.rb +64 -0
- data/lib/labimotion/entities/application_entity.rb +8 -0
- data/lib/labimotion/entities/eln_element_entity.rb +6 -0
- data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
- data/lib/labimotion/entities/klass_share_entity.rb +48 -0
- data/lib/labimotion/entities/properties_entity.rb +78 -2
- data/lib/labimotion/entities/segment_entity.rb +8 -0
- data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
- data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
- data/lib/labimotion/helpers/element_helpers.rb +282 -6
- data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
- data/lib/labimotion/helpers/generic_helpers.rb +293 -4
- data/lib/labimotion/helpers/param_helpers.rb +105 -0
- data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
- data/lib/labimotion/helpers/segment_helpers.rb +102 -4
- data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
- data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
- data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
- data/lib/labimotion/libs/ai_models.rb +201 -0
- data/lib/labimotion/libs/ai_template.rb +2285 -0
- data/lib/labimotion/libs/converter.rb +5 -43
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
- data/lib/labimotion/libs/export_element.rb +128 -13
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/libs/linked_element.rb +313 -0
- data/lib/labimotion/libs/ontology_store.rb +226 -0
- data/lib/labimotion/libs/ontology_terms.rb +227 -0
- data/lib/labimotion/libs/owner_resolver.rb +50 -0
- data/lib/labimotion/libs/ownership_audit.rb +73 -0
- data/lib/labimotion/libs/sample_association.rb +52 -1
- data/lib/labimotion/libs/share_notifier.rb +114 -0
- data/lib/labimotion/libs/share_resolver.rb +373 -0
- data/lib/labimotion/libs/user_ai_settings.rb +129 -0
- data/lib/labimotion/models/cellline.rb +47 -0
- data/lib/labimotion/models/concerns/datasetable.rb +3 -0
- data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
- data/lib/labimotion/models/concerns/segmentable.rb +2 -0
- data/lib/labimotion/models/concerns/template_doi.rb +133 -0
- data/lib/labimotion/models/dataset_klass.rb +1 -1
- data/lib/labimotion/models/element_klass.rb +1 -1
- data/lib/labimotion/models/klass_share.rb +129 -0
- data/lib/labimotion/models/segment_klass.rb +1 -1
- data/lib/labimotion/models/user_klass_setting.rb +58 -0
- data/lib/labimotion/models/user_setting.rb +60 -0
- data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
- data/lib/labimotion/usecases/release_template_doi.rb +42 -17
- data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
- data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
- data/lib/labimotion/utils/export_utils.rb +1 -0
- data/lib/labimotion/utils/import_utils.rb +20 -3
- data/lib/labimotion/utils/serializer.rb +27 -0
- data/lib/labimotion/utils/units.rb +32 -67
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +27 -0
- metadata +45 -3
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'labimotion/version'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
# Per-user, per-klass UI settings API (toolbar Overview/Arrange visibility, and
|
|
7
|
+
# the default inline attributes of a linked element).
|
|
8
|
+
# Every route is scoped to the authenticated user via created_by, so a user
|
|
9
|
+
# only ever reads/writes their own setting; no klass-edit permission needed.
|
|
10
|
+
class UserKlassSettingsAPI < Grape::API
|
|
11
|
+
helpers Labimotion::ParamHelpers
|
|
12
|
+
|
|
13
|
+
helpers do
|
|
14
|
+
def settings_scope
|
|
15
|
+
Labimotion::UserKlassSetting.where(created_by: current_user.id)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Only the sub-settings actually present in the request are written, so a
|
|
19
|
+
# save carrying just one of them leaves the other untouched. Absent reads
|
|
20
|
+
# as "leave alone"; an empty linked_el_attrs array reads as "clear".
|
|
21
|
+
def submitted_settings(settings)
|
|
22
|
+
settings = (settings || {}).transform_keys(&:to_s)
|
|
23
|
+
toolbar = settings[Labimotion::Constants::UserKlassSetting::TOOLBAR]
|
|
24
|
+
attrs = settings[Labimotion::Constants::UserKlassSetting::LINKED_EL_ATTRS]
|
|
25
|
+
{}.tap do |result|
|
|
26
|
+
result[Labimotion::Constants::UserKlassSetting::TOOLBAR] = toolbar if toolbar.is_a?(Hash)
|
|
27
|
+
if attrs.is_a?(Array)
|
|
28
|
+
result[Labimotion::Constants::UserKlassSetting::LINKED_EL_ATTRS] = normalize_linked_el_attrs(attrs)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Persist only the identity of each attribute. The values are resolved live
|
|
34
|
+
# on every load, and the label is refreshed from the template alongside them.
|
|
35
|
+
def normalize_linked_el_attrs(attrs)
|
|
36
|
+
attrs.filter_map do |attr|
|
|
37
|
+
attr = attr.transform_keys(&:to_s) if attr.respond_to?(:transform_keys)
|
|
38
|
+
next unless attr.is_a?(Hash) && attr['key'].present?
|
|
39
|
+
|
|
40
|
+
{ 'source' => attr['source'], 'key' => attr['key'], 'label' => attr['label'] }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
resource :user_klass_settings do
|
|
46
|
+
desc 'Fetch the current user UI setting for a klass'
|
|
47
|
+
params do
|
|
48
|
+
requires :klass, type: String, values: Labimotion::Constants::Klass::USER_SETTINGS, desc: 'Klass type'
|
|
49
|
+
requires :identifier, type: String, desc: 'Klass identifier'
|
|
50
|
+
end
|
|
51
|
+
get 'fetch' do
|
|
52
|
+
error!('401 Unauthorized', 401) unless current_user
|
|
53
|
+
|
|
54
|
+
record = settings_scope.find_by(klass: params[:klass], klass_identifier: params[:identifier])
|
|
55
|
+
data = record ? Labimotion::UserKlassSettingEntity.represent(record) : nil
|
|
56
|
+
{ mc: 'ss00', data: data }
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
Labimotion.log_exception(e, current_user)
|
|
59
|
+
{ mc: 'se00', msg: e.message, data: nil }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
desc 'Upsert the current user UI setting for a klass'
|
|
63
|
+
params do
|
|
64
|
+
use :user_klass_settings_save
|
|
65
|
+
end
|
|
66
|
+
post 'save' do
|
|
67
|
+
error!('401 Unauthorized', 401) unless current_user
|
|
68
|
+
|
|
69
|
+
record = settings_scope.find_or_initialize_by(
|
|
70
|
+
klass: params[:klass], klass_identifier: params[:klass_identifier]
|
|
71
|
+
)
|
|
72
|
+
record.klass_label = params[:klass_label] if params[:klass_label]
|
|
73
|
+
record.created_by ||= current_user.id
|
|
74
|
+
record.settings = (record.settings || {}).merge(submitted_settings(params[:settings]))
|
|
75
|
+
record.save!
|
|
76
|
+
{ mc: 'ss00', data: Labimotion::UserKlassSettingEntity.represent(record) }
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
Labimotion.log_exception(e, current_user)
|
|
79
|
+
{ mc: 'se00', msg: e.message, data: {} }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
desc 'List all UI settings of the current user'
|
|
83
|
+
get 'list' do
|
|
84
|
+
error!('401 Unauthorized', 401) unless current_user
|
|
85
|
+
|
|
86
|
+
{ mc: 'ss00', data: Labimotion::UserKlassSettingEntity.represent(settings_scope.to_a) }
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
Labimotion.log_exception(e, current_user)
|
|
89
|
+
{ mc: 'se00', msg: e.message, data: [] }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/labimotion/conf.rb
CHANGED
|
@@ -3,4 +3,9 @@
|
|
|
3
3
|
## Labimotion Configuration
|
|
4
4
|
module Labimotion
|
|
5
5
|
KLASSES_JSON = Rails.root.join('public', 'klasses.json').to_s.freeze # Rails.root.join('app/packs/klasses.json').to_s.freeze
|
|
6
|
+
|
|
7
|
+
# One JSON file per ontology root, holding the subtree an "ontology-select"
|
|
8
|
+
# field may offer. Sits under the host's public/ontologies, which the ELN
|
|
9
|
+
# already gitignores and already lets this gem write to (see KLASSES_JSON).
|
|
10
|
+
ONTOLOGY_ROOTS_DIR = Rails.public_path.join('ontologies', 'roots').to_s.freeze
|
|
6
11
|
end
|
data/lib/labimotion/constants.rb
CHANGED
|
@@ -17,6 +17,14 @@ module Labimotion
|
|
|
17
17
|
CV = 'Cyclic Voltammetry'
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# The element cover selection (labimotion#259): cap and source vocabulary
|
|
21
|
+
# of `metadata.cover_images` entries. Mirrored by the chem-generic-ui
|
|
22
|
+
# cover-image picker (chem-generic-ui#1050) — keep them in sync.
|
|
23
|
+
module CoverImage
|
|
24
|
+
SOURCES = %w[attachment analysis].freeze
|
|
25
|
+
MAX = 3
|
|
26
|
+
end
|
|
27
|
+
|
|
20
28
|
module Mapper
|
|
21
29
|
NMR_CONFIG = ::File.join(__dir__, 'libs', 'data', 'mapper', 'Source.json').freeze
|
|
22
30
|
WIKI_CONFIG = ::File.join(__dir__, 'libs', 'data', 'mapper', 'Chemwiki.json').freeze
|
|
@@ -27,6 +35,62 @@ module Labimotion
|
|
|
27
35
|
SEGMENT = 'SegmentKlass'
|
|
28
36
|
DATASET = 'DatasetKlass'
|
|
29
37
|
ALL = [ELEMENT, SEGMENT, DATASET].freeze
|
|
38
|
+
|
|
39
|
+
# Standard ELN elements (sample, reaction, molecule, ...) have no klass
|
|
40
|
+
# model. Per-user settings that key on one use this pseudo-type, with the
|
|
41
|
+
# permit-target key itself as the klass identifier — see
|
|
42
|
+
# Labimotion::UserKlassSetting.
|
|
43
|
+
PERMIT_TARGET = 'PermitTarget'
|
|
44
|
+
USER_SETTINGS = [ELEMENT, SEGMENT, DATASET, PERMIT_TARGET].freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The independent sub-settings held side by side in
|
|
48
|
+
# user_klass_settings.settings. A save writes only the ones it carries.
|
|
49
|
+
#
|
|
50
|
+
# Named for the table: these are the sub-keys of a per-user, per-KLASS row.
|
|
51
|
+
# The per-user, klass-independent settings are a different table with its own
|
|
52
|
+
# key list — see UserSettingKey below.
|
|
53
|
+
module UserKlassSetting
|
|
54
|
+
TOOLBAR = 'toolbar'
|
|
55
|
+
LINKED_EL_ATTRS = 'linked_el_attrs'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The `key` column of user_settings: one row per user per key, each owning an
|
|
59
|
+
# independent `settings` jsonb. A feature claims a key here and nothing else
|
|
60
|
+
# in the table can disturb it, which is the point of splitting by row rather
|
|
61
|
+
# than sharing one blob — a large layer library and a stored credential have
|
|
62
|
+
# no reason to be rewritten by each other's saves.
|
|
63
|
+
module UserSettingKey
|
|
64
|
+
# Per-user AI settings: model, personal API key (encrypted), provider
|
|
65
|
+
# endpoint. Read and written through Labimotion::UserAiSettings.
|
|
66
|
+
AI = 'ai'
|
|
67
|
+
# A user's own layers — visible to them alone, not published to a template.
|
|
68
|
+
MY_LAYERS = 'my_layers'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The independent sub-settings held side by side in a KLASS's own `settings`
|
|
72
|
+
# jsonb (element_klasses / segment_klasses / dataset_klasses). A save
|
|
73
|
+
# shallow-merges, so writing one never disturbs the others.
|
|
74
|
+
module KlassSetting
|
|
75
|
+
LAYER_FUNCTIONS = 'layer_functions'
|
|
76
|
+
COVER_IMAGE = 'cover_image'
|
|
77
|
+
# Template-specific guidance for the AI data auto-fill, written by the
|
|
78
|
+
# designer and read by Labimotion::AiTemplate.fill.
|
|
79
|
+
AI_FILL_PROMPT = 'ai_fill_prompt'
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The designer-right families as keyed in `users.generic_admin` (host-owned jsonb) and
|
|
83
|
+
# read by authenticate_admin!. FAMILY_OF answers which family gates a klass type.
|
|
84
|
+
module Family
|
|
85
|
+
ELEMENT = 'elements'
|
|
86
|
+
SEGMENT = 'segments'
|
|
87
|
+
DATASET = 'datasets'
|
|
88
|
+
ALL = [ELEMENT, SEGMENT, DATASET].freeze
|
|
89
|
+
FAMILY_OF = {
|
|
90
|
+
Klass::ELEMENT => ELEMENT,
|
|
91
|
+
Klass::SEGMENT => SEGMENT,
|
|
92
|
+
Klass::DATASET => DATASET
|
|
93
|
+
}.freeze
|
|
30
94
|
end
|
|
31
95
|
end
|
|
32
96
|
end
|
|
@@ -8,6 +8,14 @@ module Labimotion
|
|
|
8
8
|
# Common condition for fields that should not be displayed in list views
|
|
9
9
|
DISPLAYED_IN_LIST_CONDITION = { unless: :displayed_in_list }.freeze
|
|
10
10
|
|
|
11
|
+
# Ownership is opt-in per call site and deliberately does NOT reuse `displayed_in_list`.
|
|
12
|
+
# That option is passed as `true` on the payloads submitted to the central Template Hub
|
|
13
|
+
# (labimotion_hub_api.rb:76, :103), so keying ownership on it would ship local user
|
|
14
|
+
# identity off-instance; and it is hardcoded `false` on all three Designer list endpoints,
|
|
15
|
+
# which are the only places ownership is wanted. Off by default, so `download_klass`
|
|
16
|
+
# exports and Hub submissions stay clean without having to opt out.
|
|
17
|
+
WITH_OWNERSHIP_CONDITION = { if: :with_ownership }.freeze
|
|
18
|
+
|
|
11
19
|
# ISO 8601. A zone *name* (strftime '%Z', e.g. "UTC") is not a valid ISO zone designator, so
|
|
12
20
|
# clients parsing it with moment.js fall back to `new Date()` — engine-specific behaviour.
|
|
13
21
|
ELN_TIMESTAMP_FORMAT = '%Y-%m-%dT%H:%M:%S%z'
|
|
@@ -68,6 +68,12 @@ module Labimotion
|
|
|
68
68
|
object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx] =
|
|
69
69
|
set_table(field, field_table_samples, Labimotion::Prop::SAMPLE)
|
|
70
70
|
end
|
|
71
|
+
|
|
72
|
+
field_table_elements = field[Labimotion::Prop::SUBFIELDS].select { |ss| ss['type'] == Labimotion::FieldType::DRAG_ELEMENT }
|
|
73
|
+
if field_table_elements.present?
|
|
74
|
+
object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx] =
|
|
75
|
+
Labimotion::Serializer.set_table_element(field, field_table_elements)
|
|
76
|
+
end
|
|
71
77
|
end
|
|
72
78
|
end
|
|
73
79
|
object.properties
|
|
@@ -9,6 +9,132 @@ module Labimotion
|
|
|
9
9
|
expose :properties_template, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
|
|
10
10
|
expose :properties_release, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
|
|
11
11
|
expose :metadata, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
|
|
12
|
+
expose :settings, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
|
|
12
13
|
expose_timestamps(timestamp_fields: %i[released_at created_at updated_at sync_time])
|
|
14
|
+
|
|
15
|
+
expose :created_by, **WITH_OWNERSHIP_CONDITION
|
|
16
|
+
expose :owner, **WITH_OWNERSHIP_CONDITION
|
|
17
|
+
|
|
18
|
+
# The capability block the grid and toolbars key off — never the raw level, so a policy
|
|
19
|
+
# change is one edit to ShareResolver::REQUIRED_LEVEL and every button follows. Exposed
|
|
20
|
+
# only when the list endpoint preloaded a share context: absent on exports and Hub
|
|
21
|
+
# submissions by construction (same reasoning as WITH_OWNERSHIP_CONDITION), and absent on
|
|
22
|
+
# hosts that have not run the klass_shares migration, whose payloads stay byte-identical
|
|
23
|
+
# to Phase 0.
|
|
24
|
+
expose :current_user_permission, if: :share_context
|
|
25
|
+
# Plain symbols rather than ShareResolver::REQUIRED_LEVEL.keys: referencing that constant
|
|
26
|
+
# in the class body would chain-autoload KlassShare (an ActiveRecord model) just to load
|
|
27
|
+
# this entity, which the gem's Rails-less spec harness cannot do. The blocks only run when
|
|
28
|
+
# a share context was preloaded, so inside them the constants are safe.
|
|
29
|
+
%i[write release release_minor deactivate destroy manage].each do |action|
|
|
30
|
+
expose "can_#{action}", if: :share_context do |object, options|
|
|
31
|
+
context = options[:share_context]
|
|
32
|
+
key = Labimotion::KlassShare.key_of(object)
|
|
33
|
+
level = context[:levels][key]
|
|
34
|
+
if context[:admin]
|
|
35
|
+
# Administrator = implicit owner everywhere.
|
|
36
|
+
true
|
|
37
|
+
elsif !context[:has_owner].include?(key)
|
|
38
|
+
# Owner-less templates keep the legacy designer-wide rights (permanent fallback) —
|
|
39
|
+
# which the ungated lists' non-designer readers never held, so the grid must not
|
|
40
|
+
# offer them buttons the server will 401.
|
|
41
|
+
(context[:legacy] || {})[key.split(':').first] == true
|
|
42
|
+
else
|
|
43
|
+
!level.nil? && level >= Labimotion::ShareResolver::REQUIRED_LEVEL[action]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# How many people are waiting for access to this template, for the owner only — everyone
|
|
49
|
+
# else reads 0, because ShareResolver scopes the count to the requesting user's own owner
|
|
50
|
+
# rows and nobody else can act on it. It rides the payload the grid already fetches, so
|
|
51
|
+
# the Share button stays coloured across a reload without a call of its own. A block for
|
|
52
|
+
# the same reason as the capabilities above: naming KlassShare in the class body would
|
|
53
|
+
# chain-autoload an ActiveRecord model into the Rails-less spec harness. Absent without a
|
|
54
|
+
# share context, exactly like its neighbours.
|
|
55
|
+
expose :pending_request_count, if: :share_context do |object, options|
|
|
56
|
+
(options[:share_context][:pending_requests] || {})[Labimotion::KlassShare.key_of(object)] || 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# `state` is what the grid badges on: a template whose owner left the institute has nobody
|
|
60
|
+
# to ask, which is the case ownership transfer exists to resolve.
|
|
61
|
+
#
|
|
62
|
+
# With a share context the owner is the permission_level=30 share row — the transferable
|
|
63
|
+
# right — and `created_by` stays exposed above as immutable provenance; the two diverge
|
|
64
|
+
# permanently after the first transfer. Without a context (host not migrated, or an
|
|
65
|
+
# endpoint that predates shares) the Phase 0 behaviour stands: `created_by` is the owner.
|
|
66
|
+
def owner
|
|
67
|
+
return owner_from_share if share_context
|
|
68
|
+
|
|
69
|
+
owner_from_created_by
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The level label, or nil when the user has no row at all. Note the fourth answer:
|
|
73
|
+
# `'requested'` — a level-0 row is a standing access request, not access, so a client
|
|
74
|
+
# must compare the level rather than test this string for truthiness.
|
|
75
|
+
def current_user_permission
|
|
76
|
+
level = share_context[:levels][Labimotion::KlassShare.key_of(object)]
|
|
77
|
+
level && Labimotion::KlassShare::LEVELS.key(level)&.to_s
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def share_context
|
|
83
|
+
options[:share_context]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def owner_from_share
|
|
87
|
+
ref = share_context[:owners][Labimotion::KlassShare.key_of(object)]
|
|
88
|
+
return nil if ref.nil? && !share_context[:has_owner].include?(Labimotion::KlassShare.key_of(object))
|
|
89
|
+
# The ref keeps the share row's user id even when the user record cannot be resolved,
|
|
90
|
+
# so the transfer flow still knows who the row points at.
|
|
91
|
+
return { id: ref&.id, name: nil, name_abbreviation: nil, state: 'unknown' } if ref.nil? || ref.user.nil?
|
|
92
|
+
|
|
93
|
+
user_hash(ref.id, ref.user)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def owner_from_created_by
|
|
97
|
+
user_id = object.created_by
|
|
98
|
+
return nil if user_id.blank?
|
|
99
|
+
|
|
100
|
+
user = owners_map[user_id] || Labimotion::OwnerResolver.find(user_id)
|
|
101
|
+
return { id: user_id, name: nil, name_abbreviation: nil, state: 'unknown' } if user.nil?
|
|
102
|
+
|
|
103
|
+
user_hash(user_id, user)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def user_hash(user_id, user)
|
|
107
|
+
{
|
|
108
|
+
id: user_id,
|
|
109
|
+
name: user.name,
|
|
110
|
+
name_abbreviation: user.name_abbreviation,
|
|
111
|
+
state: owner_state(user)
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def owners_map
|
|
116
|
+
options[:owners] || {}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# All three columns are host-owned and none is guaranteed: `deleted_at` only exists if the
|
|
120
|
+
# host's User is acts_as_paranoid (the same assumption OwnerResolver guards),
|
|
121
|
+
# `account_active` postdates some deployments, and `type` is there only on an STI User.
|
|
122
|
+
# A missing column means "nothing says otherwise", not a 500 on the whole Designer grid.
|
|
123
|
+
def owner_state(user)
|
|
124
|
+
return 'deleted' if user.respond_to?(:deleted_at) && user.deleted_at.present?
|
|
125
|
+
# `account_active` is nil on accounts predating the column and on non-Person types;
|
|
126
|
+
# only an explicit false means deactivated.
|
|
127
|
+
return 'deactivated' if user.respond_to?(:account_active) && user.account_active == false
|
|
128
|
+
# An admin-owned template has an owner who is neither gone nor disabled, so it read
|
|
129
|
+
# `active` and appeared in no banner list — yet a designer refused on it is told by
|
|
130
|
+
# `sc11` to ask that owner, and the request comes back 422: ELN's channel_type-8 fan-out
|
|
131
|
+
# inserts notifications for `type = 'Person'` only. Nothing told anyone that a transfer
|
|
132
|
+
# is what this template needs. Ordered last of the three: a deleted or deactivated admin
|
|
133
|
+
# is deleted or deactivated first. A blank `type` counts as unnotifiable too, because
|
|
134
|
+
# that same `type = 'Person'` filter skips it just as surely.
|
|
135
|
+
return 'unnotifiable' if user.respond_to?(:type) && user.type != 'Person'
|
|
136
|
+
|
|
137
|
+
'active'
|
|
138
|
+
end
|
|
13
139
|
end
|
|
14
140
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'labimotion/entities/application_entity'
|
|
4
|
+
module Labimotion
|
|
5
|
+
# One collaborator row in the Share modal. Exposes both the integer level and its label so
|
|
6
|
+
# the frontend neither hardcodes the numeric mapping nor loses the ordering the integers
|
|
7
|
+
# carry. The user block mirrors the owner block on GenericKlassEntity, `state` included —
|
|
8
|
+
# a share held by a departed user renders greyed out, it is never deleted (the account can
|
|
9
|
+
# be undeleted, and its shares should come back with it).
|
|
10
|
+
class KlassShareEntity < Labimotion::ApplicationEntity
|
|
11
|
+
expose :id, :klass_type, :klass_id, :created_by
|
|
12
|
+
expose :permission
|
|
13
|
+
expose :permission_level_value, as: :permission_level
|
|
14
|
+
expose :user
|
|
15
|
+
expose_timestamps(timestamp_fields: %i[created_at])
|
|
16
|
+
|
|
17
|
+
def permission
|
|
18
|
+
value = object.permission_level
|
|
19
|
+
value.is_a?(Integer) ? Labimotion::KlassShare::LEVELS.key(value)&.to_s : value.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def permission_level_value
|
|
23
|
+
object.level
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def user
|
|
27
|
+
resolved = options[:users]&.[](object.shared_with_id) ||
|
|
28
|
+
Labimotion::OwnerResolver.find(object.shared_with_id)
|
|
29
|
+
return { id: object.shared_with_id, name: nil, name_abbreviation: nil, state: 'unknown' } if resolved.nil?
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
id: object.shared_with_id,
|
|
33
|
+
name: resolved.name,
|
|
34
|
+
name_abbreviation: resolved.name_abbreviation,
|
|
35
|
+
state: user_state(resolved)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def user_state(user)
|
|
42
|
+
return 'deleted' if user.respond_to?(:deleted_at) && user.deleted_at.present?
|
|
43
|
+
return 'deactivated' if user.respond_to?(:account_active) && user.account_active == false
|
|
44
|
+
|
|
45
|
+
'active'
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -20,6 +20,7 @@ module Labimotion
|
|
|
20
20
|
|
|
21
21
|
def process_fields(key, layer)
|
|
22
22
|
process_sample_and_molecule_fields(key, layer)
|
|
23
|
+
process_linked_attr_fields(key, layer)
|
|
23
24
|
process_reaction_fields(key, layer)
|
|
24
25
|
process_table_fields(key, layer)
|
|
25
26
|
# process_voc_fields(key, layer)
|
|
@@ -32,6 +33,18 @@ module Labimotion
|
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
|
|
36
|
+
# Resolve the linked attributes a user chose to surface inline on a drag field
|
|
37
|
+
# (stored as value['el_attrs']). Only the selection is persisted; the current
|
|
38
|
+
# values are refreshed here from the linked element on every load. Works for
|
|
39
|
+
# drag_element (generic element or any permit-target type) as well as
|
|
40
|
+
# drag_sample / drag_molecule.
|
|
41
|
+
def process_linked_attr_fields(key, layer)
|
|
42
|
+
select_fields(layer, [Labimotion::FieldType::DRAG_ELEMENT, Labimotion::FieldType::DRAG_SAMPLE,
|
|
43
|
+
Labimotion::FieldType::DRAG_MOLECULE]).each do |field, idx|
|
|
44
|
+
update_linked_attr_field(key, field, idx)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
35
48
|
def process_reaction_fields(key, layer)
|
|
36
49
|
select_fields(layer, [Labimotion::FieldType::SYS_REACTION]).each do |field, idx|
|
|
37
50
|
update_reaction_field(key, field, idx)
|
|
@@ -80,6 +93,61 @@ module Labimotion
|
|
|
80
93
|
})
|
|
81
94
|
end
|
|
82
95
|
|
|
96
|
+
def update_linked_attr_field(key, field, idx)
|
|
97
|
+
return unless field['value'].is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
attrs = field.dig('value', 'el_attrs')
|
|
100
|
+
el_klass = field.dig('value', 'el_klass')
|
|
101
|
+
el_id = field.dig('value', 'el_id')
|
|
102
|
+
el_type = field.dig('value', 'el_type')
|
|
103
|
+
linked = Labimotion::LinkedElement.new(el_klass, el_id, el_type)
|
|
104
|
+
|
|
105
|
+
# A missing el_attrs means the user never picked anything for this link, so
|
|
106
|
+
# their My LabIMotion default applies. An explicit [] means they picked none
|
|
107
|
+
# — the two must not collapse, or clearing a selection would not stick.
|
|
108
|
+
from_default = attrs.nil?
|
|
109
|
+
attrs = default_linked_attrs(el_klass, el_type) if from_default
|
|
110
|
+
return unless attrs.is_a?(Array) && attrs.present?
|
|
111
|
+
return unless object.properties.dig(Labimotion::Prop::LAYERS, key, Labimotion::Prop::FIELDS, idx,
|
|
112
|
+
'value').present?
|
|
113
|
+
|
|
114
|
+
resolved = linked.resolve(attrs)
|
|
115
|
+
resolved = resolved.map { |attr| mark_default(attr) } if from_default
|
|
116
|
+
object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx]['value']['el_attrs'] = resolved
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def mark_default(attr)
|
|
120
|
+
attr.is_a?(Hash) ? attr.merge(Labimotion::LinkedElement::IS_DEFAULT => true) : attr
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Keyed by what the drag field itself stores — el_klass for a generic element
|
|
124
|
+
# or a standard type, el_type for drag_molecule, which omits el_klass. Read
|
|
125
|
+
# straight off the preloaded map rather than via LinkedElement#target_key, so
|
|
126
|
+
# a field with no default costs no query.
|
|
127
|
+
def default_linked_attrs(el_klass, el_type)
|
|
128
|
+
map = default_linked_attrs_map
|
|
129
|
+
return [] if map.empty?
|
|
130
|
+
|
|
131
|
+
map[el_klass.to_s] || map[el_type.to_s] || []
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The current user's defaults for every linked target, loaded once per
|
|
135
|
+
# represented object. Skipped when no user is in scope (this entity is also
|
|
136
|
+
# rendered from contexts that pass none, e.g. Hub submissions and exports) and
|
|
137
|
+
# for list views, which don't render the inline attributes and would otherwise
|
|
138
|
+
# pay the lookup once per row.
|
|
139
|
+
def default_linked_attrs_map
|
|
140
|
+
return @default_linked_attrs_map if defined?(@default_linked_attrs_map)
|
|
141
|
+
|
|
142
|
+
user_id = options[:current_user]&.id
|
|
143
|
+
@default_linked_attrs_map =
|
|
144
|
+
if user_id.blank? || displayed_in_list?
|
|
145
|
+
{}
|
|
146
|
+
else
|
|
147
|
+
Labimotion::UserKlassSetting.linked_el_attrs_by_el_klass(user_id)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
83
151
|
def update_reaction_field(key, field, idx)
|
|
84
152
|
return unless field['value'].is_a?(Hash)
|
|
85
153
|
|
|
@@ -116,10 +184,18 @@ module Labimotion
|
|
|
116
184
|
field_table_samples = field[Labimotion::Prop::SUBFIELDS].select do |ss|
|
|
117
185
|
ss['type'] == Labimotion::FieldType::DRAG_SAMPLE
|
|
118
186
|
end
|
|
119
|
-
|
|
187
|
+
if field_table_samples.present?
|
|
188
|
+
object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx] =
|
|
189
|
+
set_table(field, field_table_samples, Labimotion::Prop::SAMPLE)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
field_table_elements = field[Labimotion::Prop::SUBFIELDS].select do |ss|
|
|
193
|
+
ss['type'] == Labimotion::FieldType::DRAG_ELEMENT
|
|
194
|
+
end
|
|
195
|
+
return unless field_table_elements.present?
|
|
120
196
|
|
|
121
197
|
object.properties[Labimotion::Prop::LAYERS][key][Labimotion::Prop::FIELDS][idx] =
|
|
122
|
-
|
|
198
|
+
Labimotion::Serializer.set_table_element(field, field_table_elements)
|
|
123
199
|
end
|
|
124
200
|
|
|
125
201
|
def update_voc_field(key, field, idx)
|
|
@@ -9,9 +9,17 @@ module Labimotion
|
|
|
9
9
|
expose :properties, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
|
|
10
10
|
expose :properties_release, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
|
|
11
11
|
expose :metadata, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
|
|
12
|
+
expose :settings, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
|
|
12
13
|
|
|
13
14
|
def klass_label
|
|
14
15
|
object.segment_klass.label
|
|
15
16
|
end
|
|
17
|
+
|
|
18
|
+
# Saved segments are serialized without their klass, so surface the
|
|
19
|
+
# template-scoped settings here; guarded for hosts not yet migrated.
|
|
20
|
+
def settings
|
|
21
|
+
klass = object.segment_klass
|
|
22
|
+
klass.respond_to?(:settings) ? klass.settings || {} : {}
|
|
23
|
+
end
|
|
16
24
|
end
|
|
17
25
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'labimotion/entities/application_entity'
|
|
4
|
+
module Labimotion
|
|
5
|
+
# Per-user, per-klass UI setting entity
|
|
6
|
+
class UserKlassSettingEntity < Labimotion::ApplicationEntity
|
|
7
|
+
expose :id, :klass, :klass_identifier, :klass_label, :settings
|
|
8
|
+
expose_timestamps(timestamp_fields: %i[created_at])
|
|
9
|
+
end
|
|
10
|
+
end
|