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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<resource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://datacite.org/schema/kernel-4" xsi:schemaLocation="http://datacite.org/schema/kernel-4 https://schema.datacite.org/meta/kernel-4/metadata.xsd">
|
|
3
|
+
<identifier identifierType="DOI">https://dx.doi.org/<%= full_doi %></identifier>
|
|
4
|
+
<creators>
|
|
5
|
+
<% if creators.empty? %>
|
|
6
|
+
<creator>
|
|
7
|
+
<creatorName>(:unav)</creatorName>
|
|
8
|
+
</creator>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% creators.each do |creator| %>
|
|
11
|
+
<creator>
|
|
12
|
+
<creatorName nameType="Personal"><%= creator['familyName'] %>, <%= creator['givenName'] %></creatorName>
|
|
13
|
+
<givenName><%= creator['givenName'] %></givenName>
|
|
14
|
+
<familyName><%= creator['familyName'] %></familyName>
|
|
15
|
+
<% if creator['orcid'].present? %>
|
|
16
|
+
<nameIdentifier schemeURI="https://orcid.org/" nameIdentifierScheme="ORCID"><%= creator['orcid'] %></nameIdentifier>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% if creator['affiliation'].present? || creator['ror'].present? %>
|
|
19
|
+
<affiliation<% if creator['ror'].present? %> affiliationIdentifier="<%= creator['ror'] %>" affiliationIdentifierScheme="ROR" schemeURI="https://ror.org"<% end %>><%= creator['affiliation'] %></affiliation>
|
|
20
|
+
<% end %>
|
|
21
|
+
</creator>
|
|
22
|
+
<% end %>
|
|
23
|
+
</creators>
|
|
24
|
+
<titles>
|
|
25
|
+
<title xml:lang="en-US"><%= title %></title>
|
|
26
|
+
</titles>
|
|
27
|
+
<%= render_publisher(publisher_config) %>
|
|
28
|
+
<publicationYear><%= publication_year %></publicationYear>
|
|
29
|
+
<subjects>
|
|
30
|
+
<subject>Chemistry</subject>
|
|
31
|
+
</subjects>
|
|
32
|
+
<% if contributors.any? %>
|
|
33
|
+
<contributors>
|
|
34
|
+
<% contributors.each do |contributor| %>
|
|
35
|
+
<contributor contributorType="<%= contributor['contributorType'] %>">
|
|
36
|
+
<contributorName nameType="Personal"><%= contributor['familyName'] %>, <%= contributor['givenName'] %></contributorName>
|
|
37
|
+
<givenName><%= contributor['givenName'] %></givenName>
|
|
38
|
+
<familyName><%= contributor['familyName'] %></familyName>
|
|
39
|
+
<% if contributor['orcid'].present? %>
|
|
40
|
+
<nameIdentifier schemeURI="https://orcid.org/" nameIdentifierScheme="ORCID"><%= contributor['orcid'] %></nameIdentifier>
|
|
41
|
+
<% end %>
|
|
42
|
+
<% if contributor['affiliation'].present? || contributor['ror'].present? %>
|
|
43
|
+
<affiliation<% if contributor['ror'].present? %> affiliationIdentifier="<%= contributor['ror'] %>" affiliationIdentifierScheme="ROR" schemeURI="https://ror.org"<% end %>><%= contributor['affiliation'] %></affiliation>
|
|
44
|
+
<% end %>
|
|
45
|
+
</contributor>
|
|
46
|
+
<% end %>
|
|
47
|
+
</contributors>
|
|
48
|
+
<% end %>
|
|
49
|
+
<% if references.any? %>
|
|
50
|
+
<relatedIdentifiers>
|
|
51
|
+
<% references.each do |reference| %>
|
|
52
|
+
<relatedIdentifier relatedIdentifierType="DOI" relationType="References">https://dx.doi.org/<%= reference['doi'] %></relatedIdentifier>
|
|
53
|
+
<% end %>
|
|
54
|
+
</relatedIdentifiers>
|
|
55
|
+
<% end %>
|
|
56
|
+
<language>en</language>
|
|
57
|
+
<resourceType resourceTypeGeneral="Model"><%= resource_label %></resourceType>
|
|
58
|
+
<version><%= version %></version>
|
|
59
|
+
<rightsList>
|
|
60
|
+
<rights><%= license %></rights>
|
|
61
|
+
</rightsList>
|
|
62
|
+
<descriptions>
|
|
63
|
+
<% if description.present? %>
|
|
64
|
+
<description xml:lang="en-US" descriptionType="Abstract"><%= description %></description>
|
|
65
|
+
<% end %>
|
|
66
|
+
</descriptions>
|
|
67
|
+
</resource>
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
require 'tempfile'
|
|
7
|
+
require 'date'
|
|
8
|
+
|
|
9
|
+
# Optional parsers — the feature degrades to filename-only if a gem is absent.
|
|
10
|
+
begin
|
|
11
|
+
require 'pdf/reader'
|
|
12
|
+
rescue LoadError
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
begin
|
|
16
|
+
require 'roo'
|
|
17
|
+
rescue LoadError
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
module Labimotion
|
|
22
|
+
# FileExtractor turns an uploaded file (base64-encoded raw bytes + filename)
|
|
23
|
+
# into plain text usable as LLM context for the AI dataset-template feature.
|
|
24
|
+
#
|
|
25
|
+
# It NEVER raises into the caller. Any failure — missing gem, unsupported or
|
|
26
|
+
# oversized or corrupt file, parse error, timeout, out-of-memory — returns
|
|
27
|
+
# either '' or a short bracketed marker describing why, so the request keeps
|
|
28
|
+
# working with filename-only context.
|
|
29
|
+
module FileExtractor
|
|
30
|
+
TEXT_EXTS = %w[txt csv tsv md markdown json xml yml yaml].freeze
|
|
31
|
+
BINARY_EXTS = %w[pdf xlsx xlsm xls].freeze
|
|
32
|
+
ALLOWED_EXTS = (TEXT_EXTS + BINARY_EXTS).freeze
|
|
33
|
+
|
|
34
|
+
MAX_DECODED_BYTES = 15 * 1024 * 1024 # reject decoded payloads larger than this
|
|
35
|
+
MAX_UNCOMPRESSED = 80 * 1024 * 1024 # xlsx zip-bomb guard (sum of entry sizes)
|
|
36
|
+
MAX_OUT_CHARS = 8000 # per-file extracted-text cap
|
|
37
|
+
MAX_PDF_PAGES = 50
|
|
38
|
+
MAX_SHEETS = 5
|
|
39
|
+
MAX_ROWS = 100
|
|
40
|
+
MAX_COLS = 30
|
|
41
|
+
MAX_CELL = 200
|
|
42
|
+
EXTRACT_TIMEOUT = 20 # best-effort (cannot interrupt C-ext parsing)
|
|
43
|
+
|
|
44
|
+
# Magic bytes — forced to binary so start_with? never hits an
|
|
45
|
+
# Encoding::CompatibilityError against the ASCII-8BIT decoded bytes.
|
|
46
|
+
ZIP_MAGIC = "PK\x03\x04".b
|
|
47
|
+
OLE2_MAGIC = "\xD0\xCF\x11\xE0".b
|
|
48
|
+
PDF_MAGIC = '%PDF'.b
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# @param filename [String] used for the extension whitelist + dispatch
|
|
52
|
+
# @param content_base64 [String] base64 of the raw file bytes (data-URL prefix tolerated)
|
|
53
|
+
# @return [String] extracted text, a '[...]' marker, or ''
|
|
54
|
+
def extract(filename, content_base64)
|
|
55
|
+
extract_bytes(filename, decode(content_base64))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Same as #extract but for already-decoded RAW bytes (e.g. Attachment#read_file).
|
|
59
|
+
# Keeps every guard #extract has: extension whitelist, empty check,
|
|
60
|
+
# MAX_DECODED_BYTES cap, the extraction Timeout, output truncation and the
|
|
61
|
+
# four rescue clauses. NEVER raises into the caller.
|
|
62
|
+
#
|
|
63
|
+
# @param filename [String] used for the extension whitelist + dispatch
|
|
64
|
+
# @param bytes [String, nil] raw file bytes (any encoding; treated as binary)
|
|
65
|
+
# @return [String] extracted text, a '[...]' marker, or ''
|
|
66
|
+
def extract_bytes(filename, bytes)
|
|
67
|
+
ext = File.extname(filename.to_s).downcase.delete('.')
|
|
68
|
+
return '' unless ALLOWED_EXTS.include?(ext)
|
|
69
|
+
|
|
70
|
+
return '' if bytes.nil? || bytes.empty?
|
|
71
|
+
return '[file too large to extract]' if bytes.bytesize > MAX_DECODED_BYTES
|
|
72
|
+
|
|
73
|
+
truncate(Timeout.timeout(EXTRACT_TIMEOUT) { dispatch(ext, bytes) })
|
|
74
|
+
rescue Timeout::Error
|
|
75
|
+
'[extraction timed out]'
|
|
76
|
+
rescue NoMemoryError, SystemStackError => e
|
|
77
|
+
log(e)
|
|
78
|
+
'[file too complex to extract]'
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
log(e)
|
|
81
|
+
''
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def decode(content_base64)
|
|
87
|
+
str = content_base64.to_s.sub(/\Adata:[^;,]*;base64,/, '') # tolerate data-URL prefixes
|
|
88
|
+
return nil if str.strip.empty?
|
|
89
|
+
|
|
90
|
+
Base64.decode64(str)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Dispatch by magic bytes first (robust against spoofed/missing extensions),
|
|
94
|
+
# falling back to the extension.
|
|
95
|
+
def dispatch(ext, bytes)
|
|
96
|
+
head = bytes[0, 8].to_s.b
|
|
97
|
+
if head.start_with?(ZIP_MAGIC) || %w[xlsx xlsm].include?(ext)
|
|
98
|
+
extract_xlsx(bytes)
|
|
99
|
+
elsif head.start_with?(OLE2_MAGIC) || ext == 'xls'
|
|
100
|
+
extract_xls(bytes)
|
|
101
|
+
elsif head.start_with?(PDF_MAGIC) || ext == 'pdf'
|
|
102
|
+
extract_pdf(bytes)
|
|
103
|
+
else
|
|
104
|
+
extract_text(bytes)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def extract_pdf(bytes)
|
|
109
|
+
return '[PDF support unavailable on this server]' unless defined?(PDF::Reader)
|
|
110
|
+
|
|
111
|
+
reader = PDF::Reader.new(StringIO.new(bytes))
|
|
112
|
+
buf = +''
|
|
113
|
+
reader.pages.first(MAX_PDF_PAGES).each do |page|
|
|
114
|
+
buf << page.text.to_s << "\n\n"
|
|
115
|
+
break if buf.length >= MAX_OUT_CHARS
|
|
116
|
+
end
|
|
117
|
+
text = buf.scrub('').strip
|
|
118
|
+
text.empty? ? '[no extractable text - likely a scanned/image PDF]' : text
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
log(e)
|
|
121
|
+
e.class.name.to_s.include?('Encrypted') ? '[encrypted PDF - no text extracted]' : ''
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def extract_xlsx(bytes)
|
|
125
|
+
return '[Excel support unavailable on this server]' unless defined?(Roo::Excelx)
|
|
126
|
+
return '[Excel file too large to extract]' if xlsx_uncompressed_too_big?(bytes)
|
|
127
|
+
|
|
128
|
+
render_workbook(Roo::Excelx.new(StringIO.new(bytes)))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Legacy OLE2 .xls needs the roo-xls gem (not bundled here) and a real path.
|
|
132
|
+
def extract_xls(bytes)
|
|
133
|
+
return '[legacy .xls not supported on this server]' unless defined?(Roo::Excel)
|
|
134
|
+
|
|
135
|
+
Tempfile.create(['labimotion-upload', '.xls']) do |f|
|
|
136
|
+
f.binmode
|
|
137
|
+
f.write(bytes)
|
|
138
|
+
f.flush
|
|
139
|
+
render_workbook(Roo::Spreadsheet.open(f.path))
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def extract_text(bytes)
|
|
144
|
+
bytes.dup.force_encoding('UTF-8').scrub('')[0, MAX_OUT_CHARS].to_s
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Zip-bomb guard: sum the uncompressed entry sizes before roo inflates the
|
|
148
|
+
# archive. roo bundles rubyzip, so Zip is available when roo loaded.
|
|
149
|
+
def xlsx_uncompressed_too_big?(bytes)
|
|
150
|
+
return false unless defined?(Zip)
|
|
151
|
+
|
|
152
|
+
total = 0
|
|
153
|
+
Zip::File.open_buffer(StringIO.new(bytes)) do |zip|
|
|
154
|
+
zip.each do |entry|
|
|
155
|
+
total += entry.size.to_i
|
|
156
|
+
return true if total > MAX_UNCOMPRESSED
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
false
|
|
160
|
+
rescue StandardError
|
|
161
|
+
false # if we can't pre-inspect, let roo try (still bounded by caps + timeout)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Render up to MAX_SHEETS sheets, each with a "## <name>" header and a
|
|
165
|
+
# capped, pipe-delimited row dump, under a shared output-char budget.
|
|
166
|
+
def render_workbook(book)
|
|
167
|
+
buf = +''
|
|
168
|
+
names = Array(book.sheets)
|
|
169
|
+
names.first(MAX_SHEETS).each do |name|
|
|
170
|
+
sheet = book.sheet(name)
|
|
171
|
+
next unless sheet.last_row # skip empty sheets
|
|
172
|
+
|
|
173
|
+
buf << "## #{name}\n"
|
|
174
|
+
last = [sheet.last_row, sheet.first_row + MAX_ROWS - 1].min
|
|
175
|
+
(sheet.first_row..last).each do |i|
|
|
176
|
+
line = +''
|
|
177
|
+
line << sheet.row(i).first(MAX_COLS).map { |c| fmt_cell(c) }.join(' | ') << "\n"
|
|
178
|
+
break if buf.length + line.length > MAX_OUT_CHARS
|
|
179
|
+
|
|
180
|
+
buf << line
|
|
181
|
+
end
|
|
182
|
+
buf << "[+#{sheet.last_row - last} more rows]\n" if sheet.last_row > last
|
|
183
|
+
break if buf.length >= MAX_OUT_CHARS
|
|
184
|
+
end
|
|
185
|
+
buf << "[+#{names.size - MAX_SHEETS} more sheets omitted]\n" if names.size > MAX_SHEETS
|
|
186
|
+
buf
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# roo returns Date/Time/DateTime objects and nil for empty cells.
|
|
190
|
+
def fmt_cell(cell)
|
|
191
|
+
case cell
|
|
192
|
+
when nil then ''
|
|
193
|
+
when Date, Time, DateTime then cell.iso8601
|
|
194
|
+
else cell.to_s[0, MAX_CELL]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def truncate(str)
|
|
199
|
+
s = str.to_s
|
|
200
|
+
s.length > MAX_OUT_CHARS ? "#{s[0, MAX_OUT_CHARS]}\n…[truncated]" : s
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def log(err)
|
|
204
|
+
Labimotion.log_exception(err) if Labimotion.respond_to?(:log_exception)
|
|
205
|
+
rescue StandardError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# `index_by` below is an ActiveSupport core extension, not plain Ruby. A booted host always
|
|
4
|
+
# has it, so the omission was invisible in production, but relying on the host to have loaded
|
|
5
|
+
# it is luck rather than contract -- and in the gem's own spec process it made this file
|
|
6
|
+
# depend on whether some other spec had required grape first.
|
|
7
|
+
require 'active_support/core_ext/enumerable'
|
|
8
|
+
|
|
9
|
+
module Labimotion
|
|
10
|
+
## OwnerResolver
|
|
11
|
+
# Builds the `owners:` option the klass entities read, so rendering a grid of N templates
|
|
12
|
+
# costs one user query instead of N. Deleted users are included on purpose: a template whose
|
|
13
|
+
# author has left still has to name somebody, otherwise the grid can only say "unknown" and
|
|
14
|
+
# an administrator has nothing to act on.
|
|
15
|
+
class OwnerResolver
|
|
16
|
+
def self.map_for(klasses)
|
|
17
|
+
map_for_ids(Array(klasses).filter_map { |klass| klass.try(:created_by) })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Same map, from bare user ids — what ShareResolver feeds it, where the ids come from
|
|
21
|
+
# owner share rows rather than a created_by column.
|
|
22
|
+
def self.map_for_ids(ids)
|
|
23
|
+
ids = Array(ids).compact.uniq
|
|
24
|
+
return {} if ids.empty?
|
|
25
|
+
|
|
26
|
+
user_scope.where(id: ids).index_by(&:id)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
# Ownership is decoration on a list that must still render.
|
|
29
|
+
Labimotion.log_exception(e)
|
|
30
|
+
{}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Single-user fallback for the call sites that ask for ownership without preloading a map.
|
|
34
|
+
# Same soft-delete scope and same "never break the caller" contract as `map_for`.
|
|
35
|
+
def self.find(user_id)
|
|
36
|
+
return nil if user_id.blank?
|
|
37
|
+
|
|
38
|
+
user_scope.find_by(id: user_id)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
Labimotion.log_exception(e)
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# `with_deleted` comes from acts_as_paranoid, which the host's User model may not use.
|
|
45
|
+
def self.user_scope
|
|
46
|
+
::User.respond_to?(:with_deleted) ? ::User.with_deleted : ::User
|
|
47
|
+
end
|
|
48
|
+
private_class_method :user_scope
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# `constantize` and `underscore` below are ActiveSupport core extensions, not plain Ruby.
|
|
4
|
+
# A booted host always has them, so the omission is invisible in production, but the gem's
|
|
5
|
+
# own spec process does not load them on its own.
|
|
6
|
+
require 'active_support/core_ext/string/inflections'
|
|
7
|
+
require 'labimotion/constants'
|
|
8
|
+
|
|
9
|
+
module Labimotion
|
|
10
|
+
## OwnershipAudit
|
|
11
|
+
# Reports templates whose `created_by` may no longer name the person who wrote them.
|
|
12
|
+
#
|
|
13
|
+
# Why this exists: until the fix in this same change, `upload_klass` merged
|
|
14
|
+
# `created_by: current_user.id` into the attributes it handed to `validate_klass`, whose
|
|
15
|
+
# *update* branch then wrote them wholesale — so every re-import of an existing template
|
|
16
|
+
# reassigned its authorship to whoever re-imported it. The fix stops new damage; it cannot
|
|
17
|
+
# undo what already happened, and `created_by` is both what the Owner column displays and
|
|
18
|
+
# what per-template ownership will be seeded from.
|
|
19
|
+
#
|
|
20
|
+
# What it can and cannot tell you: a klass revision records `created_by` as the user who
|
|
21
|
+
# *released* that revision, not the one who created the klass. So a mismatch between a
|
|
22
|
+
# klass's `created_by` and its earliest revision's is a **signal** that authorship moved,
|
|
23
|
+
# not proof — a template legitimately first released by a colleague looks the same. This is
|
|
24
|
+
# a report for a human to read. It gates nothing, changes nothing, and writes nothing.
|
|
25
|
+
#
|
|
26
|
+
# Run it from the host:
|
|
27
|
+
# bundle exec rails runner 'Labimotion::OwnershipAudit.report'
|
|
28
|
+
class OwnershipAudit
|
|
29
|
+
def self.report(io = $stdout)
|
|
30
|
+
findings = Labimotion::Constants::Klass::ALL.flat_map { |klass_name| findings_for(klass_name) }
|
|
31
|
+
io.puts(format_report(findings))
|
|
32
|
+
findings
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.findings_for(klass_name)
|
|
36
|
+
model = "Labimotion::#{klass_name}".constantize
|
|
37
|
+
revision_model = "Labimotion::#{klass_name}esRevision".constantize
|
|
38
|
+
foreign_key = "#{klass_name.underscore}_id"
|
|
39
|
+
|
|
40
|
+
model.all.filter_map do |klass|
|
|
41
|
+
first_revision = revision_model.where(foreign_key => klass.id).order(:created_at).first
|
|
42
|
+
next if first_revision.nil?
|
|
43
|
+
next if first_revision.created_by.blank? || klass.created_by == first_revision.created_by
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
klass: klass_name,
|
|
47
|
+
id: klass.id,
|
|
48
|
+
label: klass.label,
|
|
49
|
+
created_by: klass.created_by,
|
|
50
|
+
first_release_by: first_revision.created_by,
|
|
51
|
+
first_released_at: first_revision.created_at
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
Labimotion.log_exception(e)
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.format_report(findings)
|
|
60
|
+
lines = ["Labimotion ownership audit — #{findings.size} template(s) to look at"]
|
|
61
|
+
lines << 'created_by differs from the user who released the earliest revision. ' \
|
|
62
|
+
'Expected for templates a colleague first released; suspicious for the rest.'
|
|
63
|
+
lines << ''
|
|
64
|
+
findings.each do |f|
|
|
65
|
+
lines << "#{f[:klass].ljust(13)} ##{f[:id].to_s.ljust(5)} " \
|
|
66
|
+
"#{f[:label].to_s[0, 40].ljust(40)} created_by=#{f[:created_by]} " \
|
|
67
|
+
"first_release_by=#{f[:first_release_by]} (#{f[:first_released_at]})"
|
|
68
|
+
end
|
|
69
|
+
lines.join("\n")
|
|
70
|
+
end
|
|
71
|
+
private_class_method :format_report
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Labimotion
|
|
4
|
+
# Courtesy notifications for share events (template-sharing.md §7), moved in
|
|
5
|
+
# from the host's Chemotion::TemplateShareAPI (chem-generic-ui#1040): the gem
|
|
6
|
+
# writes the share rows, so the gem announces them, and the two-call dance the
|
|
7
|
+
# Designer used to do (write to the gem, then call the host back to notify)
|
|
8
|
+
# collapses into the write.
|
|
9
|
+
#
|
|
10
|
+
# These messages buy awareness *outside* the Designer only — the toast in the
|
|
11
|
+
# main ELN. klass_shares is the record and the Designer reads the rows; it
|
|
12
|
+
# never reads or acks a Message. Nothing here may therefore fail a share:
|
|
13
|
+
# every public method rescues to nil, and an undeliverable notification is a
|
|
14
|
+
# notification not sent, never an error. Callers fire after the successful
|
|
15
|
+
# write and ignore the return value.
|
|
16
|
+
#
|
|
17
|
+
# The host models are reached by their bare names and only if they exist:
|
|
18
|
+
# a host without Message/Channel machinery (or one that has not run the
|
|
19
|
+
# channel migration) simply sends nothing. The channel is looked up by its
|
|
20
|
+
# literal subject — referencing Channel::TEMPLATE_SHARE_NOTIFICATION would
|
|
21
|
+
# raise on hosts whose Channel model predates the constant.
|
|
22
|
+
module ShareNotifier
|
|
23
|
+
# Must match the subject the host ELN's channel migration seeds
|
|
24
|
+
# (add_template_share_notification_channel; channel_type 8, the targeted
|
|
25
|
+
# per-user delivery branch of generate_notifications).
|
|
26
|
+
CHANNEL_SUBJECT = 'Template Share Notification'
|
|
27
|
+
|
|
28
|
+
# Fired for level changes too — the grant endpoint is an upsert and cannot
|
|
29
|
+
# tell a fresh share from a level change, so "granted you X access" stays
|
|
30
|
+
# honest for both.
|
|
31
|
+
def self.grant(klass:, actor:, user_ids:, level:)
|
|
32
|
+
label = template_label(klass)
|
|
33
|
+
ping(
|
|
34
|
+
actor: actor, user_ids: user_ids,
|
|
35
|
+
text: "#{actor.name} granted you #{level} access to the template '#{label}'."
|
|
36
|
+
)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
Labimotion.log_exception(e, actor)
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.transfer(klass:, actor:, user_id:)
|
|
43
|
+
label = template_label(klass)
|
|
44
|
+
ping(
|
|
45
|
+
actor: actor, user_ids: [user_id],
|
|
46
|
+
text: "#{actor.name} transferred ownership of the template '#{label}' to you."
|
|
47
|
+
)
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
Labimotion.log_exception(e, actor)
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The owner to ask: the klass_shares owner row, falling back to the
|
|
54
|
+
# immutable created_by for an owner-less template. A blank owner matches no
|
|
55
|
+
# user and channel_type-8 fan-out reaches Persons only, so an Admin owner
|
|
56
|
+
# gets no toast — they learn about the request when they next open the
|
|
57
|
+
# Designer, where the rows are the truth. The caller gates on `created`:
|
|
58
|
+
# the unique index dedupes rows, not messages, so only the ask that wrote
|
|
59
|
+
# the row earns a ping.
|
|
60
|
+
def self.request(klass:, actor:)
|
|
61
|
+
owner_id = Labimotion::KlassShare.owner_row_for(klass)&.shared_with_id ||
|
|
62
|
+
klass.created_by
|
|
63
|
+
return if owner_id.nil?
|
|
64
|
+
|
|
65
|
+
label = template_label(klass)
|
|
66
|
+
ping(
|
|
67
|
+
actor: actor, user_ids: [owner_id],
|
|
68
|
+
text: "#{actor.name} asks for access to your template '#{label}'. " \
|
|
69
|
+
'You can grant it from the Share dialog in the Designer.'
|
|
70
|
+
)
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
Labimotion.log_exception(e, actor)
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Plain Ruby, no ActiveSupport (`presence`/`try`): the host models are
|
|
77
|
+
# optional here, so the module must not assume the host's core extensions
|
|
78
|
+
# either — with them absent, a notification would silently never send.
|
|
79
|
+
def self.template_label(klass)
|
|
80
|
+
read_name(klass, :label) || read_name(klass, :name) || "##{klass.id}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.read_name(klass, attribute)
|
|
84
|
+
return nil unless klass.respond_to?(attribute)
|
|
85
|
+
|
|
86
|
+
value = klass.public_send(attribute).to_s
|
|
87
|
+
value.strip.empty? ? nil : value
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.ping(actor:, user_ids:, text:)
|
|
91
|
+
return unless defined?(::Message) && defined?(::Channel)
|
|
92
|
+
|
|
93
|
+
# Persons only (the fan-out inserts notifications for Persons alone), and
|
|
94
|
+
# never the actor: granting yourself a toast about your own click is
|
|
95
|
+
# noise, and a self-transfer is a no-op to announce.
|
|
96
|
+
recipients = ::User.where(type: 'Person', id: user_ids).ids - [actor.id]
|
|
97
|
+
return if recipients.empty?
|
|
98
|
+
|
|
99
|
+
channel = ::Channel.find_by(subject: CHANNEL_SUBJECT)
|
|
100
|
+
return if channel.nil?
|
|
101
|
+
|
|
102
|
+
::Message.create_msg_notification(
|
|
103
|
+
channel_id: channel.id,
|
|
104
|
+
# The level rides inside message_content: create_msg_notification uses
|
|
105
|
+
# the content verbatim on this path, so a bare level: kwarg would be
|
|
106
|
+
# dropped and the toast would fall back to warning styling.
|
|
107
|
+
message_content: { data: text, level: 'info' },
|
|
108
|
+
message_from: actor.id,
|
|
109
|
+
message_to: recipients
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
private_class_method :ping, :template_label, :read_name
|
|
113
|
+
end
|
|
114
|
+
end
|