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
@@ -7,6 +7,10 @@ module Labimotion
7
7
  # rubocop:disable Metrics/BlockLength
8
8
  # Grape route and helper DSL blocks are necessarily large.
9
9
  KLASS_TYPES = Labimotion::TemplateDoiHelpers::KLASS_BY_TYPE.keys.freeze
10
+ # Publication fields exposed on the public (anonymous) DOI endpoints.
11
+ # `contributor` (singular) is kept for DOIs released before contributors
12
+ # became a list — their snapshot still carries it.
13
+ PUBLIC_PUBLICATION_KEYS = %w[title description authors contributors contributor references license].freeze
10
14
 
11
15
  helpers do
12
16
  def resolve_klass!(type, id)
@@ -41,25 +45,36 @@ module Labimotion
41
45
  )
42
46
  end
43
47
 
44
- # Public, read-only view of one released DOI version.
45
- def released_doi_version(doi)
48
+ # Public, read-only view of one released DOI version, carrying the
49
+ # publication metadata that version was released with.
50
+ def released_doi_version(record, doi)
46
51
  full = doi.full_doi
47
52
  {
48
53
  doi: full,
49
54
  doi_url: "https://dx.doi.org/#{full}",
50
55
  version: ::Doi.labimotion_doi_version(doi),
51
- minted_at: doi.minted_at
56
+ minted_at: doi.minted_at,
57
+ publication: released_publication(record, doi)
52
58
  }
53
59
  end
54
60
 
61
+ # The snapshot taken when this version was released. DOIs released before
62
+ # snapshots were kept have none, and fall back to the template's current
63
+ # publication — the best available answer for them.
64
+ def released_publication(record, doi)
65
+ publication = ::Doi.labimotion_doi_publication(doi).presence ||
66
+ (record.properties_template || {})['publication']
67
+ publication.is_a?(Hash) ? publication.slice(*PUBLIC_PUBLICATION_KEYS) : {}
68
+ end
69
+
55
70
  # Public payload for a template that has at least one released DOI: the
56
71
  # latest released DOI promoted to the top level, plus its publication
57
- # metadata, hub deep-link and every released version. `record_id` matches
58
- # the hub grid's row id (identifier || uuid) so the frontend can merge it.
72
+ # metadata, hub deep-link and every released version. Each version brings
73
+ # its own publication, so the top-level one is the latest release's rather
74
+ # than the template's work-in-progress. `record_id` matches the hub grid's
75
+ # row id (identifier || uuid) so the frontend can merge it.
59
76
  def released_template_doi(record, dois)
60
- versions = dois.sort_by(&:id).map { |doi| released_doi_version(doi) }
61
- publication = (record.properties_template || {})['publication']
62
- publication = publication.is_a?(Hash) ? publication.slice('title', 'description', 'authors', 'license') : {}
77
+ versions = dois.sort_by(&:id).map { |doi| released_doi_version(record, doi) }
63
78
  versions.last.merge(
64
79
  record_id: record.try(:identifier).presence || record.try(:uuid).presence || record.id,
65
80
  identifier: record.try(:identifier),
@@ -68,7 +83,6 @@ module Labimotion
68
83
  template_version: record.try(:version),
69
84
  next_doi_version: ::Doi.labimotion_version_segment(record),
70
85
  template_url: Labimotion::TemplateDoiHelpers.template_url(record),
71
- publication: publication,
72
86
  versions: versions
73
87
  )
74
88
  end
@@ -125,7 +139,7 @@ module Labimotion
125
139
  version: ::Doi.labimotion_doi_version(doi),
126
140
  full_doi: doi.full_doi,
127
141
  minted: doi.minted == true,
128
- xml: Labimotion::BuildTemplateDoiXml.new(record, doi, current_user).call
142
+ xml: Labimotion::BuildTemplateDoiXml.new(record, doi).call
129
143
  }
130
144
  end
131
145
  { versions: versions }
@@ -9,6 +9,10 @@ module Labimotion
9
9
  class LabimotionTemplateBrowseAPI < Grape::API
10
10
  KLASS_BY_TYPE = Labimotion::TemplateDoiHelpers::KLASS_BY_TYPE
11
11
  KLASS_TYPES = KLASS_BY_TYPE.keys.freeze
12
+ # Publication fields exposed alongside a template / its released DOIs.
13
+ # `contributor` (singular) is kept for DOIs released before contributors
14
+ # became a list — their snapshot still carries it.
15
+ PUBLICATION_KEYS = %w[title description authors contributors contributor references license].freeze
12
16
  # type => [revision model, foreign key to the klass]
