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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/lib/labimotion/apis/generic_dataset_api.rb +41 -3
- data/lib/labimotion/apis/generic_element_api.rb +60 -8
- data/lib/labimotion/apis/generic_klass_api.rb +50 -6
- data/lib/labimotion/apis/klass_share_api.rb +646 -0
- data/lib/labimotion/apis/labimotion_api.rb +1 -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/segment_api.rb +61 -10
- data/lib/labimotion/constants.rb +14 -0
- data/lib/labimotion/entities/application_entity.rb +13 -1
- data/lib/labimotion/entities/generic_klass_entity.rb +125 -0
- data/lib/labimotion/entities/klass_share_entity.rb +48 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +123 -1
- data/lib/labimotion/helpers/element_helpers.rb +206 -4
- data/lib/labimotion/helpers/generic_helpers.rb +276 -4
- data/lib/labimotion/helpers/param_helpers.rb +68 -0
- data/lib/labimotion/helpers/segment_helpers.rb +87 -4
- data/lib/labimotion/libs/ai_egress_guard.rb +84 -0
- data/lib/labimotion/libs/ai_template.rb +1482 -0
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/libs/owner_resolver.rb +50 -0
- data/lib/labimotion/libs/ownership_audit.rb +73 -0
- data/lib/labimotion/libs/share_notifier.rb +114 -0
- data/lib/labimotion/libs/share_resolver.rb +369 -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 +126 -0
- data/lib/labimotion/models/segment_klass.rb +1 -1
- 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/prop.rb +1 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +11 -0
- 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
|
-
|
|
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.
|
|
58
|
-
#
|
|
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
|
|
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(
|
|
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
|
-
|
|
29
|
-
|
|
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!(
|
|
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 :
|
|
50
|
-
desc '
|
|
62
|
+
namespace :create_ai_klass do
|
|
63
|
+
desc 'create a Generic Segment Klass from an AI-generated template'
|
|
51
64
|
params do
|
|
52
|
-
use :
|
|
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.
|
|
79
|
-
|
|
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,
|
data/lib/labimotion/constants.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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: '',
|