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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -1
  3. data/app/assets/stylesheets/geoblacklight_admin/_core.scss +2 -1
  4. data/app/assets/stylesheets/geoblacklight_admin/modules/_icons.scss +9 -0
  5. data/app/assets/stylesheets/geoblacklight_admin/modules/_pagy.scss +7 -0
  6. data/app/controllers/admin/document_data_dictionaries_controller.rb +126 -0
  7. data/app/controllers/admin/document_data_dictionary_entries_controller.rb +124 -0
  8. data/app/controllers/admin/document_distributions_controller.rb +0 -38
  9. data/app/controllers/admin/import_distributions_controller.rb +126 -0
  10. data/app/controllers/concerns/blacklight/catalog.rb +373 -0
  11. data/app/helpers/facets_helper.rb +19 -0
  12. data/app/javascript/index.js +1 -1
  13. data/app/jobs/import_distributions_run_job.rb +32 -0
  14. data/app/jobs/import_document_distribution_job.rb +23 -0
  15. data/app/models/document.rb +5 -0
  16. data/app/models/document_data_dictionary/csv_header_validator.rb +30 -0
  17. data/app/models/document_data_dictionary.rb +39 -0
  18. data/app/models/document_data_dictionary_entry.rb +9 -0
  19. data/app/models/import_distribution/csv_header_validator.rb +32 -0
  20. data/app/models/import_distribution.rb +55 -0
  21. data/app/models/import_distribution_state_machine.rb +14 -0
  22. data/app/models/import_distribution_transition.rb +26 -0
  23. data/app/models/import_document_distribution.rb +25 -0
  24. data/app/models/import_document_distribution_state_machine.rb +13 -0
  25. data/app/models/import_document_distribution_transition.rb +19 -0
  26. data/app/views/admin/document_data_dictionaries/_data_dictionaries_table.html.erb +40 -0
  27. data/app/views/admin/document_data_dictionaries/_document_data_dictionary.html.erb +37 -0
  28. data/app/views/admin/document_data_dictionaries/_document_data_dictionary.json.jbuilder +2 -0
  29. data/app/views/admin/document_data_dictionaries/_form.html.erb +17 -0
  30. data/app/views/admin/document_data_dictionaries/edit.html.erb +12 -0
  31. data/app/views/admin/document_data_dictionaries/index.html.erb +45 -0
  32. data/app/views/admin/document_data_dictionaries/index.json.jbuilder +1 -0
  33. data/app/views/admin/document_data_dictionaries/new.html.erb +78 -0
  34. data/app/views/admin/document_data_dictionaries/show.html.erb +78 -0
  35. data/app/views/admin/document_data_dictionaries/show.json.jbuilder +1 -0
  36. data/app/views/admin/document_data_dictionary_entries/_form.html.erb +19 -0
  37. data/app/views/admin/document_data_dictionary_entries/edit.html.erb +12 -0
  38. data/app/views/admin/document_data_dictionary_entries/new.html.erb +16 -0
  39. data/app/views/admin/document_distributions/index.html.erb +2 -2
  40. data/app/views/admin/documents/_form_nav.html.erb +4 -0
  41. data/app/views/admin/import_distributions/_form.html.erb +21 -0
  42. data/app/views/admin/import_distributions/_import_distribution.json.jbuilder +5 -0
  43. data/app/views/admin/import_distributions/_show_failed_tab.html.erb +36 -0
  44. data/app/views/admin/import_distributions/_show_success_tab.html.erb +35 -0
  45. data/app/views/admin/import_distributions/edit.html.erb +8 -0
  46. data/app/views/admin/import_distributions/index.html.erb +58 -0
  47. data/app/views/admin/import_distributions/index.json.jbuilder +3 -0
  48. data/app/views/admin/import_distributions/new.html.erb +7 -0
  49. data/app/views/admin/import_distributions/show.html.erb +121 -0
  50. data/app/views/admin/import_distributions/show.json.jbuilder +3 -0
  51. data/app/views/admin/imports/index.html.erb +2 -2
  52. data/app/views/admin/shared/_navbar.html.erb +2 -2
  53. data/app/views/catalog/_facet_pagination.html.erb +6 -0
  54. data/app/views/catalog/_gbl_admin_data_dictionaries.html.erb +3 -0
  55. data/app/views/catalog/_paginate_compact.html.erb +9 -0
  56. data/app/views/catalog/_results_pagination.html.erb +9 -0
  57. data/app/views/catalog/_show_gbl_admin_data_dictionaries.html.erb +44 -0
  58. data/app/views/catalog/data_dictionaries.html.erb +12 -0
  59. data/db/migrate/20241204163117_create_document_data_dictionaries.rb +14 -0
  60. data/db/migrate/20241218174455_create_document_data_dictionary_entries.rb +17 -0
  61. data/db/migrate/20250113213655_import_distributions.rb +56 -0
  62. data/lib/generators/geoblacklight_admin/config_generator.rb +61 -4
  63. data/lib/generators/geoblacklight_admin/templates/btaa_sample_document_data_dictionary_entries.csv +9 -0
  64. data/lib/generators/geoblacklight_admin/templates/btaa_sample_document_distributions.csv +17 -0
  65. data/lib/generators/geoblacklight_admin/templates/config/initializers/pagy.rb +53 -83
  66. data/lib/geoblacklight_admin/engine.rb +1 -0
  67. data/lib/geoblacklight_admin/routes/data_dictionariesable.rb +17 -0
  68. data/lib/geoblacklight_admin/version.rb +1 -1
  69. 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
@@ -11,4 +11,4 @@ window.Stimulus = Application.start()
11
11
  Stimulus.register("results", ResultsController)
12
12
 
13
13
  // Import channels
14
- import '../channels';
14
+ import './channels';
@@ -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
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DocumentDataDictionaryEntry < ApplicationRecord
4
+ # Associations
5
+ belongs_to :document_data_dictionary
6
+
7
+ # Validations
8
+ validates :friendlier_id, :field_name, presence: true
9
+ 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