geoblacklight_admin 0.6.3 → 0.7.1
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/README.md +2 -1
- data/app/assets/stylesheets/geoblacklight_admin/_core.scss +2 -1
- data/app/assets/stylesheets/geoblacklight_admin/modules/_icons.scss +9 -0
- data/app/assets/stylesheets/geoblacklight_admin/modules/_pagy.scss +7 -0
- data/app/controllers/admin/document_data_dictionaries_controller.rb +126 -0
- data/app/controllers/admin/document_data_dictionary_entries_controller.rb +124 -0
- data/app/controllers/admin/document_distributions_controller.rb +0 -38
- data/app/controllers/admin/import_distributions_controller.rb +126 -0
- data/app/controllers/concerns/blacklight/catalog.rb +373 -0
- data/app/helpers/facets_helper.rb +19 -0
- data/app/javascript/index.js +1 -1
- data/app/jobs/import_distributions_run_job.rb +32 -0
- data/app/jobs/import_document_distribution_job.rb +23 -0
- data/app/models/document.rb +5 -0
- data/app/models/document_data_dictionary/csv_header_validator.rb +30 -0
- data/app/models/document_data_dictionary.rb +39 -0
- data/app/models/document_data_dictionary_entry.rb +9 -0
- data/app/models/import_distribution/csv_header_validator.rb +32 -0
- data/app/models/import_distribution.rb +55 -0
- data/app/models/import_distribution_state_machine.rb +14 -0
- data/app/models/import_distribution_transition.rb +26 -0
- data/app/models/import_document_distribution.rb +25 -0
- data/app/models/import_document_distribution_state_machine.rb +13 -0
- data/app/models/import_document_distribution_transition.rb +19 -0
- data/app/views/admin/document_data_dictionaries/_data_dictionaries_table.html.erb +40 -0
- data/app/views/admin/document_data_dictionaries/_document_data_dictionary.html.erb +37 -0
- data/app/views/admin/document_data_dictionaries/_document_data_dictionary.json.jbuilder +2 -0
- data/app/views/admin/document_data_dictionaries/_form.html.erb +17 -0
- data/app/views/admin/document_data_dictionaries/edit.html.erb +12 -0
- data/app/views/admin/document_data_dictionaries/index.html.erb +45 -0
- data/app/views/admin/document_data_dictionaries/index.json.jbuilder +1 -0
- data/app/views/admin/document_data_dictionaries/new.html.erb +78 -0
- data/app/views/admin/document_data_dictionaries/show.html.erb +78 -0
- data/app/views/admin/document_data_dictionaries/show.json.jbuilder +1 -0
- data/app/views/admin/document_data_dictionary_entries/_form.html.erb +19 -0
- data/app/views/admin/document_data_dictionary_entries/edit.html.erb +12 -0
- data/app/views/admin/document_data_dictionary_entries/new.html.erb +16 -0
- data/app/views/admin/document_distributions/index.html.erb +2 -2
- data/app/views/admin/documents/_form_nav.html.erb +4 -0
- data/app/views/admin/import_distributions/_form.html.erb +21 -0
- data/app/views/admin/import_distributions/_import_distribution.json.jbuilder +5 -0
- data/app/views/admin/import_distributions/_show_failed_tab.html.erb +36 -0
- data/app/views/admin/import_distributions/_show_success_tab.html.erb +35 -0
- data/app/views/admin/import_distributions/edit.html.erb +8 -0
- data/app/views/admin/import_distributions/index.html.erb +58 -0
- data/app/views/admin/import_distributions/index.json.jbuilder +3 -0
- data/app/views/admin/import_distributions/new.html.erb +7 -0
- data/app/views/admin/import_distributions/show.html.erb +121 -0
- data/app/views/admin/import_distributions/show.json.jbuilder +3 -0
- data/app/views/admin/imports/index.html.erb +2 -2
- data/app/views/admin/shared/_navbar.html.erb +2 -2
- data/app/views/catalog/_facet_pagination.html.erb +6 -0
- data/app/views/catalog/_gbl_admin_data_dictionaries.html.erb +3 -0
- data/app/views/catalog/_paginate_compact.html.erb +9 -0
- data/app/views/catalog/_results_pagination.html.erb +9 -0
- data/app/views/catalog/_show_gbl_admin_data_dictionaries.html.erb +44 -0
- data/app/views/catalog/data_dictionaries.html.erb +12 -0
- data/db/migrate/20241204163117_create_document_data_dictionaries.rb +14 -0
- data/db/migrate/20241218174455_create_document_data_dictionary_entries.rb +17 -0
- data/db/migrate/20250113213655_import_distributions.rb +56 -0
- data/lib/generators/geoblacklight_admin/config_generator.rb +61 -4
- data/lib/generators/geoblacklight_admin/templates/btaa_sample_document_data_dictionary_entries.csv +9 -0
- data/lib/generators/geoblacklight_admin/templates/btaa_sample_document_distributions.csv +17 -0
- data/lib/generators/geoblacklight_admin/templates/config/initializers/pagy.rb +53 -83
- data/lib/geoblacklight_admin/engine.rb +1 -0
- data/lib/geoblacklight_admin/routes/data_dictionariesable.rb +17 -0
- data/lib/geoblacklight_admin/version.rb +1 -1
- metadata +61 -14
@@ -0,0 +1,373 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Blacklight::Catalog
|
4
|
+
include Pagy::Backend
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
# MimeResponds is part of ActionController::Base, but not ActionController::API
|
8
|
+
include ActionController::MimeResponds
|
9
|
+
|
10
|
+
Deprecation.silence(Blacklight::Base) do
|
11
|
+
include Blacklight::Base
|
12
|
+
end
|
13
|
+
|
14
|
+
include Blacklight::Facet
|
15
|
+
include Blacklight::Searchable
|
16
|
+
|
17
|
+
extend Deprecation
|
18
|
+
|
19
|
+
# The following code is executed when someone includes blacklight::catalog in their
|
20
|
+
# own controller.
|
21
|
+
included do
|
22
|
+
if respond_to? :helper_method
|
23
|
+
helper_method :sms_mappings, :has_search_parameters?, :facet_limit_for
|
24
|
+
end
|
25
|
+
|
26
|
+
helper Blacklight::Facet if respond_to? :helper
|
27
|
+
|
28
|
+
# The index action will more than likely throw this one.
|
29
|
+
# Example: when the standard query parser is used, and a user submits a "bad" query.
|
30
|
+
rescue_from Blacklight::Exceptions::InvalidRequest, with: :handle_request_error
|
31
|
+
|
32
|
+
record_search_parameters
|
33
|
+
end
|
34
|
+
|
35
|
+
# get search results from the solr index
|
36
|
+
def index
|
37
|
+
(@response, deprecated_document_list) = search_service.search_results
|
38
|
+
|
39
|
+
@document_list = ActiveSupport::Deprecation::DeprecatedObjectProxy.new(
|
40
|
+
deprecated_document_list,
|
41
|
+
"The @document_list instance variable is deprecated; use @response.documents instead.",
|
42
|
+
ActiveSupport::Deprecation.new("8.0", "blacklight")
|
43
|
+
)
|
44
|
+
|
45
|
+
@pagy = Pagy.new(
|
46
|
+
count: @response.total_count,
|
47
|
+
page: params[:page],
|
48
|
+
limit: params[:per_page]
|
49
|
+
)
|
50
|
+
|
51
|
+
respond_to do |format|
|
52
|
+
format.html { store_preferred_view }
|
53
|
+
format.rss { render layout: false }
|
54
|
+
format.atom { render layout: false }
|
55
|
+
format.json do
|
56
|
+
@presenter = Blacklight::JsonPresenter.new(@response,
|
57
|
+
blacklight_config)
|
58
|
+
end
|
59
|
+
additional_response_formats(format)
|
60
|
+
document_export_formats(format)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# get a single document from the index
|
65
|
+
# to add responses for formats other than html or json see _Blacklight::Document::Export_
|
66
|
+
def show
|
67
|
+
deprecated_response, @document = search_service.fetch(params[:id])
|
68
|
+
@response = ActiveSupport::Deprecation::DeprecatedObjectProxy.new(
|
69
|
+
deprecated_response,
|
70
|
+
"The @response instance variable is deprecated; use @document.response instead.",
|
71
|
+
ActiveSupport::Deprecation.new("8.0", "blacklight")
|
72
|
+
)
|
73
|
+
|
74
|
+
respond_to do |format|
|
75
|
+
format.html { @search_context = setup_next_and_previous_documents }
|
76
|
+
format.json
|
77
|
+
additional_export_formats(@document, format)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def advanced_search
|
82
|
+
(@response, _deprecated_document_list) = blacklight_advanced_search_form_search_service.search_results
|
83
|
+
end
|
84
|
+
|
85
|
+
# get a single document from the index
|
86
|
+
def raw
|
87
|
+
raise(ActionController::RoutingError, "Not Found") unless blacklight_config.raw_endpoint.enabled
|
88
|
+
|
89
|
+
_, @document = search_service.fetch(params[:id])
|
90
|
+
render json: @document
|
91
|
+
end
|
92
|
+
|
93
|
+
# updates the search counter (allows the show view to paginate)
|
94
|
+
def track
|
95
|
+
search_session["counter"] = params[:counter]
|
96
|
+
search_session["id"] = params[:search_id]
|
97
|
+
search_session["per_page"] = params[:per_page]
|
98
|
+
search_session["document_id"] = params[:document_id]
|
99
|
+
|
100
|
+
if params[:redirect] && (params[:redirect].starts_with?("/") || params[:redirect] =~ URI::DEFAULT_PARSER.make_regexp)
|
101
|
+
uri = URI.parse(params[:redirect])
|
102
|
+
path = uri.query ? "#{uri.path}?#{uri.query}" : uri.path
|
103
|
+
redirect_to path, status: :see_other
|
104
|
+
else
|
105
|
+
redirect_to({action: :show, id: params[:id]}, status: :see_other)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# displays values and pagination links for a single facet field
|
110
|
+
def facet
|
111
|
+
@facet = blacklight_config.facet_fields[params[:id]]
|
112
|
+
raise ActionController::RoutingError, "Not Found" unless @facet
|
113
|
+
|
114
|
+
@response = search_service.facet_field_response(@facet.key)
|
115
|
+
@display_facet = @response.aggregations[@facet.field]
|
116
|
+
|
117
|
+
@presenter = (@facet.presenter || Blacklight::FacetFieldPresenter).new(@facet, @display_facet, view_context)
|
118
|
+
@pagination = @presenter.paginator
|
119
|
+
|
120
|
+
@pagy = Pagy.new(
|
121
|
+
count: @response.total_count,
|
122
|
+
page_param: "facet.page",
|
123
|
+
page: params["facet.page"],
|
124
|
+
limit: 20
|
125
|
+
)
|
126
|
+
|
127
|
+
respond_to do |format|
|
128
|
+
format.html do
|
129
|
+
# Draw the partial for the "more" facet modal window:
|
130
|
+
return render layout: false if request.xhr?
|
131
|
+
# Otherwise draw the facet selector for users who have javascript disabled.
|
132
|
+
end
|
133
|
+
format.json
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# method to serve up XML OpenSearch description and JSON autocomplete response
|
138
|
+
def opensearch
|
139
|
+
respond_to do |format|
|
140
|
+
format.xml { render layout: false }
|
141
|
+
format.json { render json: search_service.opensearch_response }
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def suggest
|
146
|
+
respond_to do |format|
|
147
|
+
format.json do
|
148
|
+
render json: suggestions_service.suggestions
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
# @return [Array] first value is a Blacklight::Solr::Response and the second
|
154
|
+
# is a list of documents
|
155
|
+
def action_documents
|
156
|
+
deprecated_response, @documents = search_service.fetch(Array(params[:id]))
|
157
|
+
raise Blacklight::Exceptions::RecordNotFound if @documents.blank?
|
158
|
+
|
159
|
+
[deprecated_response, @documents]
|
160
|
+
end
|
161
|
+
|
162
|
+
def action_success_redirect_path
|
163
|
+
search_state.url_for_document(blacklight_config.document_model.new(id: params[:id]))
|
164
|
+
end
|
165
|
+
|
166
|
+
##
|
167
|
+
# Check if any search parameters have been set
|
168
|
+
# @return [Boolean]
|
169
|
+
def has_search_parameters?
|
170
|
+
params[:search_field].present? || search_state.has_constraints?
|
171
|
+
end
|
172
|
+
|
173
|
+
# TODO: deprecate this constant with #facet_limit_for
|
174
|
+
DEFAULT_FACET_LIMIT = 10
|
175
|
+
|
176
|
+
# Look up facet limit for given facet_field. Will look at config, and
|
177
|
+
# if config is 'true' will look up from Solr @response if available. If
|
178
|
+
# no limit is available, returns nil. Used from #add_facetting_to_solr
|
179
|
+
# to supply f.fieldname.facet.limit values in solr request (no @response
|
180
|
+
# available), and used in display (with @response available) to create
|
181
|
+
# a facet paginator with the right limit.
|
182
|
+
def facet_limit_for(facet_field)
|
183
|
+
facet = blacklight_config.facet_fields[facet_field]
|
184
|
+
return if facet.blank?
|
185
|
+
|
186
|
+
if facet.limit && @response && @response.aggregations[facet.field]
|
187
|
+
limit = @response.aggregations[facet.field].limit
|
188
|
+
|
189
|
+
if limit.nil? # we didn't get or a set a limit, so infer one.
|
190
|
+
facet.limit if facet.limit != true
|
191
|
+
elsif limit == -1 # limit -1 is solr-speak for unlimited
|
192
|
+
nil
|
193
|
+
else
|
194
|
+
limit.to_i - 1 # we added 1 to find out if we needed to paginate
|
195
|
+
end
|
196
|
+
elsif facet.limit
|
197
|
+
(facet.limit == true) ? DEFAULT_FACET_LIMIT : facet.limit
|
198
|
+
end
|
199
|
+
end
|
200
|
+
deprecation_deprecate facet_limit_for: "moving to private logic in Blacklight::FacetFieldPresenter"
|
201
|
+
|
202
|
+
private
|
203
|
+
|
204
|
+
#
|
205
|
+
# non-routable methods ->
|
206
|
+
#
|
207
|
+
|
208
|
+
def render_sms_action?(_config, _options)
|
209
|
+
sms_mappings.present?
|
210
|
+
end
|
211
|
+
|
212
|
+
##
|
213
|
+
# If the params specify a view, then store it in the session. If the params
|
214
|
+
# do not specifiy the view, set the view parameter to the value stored in the
|
215
|
+
# session. This enables a user with a session to do subsequent searches and have
|
216
|
+
# them default to the last used view.
|
217
|
+
def store_preferred_view
|
218
|
+
session[:preferred_view] = params[:view] if params[:view]
|
219
|
+
end
|
220
|
+
|
221
|
+
##
|
222
|
+
# Render additional response formats for the index action, as provided by the
|
223
|
+
# blacklight configuration
|
224
|
+
# @param [Hash] format
|
225
|
+
# @note Make sure your format has a well known mime-type or is registered in config/initializers/mime_types.rb
|
226
|
+
# @example
|
227
|
+
# config.index.respond_to.txt = Proc.new { render plain: "A list of docs." }
|
228
|
+
def additional_response_formats(format)
|
229
|
+
blacklight_config.view_config(action_name: :index).respond_to.each do |key, config|
|
230
|
+
format.send key do
|
231
|
+
case config
|
232
|
+
when false
|
233
|
+
raise ActionController::RoutingError, "Not Found"
|
234
|
+
when Hash
|
235
|
+
render config
|
236
|
+
when Proc
|
237
|
+
instance_exec(&config)
|
238
|
+
when Symbol, String
|
239
|
+
send config
|
240
|
+
else
|
241
|
+
render({})
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
##
|
248
|
+
# Render additional export formats for the show action, as provided by
|
249
|
+
# the document extension framework. See _Blacklight::Document::Export_
|
250
|
+
def additional_export_formats(document, format)
|
251
|
+
document.export_formats.each_key do |format_name|
|
252
|
+
format.send(format_name.to_sym) { render body: document.export_as(format_name), layout: false }
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
##
|
257
|
+
# Try to render a response from the document export formats available
|
258
|
+
def document_export_formats(format)
|
259
|
+
format.any do
|
260
|
+
format_name = params.fetch(:format, "").to_sym
|
261
|
+
if @response.export_formats.include? format_name
|
262
|
+
render_document_export_format format_name
|
263
|
+
else
|
264
|
+
raise ActionController::UnknownFormat
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
##
|
270
|
+
# Render the document export formats for a response
|
271
|
+
# First, try to render an appropriate template (e.g. index.endnote.erb)
|
272
|
+
# If that fails, just concatenate the document export responses with a newline.
|
273
|
+
def render_document_export_format format_name
|
274
|
+
render
|
275
|
+
rescue ActionView::MissingTemplate
|
276
|
+
render plain: @response.documents.map { |x| x.export_as(format_name) if x.exports_as? format_name }.compact.join("\n"), layout: false
|
277
|
+
end
|
278
|
+
|
279
|
+
# Overrides the Blacklight::Controller provided #search_action_url.
|
280
|
+
# By default, any search action from a Blacklight::Catalog controller
|
281
|
+
# should use the current controller when constructing the route.
|
282
|
+
def search_action_url options = {}
|
283
|
+
options = options.to_h if options.is_a? Blacklight::SearchState
|
284
|
+
url_for(options.reverse_merge(action: "index"))
|
285
|
+
end
|
286
|
+
|
287
|
+
# Email Action (this will render the appropriate view on GET requests and process the form and send the email on POST requests)
|
288
|
+
def email_action documents
|
289
|
+
mail = RecordMailer.email_record(documents, {to: params[:to], message: params[:message], config: blacklight_config}, url_options)
|
290
|
+
if mail.respond_to? :deliver_now
|
291
|
+
mail.deliver_now
|
292
|
+
else
|
293
|
+
mail.deliver
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
# SMS action (this will render the appropriate view on GET requests and process the form and send the email on POST requests)
|
298
|
+
def sms_action documents
|
299
|
+
to = "#{params[:to].gsub(/[^\d]/, "")}@#{params[:carrier]}"
|
300
|
+
mail = RecordMailer.sms_record(documents, {to: to, config: blacklight_config}, url_options)
|
301
|
+
if mail.respond_to? :deliver_now
|
302
|
+
mail.deliver_now
|
303
|
+
else
|
304
|
+
mail.deliver
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
def validate_sms_params
|
309
|
+
if params[:to].blank?
|
310
|
+
flash[:error] = I18n.t("blacklight.sms.errors.to.blank")
|
311
|
+
elsif params[:carrier].blank?
|
312
|
+
flash[:error] = I18n.t("blacklight.sms.errors.carrier.blank")
|
313
|
+
elsif params[:to].gsub(/[^\d]/, "").length != 10
|
314
|
+
flash[:error] = I18n.t("blacklight.sms.errors.to.invalid", to: params[:to])
|
315
|
+
elsif !sms_mappings.value?(params[:carrier])
|
316
|
+
flash[:error] = I18n.t("blacklight.sms.errors.carrier.invalid")
|
317
|
+
end
|
318
|
+
|
319
|
+
flash[:error].blank?
|
320
|
+
end
|
321
|
+
|
322
|
+
def sms_mappings
|
323
|
+
Blacklight::Engine.config.blacklight.sms_mappings
|
324
|
+
end
|
325
|
+
|
326
|
+
def validate_email_params
|
327
|
+
if params[:to].blank?
|
328
|
+
flash[:error] = I18n.t("blacklight.email.errors.to.blank")
|
329
|
+
elsif !params[:to].match(Blacklight::Engine.config.blacklight.email_regexp)
|
330
|
+
flash[:error] = I18n.t("blacklight.email.errors.to.invalid", to: params[:to])
|
331
|
+
end
|
332
|
+
|
333
|
+
flash[:error].blank?
|
334
|
+
end
|
335
|
+
|
336
|
+
def start_new_search_session?
|
337
|
+
action_name == "index"
|
338
|
+
end
|
339
|
+
|
340
|
+
def determine_layout
|
341
|
+
(action_name == "show") ? "catalog_result" : super
|
342
|
+
end
|
343
|
+
|
344
|
+
# when a method throws a Blacklight::Exceptions::InvalidRequest, this method is executed.
|
345
|
+
def handle_request_error(exception)
|
346
|
+
# Rails own code will catch and give usual Rails error page with stack trace
|
347
|
+
raise exception if Rails.env.development? || Rails.env.test?
|
348
|
+
|
349
|
+
flash_notice = I18n.t("blacklight.search.errors.request_error")
|
350
|
+
|
351
|
+
# If there are errors coming from the index page, we want to trap those sensibly
|
352
|
+
|
353
|
+
if flash[:notice] == flash_notice
|
354
|
+
logger&.error "Cowardly aborting rsolr_request_error exception handling, because we redirected to a page that raises another exception"
|
355
|
+
raise exception
|
356
|
+
end
|
357
|
+
|
358
|
+
logger&.error exception
|
359
|
+
|
360
|
+
flash[:notice] = flash_notice
|
361
|
+
redirect_to search_action_url
|
362
|
+
end
|
363
|
+
|
364
|
+
def blacklight_advanced_search_form_search_service
|
365
|
+
form_search_state = search_state_class.new(blacklight_advanced_search_form_params, blacklight_config, self)
|
366
|
+
|
367
|
+
search_service_class.new(config: blacklight_config, search_state: form_search_state, user_params: form_search_state.to_h, **search_service_context)
|
368
|
+
end
|
369
|
+
|
370
|
+
def blacklight_advanced_search_form_params
|
371
|
+
{}
|
372
|
+
end
|
373
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FacetsHelper
|
4
|
+
def local_facet_sort_buttons(facet_field)
|
5
|
+
content_tag(:div, class: "sort-options btn-group") do
|
6
|
+
if params["facet.sort"] == "index"
|
7
|
+
content_tag(:span, "A-Z Sort", class: "active az btn btn-outline-secondary", data: {blacklight_modal: "preserve"}) +
|
8
|
+
link_to("Numerical Sort", facet_sort_url("count", facet_field), class: "sort_change numeric btn btn-outline-secondary", data: {blacklight_modal: "preserve"})
|
9
|
+
else
|
10
|
+
link_to("A-Z Sort", facet_sort_url("index", facet_field), class: "sort_change az btn btn-outline-secondary", data: {blacklight_modal: "preserve"}) +
|
11
|
+
content_tag(:span, "Numerical Sort", class: "active numeric btn btn-outline-secondary")
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def facet_sort_url(sort_type, facet_field)
|
17
|
+
url_for(request.query_parameters.merge(:controller => "catalog", :action => "facet", "facet_field" => facet_field, "facet.sort" => sort_type))
|
18
|
+
end
|
19
|
+
end
|
data/app/javascript/index.js
CHANGED
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# ImportDistributionsRunJob class
|
4
|
+
class ImportDistributionsRunJob < ApplicationJob
|
5
|
+
queue_as :priority
|
6
|
+
|
7
|
+
def perform(import)
|
8
|
+
data = CSV.parse(import.csv_file.download, headers: true)
|
9
|
+
|
10
|
+
data.each do |dist|
|
11
|
+
extract_hash = dist.to_h
|
12
|
+
|
13
|
+
document_distribution_hash = {
|
14
|
+
friendlier_id: extract_hash["friendlier_id"],
|
15
|
+
reference_type: extract_hash["reference_type"],
|
16
|
+
distribution_url: extract_hash["distribution_url"],
|
17
|
+
label: extract_hash["label"],
|
18
|
+
import_distribution_id: import.id
|
19
|
+
}
|
20
|
+
|
21
|
+
# Capture document distribution for import attempt
|
22
|
+
import_document_distribution = ImportDocumentDistribution.create(document_distribution_hash)
|
23
|
+
|
24
|
+
# Add import document distribution to background job queue
|
25
|
+
ImportDocumentDistributionJob.perform_later(import_document_distribution)
|
26
|
+
rescue => e
|
27
|
+
logger.debug "\n\nCANNOT IMPORT DISTRIBUTION: #{extract_hash.inspect}"
|
28
|
+
logger.debug "Error: #{e.inspect}\n\n"
|
29
|
+
next
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# ImportDocumentDistributionJob class
|
4
|
+
class ImportDocumentDistributionJob < ApplicationJob
|
5
|
+
queue_as :priority
|
6
|
+
|
7
|
+
def perform(import_document_distribution)
|
8
|
+
document_distribution = DocumentDistribution.find_or_create_by(
|
9
|
+
friendlier_id: import_document_distribution.friendlier_id,
|
10
|
+
reference_type: ReferenceType.find_by(name: import_document_distribution.reference_type),
|
11
|
+
url: import_document_distribution.distribution_url
|
12
|
+
)
|
13
|
+
|
14
|
+
if document_distribution.update(import_document_distribution.to_hash)
|
15
|
+
import_document_distribution.state_machine.transition_to!(:success)
|
16
|
+
else
|
17
|
+
import_document_distribution.state_machine.transition_to!(:failed, "Failed - #{document_distribution.errors.inspect}")
|
18
|
+
end
|
19
|
+
rescue => e
|
20
|
+
logger.debug("Error: #{e}")
|
21
|
+
import_document_distribution.state_machine.transition_to!(:failed, "Error - #{e.inspect}")
|
22
|
+
end
|
23
|
+
end
|
data/app/models/document.rb
CHANGED
@@ -21,6 +21,7 @@ class Document < Kithe::Work
|
|
21
21
|
# - Publication State
|
22
22
|
has_many :document_transitions, foreign_key: "kithe_model_id", autosave: false, dependent: :destroy,
|
23
23
|
inverse_of: :document
|
24
|
+
|
24
25
|
# - Thumbnail State
|
25
26
|
has_many :document_thumbnail_transitions, foreign_key: "kithe_model_id", autosave: false, dependent: :destroy,
|
26
27
|
inverse_of: :document
|
@@ -34,6 +35,10 @@ class Document < Kithe::Work
|
|
34
35
|
has_many :document_downloads, primary_key: "friendlier_id", foreign_key: "friendlier_id", autosave: false, dependent: :destroy,
|
35
36
|
inverse_of: :document
|
36
37
|
|
38
|
+
# - DocumentDataDictionaries
|
39
|
+
has_many :document_data_dictionaries, primary_key: "friendlier_id", foreign_key: "friendlier_id", autosave: false, dependent: :destroy,
|
40
|
+
inverse_of: :document
|
41
|
+
|
37
42
|
# - DocumentDistributions
|
38
43
|
has_many :document_distributions, primary_key: "friendlier_id", foreign_key: "friendlier_id", autosave: false, dependent: :destroy,
|
39
44
|
inverse_of: :document
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "csv"
|
4
|
+
|
5
|
+
# CSV Header Validation
|
6
|
+
class DocumentDataDictionary
|
7
|
+
# CsvHeaderValidator
|
8
|
+
class CsvHeaderValidator < ActiveModel::Validator
|
9
|
+
def validate(record)
|
10
|
+
valid_csv_header = true
|
11
|
+
unless valid_csv_headers?(record&.csv_file)
|
12
|
+
valid_csv_header = false
|
13
|
+
record.errors.add(:csv_file,
|
14
|
+
"Missing a required CSV header. friendlier_id, field_name, field_type, values, definition, definition_source, and parent_field_name are required.")
|
15
|
+
|
16
|
+
# Log the CSV file content
|
17
|
+
Rails.logger.error("CSV validation failed. CSV content: #{record.csv_file.download}")
|
18
|
+
end
|
19
|
+
|
20
|
+
valid_csv_header
|
21
|
+
end
|
22
|
+
|
23
|
+
def valid_csv_headers?(csv_file)
|
24
|
+
headers = CSV.parse(csv_file.download)[0]
|
25
|
+
(["friendlier_id", "field_name", "field_type", "values", "definition", "definition_source", "parent_field_name"] - headers).empty?
|
26
|
+
rescue ArgumentError, ActiveStorage::FileNotFoundError
|
27
|
+
false
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "csv"
|
4
|
+
|
5
|
+
class DocumentDataDictionary < ApplicationRecord
|
6
|
+
include ActiveModel::Validations
|
7
|
+
|
8
|
+
# Callbacks (keep at top)
|
9
|
+
after_save :parse_csv_file
|
10
|
+
|
11
|
+
# Associations
|
12
|
+
has_one_attached :csv_file
|
13
|
+
belongs_to :document, foreign_key: :friendlier_id, primary_key: :friendlier_id
|
14
|
+
has_many :document_data_dictionary_entries, -> { order(position: :asc) }, dependent: :destroy
|
15
|
+
|
16
|
+
# Validations
|
17
|
+
validates :name, presence: true
|
18
|
+
validates :csv_file, attached: true, content_type: {in: "text/csv", message: "is not a CSV file"}
|
19
|
+
|
20
|
+
validates_with DocumentDataDictionary::CsvHeaderValidator
|
21
|
+
|
22
|
+
def parse_csv_file
|
23
|
+
if csv_file.attached?
|
24
|
+
csv_data = CSV.parse(csv_file.download, headers: true)
|
25
|
+
csv_data.each do |row|
|
26
|
+
document_data_dictionary_entries.create!(row.to_h)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.sort_entries(id_array)
|
32
|
+
transaction do
|
33
|
+
logger.debug { id_array.inspect }
|
34
|
+
id_array.each_with_index do |entry_id, i|
|
35
|
+
DocumentDataDictionaryEntry.update(entry_id, position: i)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "csv"
|
4
|
+
|
5
|
+
# CSV Header Validation
|
6
|
+
class ImportDistribution
|
7
|
+
# CsvHeaderValidator
|
8
|
+
class CsvHeaderValidator < ActiveModel::Validator
|
9
|
+
def validate(record)
|
10
|
+
if record.csv_file.nil?
|
11
|
+
record.errors.add(:csv_file, "Missing a required CSV header. friendlier_id, reference_type, distribution_url, and label are required.")
|
12
|
+
return false
|
13
|
+
end
|
14
|
+
|
15
|
+
valid_csv_header = true
|
16
|
+
unless valid_csv_headers?(record&.csv_file)
|
17
|
+
valid_csv_header = false
|
18
|
+
record.errors.add(:csv_file,
|
19
|
+
"Missing a required CSV header. friendlier_id, reference_type, distribution_url, and label are required.")
|
20
|
+
end
|
21
|
+
|
22
|
+
valid_csv_header
|
23
|
+
end
|
24
|
+
|
25
|
+
def valid_csv_headers?(csv_file)
|
26
|
+
headers = CSV.parse(csv_file.download)[0]
|
27
|
+
(["friendlier_id", "reference_type", "distribution_url", "label"] - headers).empty?
|
28
|
+
rescue ArgumentError, ActiveStorage::FileNotFoundError
|
29
|
+
false
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "csv"
|
4
|
+
|
5
|
+
# ImportDistribution class
|
6
|
+
class ImportDistribution < ApplicationRecord
|
7
|
+
include ActiveModel::Validations
|
8
|
+
|
9
|
+
# Callbacks (keep at top)
|
10
|
+
after_commit :set_csv_file_attributes, if: :persisted?
|
11
|
+
|
12
|
+
# Associations
|
13
|
+
has_one_attached :csv_file
|
14
|
+
has_many :document_distributions, dependent: :destroy
|
15
|
+
has_many :import_document_distributions, dependent: :destroy
|
16
|
+
has_many :import_distribution_transitions, autosave: false, dependent: :destroy
|
17
|
+
|
18
|
+
# Validations
|
19
|
+
validates :csv_file, attached: true, content_type: {in: "text/csv", message: "is not a CSV file"}
|
20
|
+
|
21
|
+
validates_with ImportDistribution::CsvHeaderValidator
|
22
|
+
|
23
|
+
# States
|
24
|
+
include Statesman::Adapters::ActiveRecordQueries[
|
25
|
+
transition_class: ImportDistributionTransition,
|
26
|
+
initial_state: :created
|
27
|
+
]
|
28
|
+
|
29
|
+
def state_machine
|
30
|
+
@state_machine ||= ImportDistributionStateMachine.new(self, transition_class: ImportDistributionTransition)
|
31
|
+
end
|
32
|
+
|
33
|
+
def set_csv_file_attributes
|
34
|
+
parsed = CSV.parse(csv_file.download)
|
35
|
+
|
36
|
+
update_columns(
|
37
|
+
headers: parsed[0],
|
38
|
+
row_count: parsed.size - 1,
|
39
|
+
content_type: csv_file.content_type.to_s,
|
40
|
+
filename: csv_file.filename.to_s,
|
41
|
+
extension: csv_file.filename.extension.to_s
|
42
|
+
)
|
43
|
+
end
|
44
|
+
|
45
|
+
def run!
|
46
|
+
# @TODO: guard this call, unless mappings_valid?
|
47
|
+
|
48
|
+
# Queue Job
|
49
|
+
ImportDistributionsRunJob.perform_later(self)
|
50
|
+
|
51
|
+
# Capture State
|
52
|
+
state_machine.transition_to!(:imported)
|
53
|
+
save
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Import Distribution Statesman
|
4
|
+
class ImportDistributionStateMachine
|
5
|
+
include Statesman::Machine
|
6
|
+
|
7
|
+
state :created, initial: true
|
8
|
+
state :imported
|
9
|
+
state :success
|
10
|
+
state :failed
|
11
|
+
|
12
|
+
transition from: :created, to: [:imported]
|
13
|
+
transition from: :imported, to: %i[success failed]
|
14
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Add Import Distribution Statesman Transitions
|
4
|
+
class ImportDistributionTransition < ApplicationRecord
|
5
|
+
include Statesman::Adapters::ActiveRecordTransition
|
6
|
+
|
7
|
+
# If your transition table doesn't have the default `updated_at` timestamp column,
|
8
|
+
# you'll need to configure the `updated_timestamp_column` option, setting it to
|
9
|
+
# another column name (e.g. `:updated_on`) or `nil`.
|
10
|
+
#
|
11
|
+
# self.updated_timestamp_column = :updated_on
|
12
|
+
# self.updated_timestamp_column = nil
|
13
|
+
|
14
|
+
belongs_to :import_distribution, inverse_of: :import_distribution_transitions
|
15
|
+
|
16
|
+
after_destroy :update_most_recent, if: :most_recent?
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def update_most_recent
|
21
|
+
last_transition = import_distribution.import_distribution_transitions.order(:sort_key).last
|
22
|
+
return if last_transition.blank?
|
23
|
+
|
24
|
+
last_transition.update_column(:most_recent, true)
|
25
|
+
end
|
26
|
+
end
|