labimotion 2.3.0 → 2.4.0.rc11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +198 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +74 -8
  6. data/lib/labimotion/apis/klass_share_api.rb +648 -0
  7. data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
  8. data/lib/labimotion/apis/labimotion_api.rb +4 -0
  9. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  10. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  11. data/lib/labimotion/apis/ontology_root_api.rb +74 -0
  12. data/lib/labimotion/apis/segment_api.rb +77 -10
  13. data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
  14. data/lib/labimotion/conf.rb +5 -0
  15. data/lib/labimotion/constants.rb +64 -0
  16. data/lib/labimotion/entities/application_entity.rb +8 -0
  17. data/lib/labimotion/entities/eln_element_entity.rb +6 -0
  18. data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
  19. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  20. data/lib/labimotion/entities/properties_entity.rb +78 -2
  21. data/lib/labimotion/entities/segment_entity.rb +8 -0
  22. data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
  23. data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
  24. data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
  25. data/lib/labimotion/helpers/element_helpers.rb +282 -6
  26. data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
  27. data/lib/labimotion/helpers/generic_helpers.rb +293 -4
  28. data/lib/labimotion/helpers/param_helpers.rb +105 -0
  29. data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
  30. data/lib/labimotion/helpers/segment_helpers.rb +102 -4
  31. data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
  32. data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
  33. data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
  34. data/lib/labimotion/libs/ai_models.rb +201 -0
  35. data/lib/labimotion/libs/ai_template.rb +2285 -0
  36. data/lib/labimotion/libs/converter.rb +5 -43
  37. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  38. data/lib/labimotion/libs/export_element.rb +128 -13
  39. data/lib/labimotion/libs/file_extractor.rb +210 -0
  40. data/lib/labimotion/libs/linked_element.rb +313 -0
  41. data/lib/labimotion/libs/ontology_store.rb +226 -0
  42. data/lib/labimotion/libs/ontology_terms.rb +227 -0
  43. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  44. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  45. data/lib/labimotion/libs/sample_association.rb +52 -1
  46. data/lib/labimotion/libs/share_notifier.rb +114 -0
  47. data/lib/labimotion/libs/share_resolver.rb +373 -0
  48. data/lib/labimotion/libs/user_ai_settings.rb +129 -0
  49. data/lib/labimotion/models/cellline.rb +47 -0
  50. data/lib/labimotion/models/concerns/datasetable.rb +3 -0
  51. data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
  52. data/lib/labimotion/models/concerns/segmentable.rb +2 -0
  53. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  54. data/lib/labimotion/models/dataset_klass.rb +1 -1
  55. data/lib/labimotion/models/element_klass.rb +1 -1
  56. data/lib/labimotion/models/klass_share.rb +129 -0
  57. data/lib/labimotion/models/segment_klass.rb +1 -1
  58. data/lib/labimotion/models/user_klass_setting.rb +58 -0
  59. data/lib/labimotion/models/user_setting.rb +60 -0
  60. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  61. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  62. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  63. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  64. data/lib/labimotion/utils/export_utils.rb +1 -0
  65. data/lib/labimotion/utils/import_utils.rb +20 -3
  66. data/lib/labimotion/utils/serializer.rb +27 -0
  67. data/lib/labimotion/utils/units.rb +32 -67
  68. data/lib/labimotion/version.rb +1 -1
  69. data/lib/labimotion.rb +27 -0
  70. metadata +45 -3
@@ -0,0 +1,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
@@ -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
@@ -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
- return unless field_table_samples.present?
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
- set_table(field, field_table_samples, Labimotion::Prop::SAMPLE)
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