13
17
  REVISION_BY_TYPE = {
14
18
  'element' => [::Labimotion::ElementKlassesRevision, :element_klass_id],
@@ -76,7 +80,14 @@ module Labimotion
76
80
  # The template's DOI publication metadata (shown alongside the DOI).
77
81
  def template_publication(record)
78
82
  pub = (record.properties_template || {})['publication']
79
- pub.is_a?(Hash) ? pub.slice('title', 'description', 'authors', 'license') : {}
83
+ pub.is_a?(Hash) ? pub.slice(*PUBLICATION_KEYS) : {}
84
+ end
85
+
86
+ # The publication a released DOI was published with. Falls back to the
87
+ # template's current metadata for DOIs released before snapshots were kept.
88
+ def released_publication(record, doi)
89
+ pub = ::Doi.labimotion_doi_publication(doi)
90
+ pub.is_a?(Hash) && pub.any? ? pub.slice(*PUBLICATION_KEYS) : template_publication(record)
80
91
  end
81
92
 
82
93
  # Released (minted) DOIs of the template, each tagged with its DOI version
@@ -88,6 +99,7 @@ module Labimotion
88
99
  full = doi.full_doi
89
100
  { version: ::Doi.labimotion_doi_version(doi), doi: full,
90
101
  doi_url: "https://dx.doi.org/#{full}", minted_at: doi.minted_at,
102
+ publication: released_publication(record, doi),
91
103
  xml: Labimotion::BuildTemplateDoiXml.new(record, doi).call }
92
104
  end
93
105
  end
@@ -25,8 +25,21 @@ module Labimotion
25
25
  optional :displayed_in_list, type: Boolean, desc: 'Display in list format', default: false
26
26
  end
27
27
  get do
28
- list = klass_list(nil, params[:is_active], false)
29
- present list, with: Labimotion::SegmentKlassEntity, root: 'klass', displayed_in_list: false
28
+ # SegmentKlassEntity nests ElementKlassEntity, and Grape hands serialization options
29
+ # down into nested entities — so the element klasses resolve an owner too. Their
30
+ # authors have to go into the same map, or every row falls back to its own user
31
+ # query and the preload this option exists for buys nothing. `klass_list` only
32
+ # preloads the association when filtering by element, which this call does not, so
33
+ # ask for it here or `map(&:element_klass)` is itself one query per row.
34
+ list = klass_list(nil, params[:is_active], false).preload(:element_klass)
35
+ owners = Labimotion::OwnerResolver.map_for(list.to_a + list.map(&:element_klass))
36
+ # The nested element klasses go into the same share context for the same reason
37
+ # they share the owners map; ShareResolver folds a segment's parent in by itself.
38
+ share_context = Labimotion::ShareResolver.context_for(
39
+ list.to_a + list.map(&:element_klass), current_user
40
+ )
41
+ present list, with: Labimotion::SegmentKlassEntity, root: 'klass', displayed_in_list: false,
42
+ with_ownership: true, owners: owners, share_context: share_context
30
43
  end
31
44
  end
32
45
 
@@ -36,7 +49,7 @@ module Labimotion
36
49
  use :create_segment_klass_params
37
50
  end
38
51
  after_validation do
39
- authenticate_admin!('segments')
52
+ authenticate_admin!(Labimotion::Constants::Family::SEGMENT)
40
53
  @klass = fetch_klass('ElementKlass', params[:element_klass])
41
54
  end
42
55
  post do
@@ -46,14 +59,31 @@ module Labimotion
46
59
  end
47
60
  end
48
61
 
49
- namespace :update_segment_klass do
50
- desc 'update Generic Segment Klass'
62
+ namespace :create_ai_klass do
63
+ desc 'create a Generic Segment Klass from an AI-generated template'
51
64
  params do
52
- use :update_segment_klass_params
65
+ use :create_ai_segment_klass_params
53
66
  end
54
67
  after_validation do
55
68
  authenticate_admin!('segments')
69
+ @klass = fetch_klass('ElementKlass', params[:element_klass])
70
+ end
71
+ post do
72
+ msg = create_ai_segment_klass(current_user, params)
73
+ { status: msg[:status], message: msg[:message],
74
+ klass: Labimotion::SegmentKlassEntity.represent(Labimotion::SegmentKlass.all) }
75
+ rescue StandardError => e
76
+ Labimotion.log_exception(e, current_user)
77
+ { error: e.message }
78
+ end
79
+ end
80
+
81
+ namespace :update_segment_klass do
82
+ desc 'update Generic Segment Klass'
83
+ params do
84
+ use :update_segment_klass_params
56
85
  end
86
+ # authorize_klass!(:write) runs inside the helper, on the loaded record.
57
87
  post do
58
88
  update_segment_klass(current_user, params)
59
89
  rescue StandardError => e
@@ -67,7 +97,10 @@ module Labimotion
67
97
  params do
68
98
  requires :identifier, type: String, desc: 'identifier'
69
99
  end
70
- post do
100
+ post do # rubocop:disable Metrics/BlockLength
101
+ # Pulling from the repository creates or overwrites a local template — the same
102
+ # create/upgrade pair the import endpoints gate, so it takes the same two gates.
103
+ authenticate_admin!(Labimotion::Constants::Family::SEGMENT)
71
104
  sk_obj = fetch_repo_generic_template('Segment', params[:identifier])
72
105
  sk_obj = sk_obj.deep_symbolize_keys[:generic_template]
73
106
  return { error: 'No template data found' } unless sk_obj.present?
@@ -75,9 +108,17 @@ module Labimotion
75
108
  ek_obj = Labimotion::ElementKlass.find_by(name: sk_obj.dig(:element_klass, :klass_name))
76
109
  return { error: 'No related element data found' } unless ek_obj.present?
77
110
 
78
- segment_klass = Labimotion::SegmentKlass.find_or_create_by(
79
- identifier: sk_obj.dig(:identifier),
80
- )
111
+ segment_klass = Labimotion::SegmentKlass.find_by(identifier: sk_obj.dig(:identifier))
112
+ # Syncing over an existing template replaces its released properties — :release,
113
+ # owner-only, exactly like the upgrade branch of validate_klass.
114
+ authorize_klass!(segment_klass, :release) if segment_klass
115
+ newly_created = segment_klass.nil?
116
+ # created_by belongs in this INSERT, not only in the update below: the column is
117
+ # NOT NULL since tighten_klass_created_columns, so a bare create is one relaxed
118
+ # label validation away from an ActiveRecord::NotNullViolation that would break
119
+ # every Hub pull of a new segment template.
120
+ segment_klass ||= Labimotion::SegmentKlass.create(identifier: sk_obj.dig(:identifier),
121
+ created_by: current_user.id)
81
122
  segment_klass.update(sk_obj.slice(
82
123
  :label,
83
124
  :desc,
@@ -92,6 +133,10 @@ module Labimotion
92
133
  released_at: DateTime.now,
93
134
  sync_time: DateTime.now,
94
135
  ))
136
+ # Owner bookkeeping only for a template this pull just created. An existing
137
+ # owner-less row stays owner-less: assigning it a first owner is the
138
+ # administrator's transfer_ownership call, not a side effect of syncing.
139
+ Labimotion::KlassShare.seed_owner!(segment_klass, current_user.id) if newly_created
95
140
 
96
141
  present segment_klass, with: Labimotion::SegmentKlassEntity, root: 'klass'
97
142
  rescue StandardError => e
@@ -126,6 +171,9 @@ module Labimotion
126
171
  requires :identifier, type: String, desc: 'Identifier'
127
172
  end
128
173
  post do
174
+ # Importing needs the global create right; upgrading an existing template is gated
175
+ # owner-only inside validate_klass, where the create/update branch is decided.
176
+ authenticate_admin!(Labimotion::Constants::Family::SEGMENT)
129
177
  msg = create_repo_klass(params, current_user, request.headers['Origin'])
130
178
  klass = Labimotion::SegmentKlassEntity.represent(Labimotion::SegmentKlass.all)
131
179
  { status: msg[:status], message: msg[:message], klass: klass }
@@ -142,6 +190,9 @@ module Labimotion
142
190
  use :upload_segment_klass_params
143
191
  end
144
192
  post do
193
+ # Same split as create_repo_klass: create right here, owner-only upgrade inside
194
+ # validate_klass. This route previously had no gate at all.
195
+ authenticate_admin!(Labimotion::Constants::Family::SEGMENT)
145
196
  declared_params = declared(params, include_missing: false)
146
197
  attributes = declared_params.merge(
147
198
  created_by: current_user.id,
@@ -28,5 +28,19 @@ module Labimotion
28
28
  DATASET = 'DatasetKlass'
29
29
  ALL = [ELEMENT, SEGMENT, DATASET].freeze
30
30
  end
31
+
32
+ # The designer-right families as keyed in `users.generic_admin` (host-owned jsonb) and
33
+ # read by authenticate_admin!. FAMILY_OF answers which family gates a klass type.
34
+ module Family
35
+ ELEMENT = 'elements'
36
+ SEGMENT = 'segments'
37
+ DATASET = 'datasets'
38
+ ALL = [ELEMENT, SEGMENT, DATASET].freeze
39
+ FAMILY_OF = {
40
+ Klass::ELEMENT => ELEMENT,
41
+ Klass::SEGMENT => SEGMENT,
42
+ Klass::DATASET => DATASET
43
+ }.freeze
44
+ end
31
45
  end
32
46
  end
@@ -8,8 +8,20 @@ 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
+
19
+ # ISO 8601. A zone *name* (strftime '%Z', e.g. "UTC") is not a valid ISO zone designator, so
20
+ # clients parsing it with moment.js fall back to `new Date()` — engine-specific behaviour.
21
+ ELN_TIMESTAMP_FORMAT = '%Y-%m-%dT%H:%M:%S%z'
22
+
11
23
  format_with(:eln_timestamp) do |datetime|
12
- datetime.present? ? datetime.strftime('%Y-%m-%d %H:%M:%S %Z') : nil
24
+ datetime.present? ? datetime.strftime(ELN_TIMESTAMP_FORMAT) : nil
13
25
  end
14
26
  end
15
27
  end
@@ -11,5 +11,130 @@ module Labimotion
11
11
  expose :metadata, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
12
12
  expose :settings, **DISPLAYED_IN_LIST_CONDITION, anonymize_with: {}
13
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 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
14
139
  end
15
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
@@ -31,6 +31,9 @@ module Labimotion
31
31
  { status: 'success', message: "This dataset: #{attributes['label']} has the latest version!" }
32
32
  else
33
33
  ds = Labimotion::DatasetKlass.find_by(ols_term_id: attributes['ols_term_id'])
34
+ # Upgrading bumps the version and cuts a revision — :release, owner-only; the gate
35
+ # lives on the update branch because only the ols_term_id lookup decides it.
36
+ authorize_klass!(ds, :release)
34
37
  ds.update!(attributes)
35
38
  ds.create_klasses_revision(current_user)
36
39
  { status: 'success',
@@ -38,7 +41,13 @@ module Labimotion
38
41
  end
39
42
  else
40
43
  attributes['created_by'] = current_user.id
41
- ds = Labimotion::DatasetKlass.create!(attributes)
44
+ # The owner row shares the create's transaction: a klass committed without it is
45
+ # owner-less, which falls open to the legacy designer-wide gate.
46
+ ds = Labimotion::DatasetKlass.transaction do
47
+ Labimotion::DatasetKlass.create!(attributes).tap do |klz|
48
+ Labimotion::KlassShare.seed_owner!(klz, current_user.id)
49
+ end
50
+ end
42
51
  ds.create_klasses_revision(current_user)
43
52
  { status: 'success',
44
53
  message: "The dataset: #{attributes['label']} has been created using version: #{attributes['version']}!" }
@@ -49,6 +58,104 @@ module Labimotion
49
58
  raise e
50
59
  end
51
60
 
61
+ # Create a new (inactive) dataset klass whose properties template is
62
+ # generated by an LLM from a CHMO ontology term plus optional description,
63
+ # reference links and uploaded files. The admin reviews/edits the generated
64
+ # template in the designer and activates it (human-in-the-loop).
65
+ def create_ai_dataset_klass(params, current_user)
66
+ ols_term_id = params[:ols_term_id].to_s.split('|').first.to_s.strip
67
+ raise 'An ontology term (CHMO) is required' if ols_term_id.blank?
68
+
69
+ if Labimotion::DatasetKlass.find_by(ols_term_id: ols_term_id).present?
70
+ return { status: 'error',
71
+ message: "A dataset template already exists for #{ols_term_id}. Edit it in the designer instead." }
72
+ end
73
+
74
+ if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
75
+ return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
76
+ end
77
+
78
+ overrides = ai_user_overrides(current_user)
79
+ ai = Labimotion::AiTemplate.generate(
80
+ ols_term_id: params[:ols_term_id],
81
+ desc: params[:desc],
82
+ cols: params[:cols],
83
+ references: params[:references],
84
+ files: params[:files],
85
+ **overrides
86
+ )
87
+
88
+ uuid = SecureRandom.uuid
89
+ label = ai['label'].presence ||
90
+ params[:ols_term_id].to_s.split('|').last.to_s.strip.presence ||
91
+ 'AI dataset template'
92
+ # property-base schema requires pkg, uuid, klass, layers, version, identifier.
93
+ properties_template = {
94
+ 'uuid' => uuid,
95
+ 'klass' => 'DatasetKlass',
96
+ 'pkg' => Labimotion::Utils.pkg(nil),
97
+ 'version' => '1.0.0',
98
+ 'identifier' => uuid,
99
+ 'layers' => ai['layers'],
100
+ 'select_options' => ai['select_options'],
101
+ 'metadata' => ai['metadata']
102
+ }
103
+ attributes = {
104
+ 'uuid' => uuid,
105
+ 'label' => label,
106
+ 'desc' => params[:desc].presence || ai['label'].presence,
107
+ 'ols_term_id' => ols_term_id,
108
+ 'place' => ((Labimotion::DatasetKlass.all.length * 10) || 0) + 10,
109
+ 'is_active' => false,
110
+ 'released_at' => DateTime.now,
111
+ 'properties_template' => properties_template,
112
+ 'properties_release' => properties_template,
113
+ 'created_by' => current_user.id
114
+ }
115
+
116
+ ds = Labimotion::DatasetKlass.create!(attributes)
117
+ ds.create_klasses_revision(current_user)
118
+ { status: 'success',
119
+ message: "The AI dataset template [#{label}] has been created as inactive. Review and activate it in the designer." }
120
+ rescue StandardError => e
121
+ Labimotion.log_exception(e, current_user)
122
+ { status: 'error', message: e.message }
123
+ end
124
+
125
+ # Refine an existing dataset template with AI and return the revised template
126
+ # (label, layers, select_options) plus a one-line summary of the change.
127
+ # Nothing is persisted — the admin applies the result into the designer's
128
+ # working copy and saves it there (human-in-the-loop, mirrors the create flow).
129
+ def refine_ai_dataset_klass(params, current_user)
130
+ instruction = params[:instruction].to_s.strip
131
+ raise 'An instruction is required' if instruction.blank?
132
+
133
+ current = {
134
+ 'label' => params[:label],
135
+ 'layers' => params[:layers] || {},
136
+ 'select_options' => params[:select_options] || {}
137
+ }
138
+ overrides = ai_user_overrides(current_user)
139
+ ai = Labimotion::AiTemplate.refine(
140
+ current: current,
141
+ instruction: instruction,
142
+ ols_term_id: ai_term_with_label(params[:ols_term_id]),
143
+ history: params[:history],
144
+ cols: params[:cols],
145
+ **overrides
146
+ )
147
+ {
148
+ status: 'success',
149
+ label: ai['label'].presence || params[:label],
150
+ layers: ai['layers'],
151
+ select_options: ai['select_options'],
152
+ summary: ai['summary']
153
+ }
154
+ rescue StandardError => e
155
+ Labimotion.log_exception(e, current_user)
156
+ { status: 'error', message: e.message }
157
+ end
158
+
52
159
  def find_best_match_template(ols_term_id)
53
160
  result = Labimotion::TemplateMatcher.find_best_match(ols_term_id)
54
161
  if result[:template]
@@ -60,6 +167,21 @@ module Labimotion
60
167
 
61
168
  private
62
169
 
170
+ # Pair the CHMO id with its human label ("CHMO:0000470 | mass spectrometry")
171
+ # by resolving the label from ols_terms, so the AI refine scope guard can name
172
+ # the method rather than show a bare id. Falls back to the raw value when the
173
+ # term is missing/unknown; never raises (a failed lookup must not block refine).
174
+ def ai_term_with_label(raw)
175
+ id = raw.to_s.split('|').first.to_s.strip
176
+ return raw.to_s if id.blank? || !defined?(OlsTerm)
177
+
178
+ label = OlsTerm.find_by(term_id: id)&.label
179
+ label.present? ? "#{id} | #{label}" : raw.to_s
180
+ rescue StandardError => e
181
+ Labimotion.log_exception(e)
182
+ raw.to_s
183
+ end
184
+
63
185
  def build_template_response(template, match_type, info_messages)
64
186
  response = {
65
187
  error: '',