geoblacklight_admin 0.6.3 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -1
  3. data/app/assets/stylesheets/geoblacklight_admin/_core.scss +1 -1
  4. data/app/assets/stylesheets/geoblacklight_admin/modules/_icons.scss +9 -0
  5. data/app/controllers/admin/document_data_dictionaries_controller.rb +126 -0
  6. data/app/controllers/admin/document_data_dictionary_entries_controller.rb +124 -0
  7. data/app/controllers/admin/document_distributions_controller.rb +0 -38
  8. data/app/controllers/admin/import_distributions_controller.rb +126 -0
  9. data/app/javascript/index.js +1 -1
  10. data/app/jobs/import_distributions_run_job.rb +32 -0
  11. data/app/jobs/import_document_distribution_job.rb +23 -0
  12. data/app/models/document.rb +5 -0
  13. data/app/models/document_data_dictionary/csv_header_validator.rb +30 -0
  14. data/app/models/document_data_dictionary.rb +39 -0
  15. data/app/models/document_data_dictionary_entry.rb +9 -0
  16. data/app/models/import_distribution/csv_header_validator.rb +32 -0
  17. data/app/models/import_distribution.rb +55 -0
  18. data/app/models/import_distribution_state_machine.rb +14 -0
  19. data/app/models/import_distribution_transition.rb +26 -0
  20. data/app/models/import_document_distribution.rb +25 -0
  21. data/app/models/import_document_distribution_state_machine.rb +13 -0
  22. data/app/models/import_document_distribution_transition.rb +19 -0
  23. data/app/views/admin/document_data_dictionaries/_data_dictionaries_table.html.erb +40 -0
  24. data/app/views/admin/document_data_dictionaries/_document_data_dictionary.html.erb +37 -0
  25. data/app/views/admin/document_data_dictionaries/_document_data_dictionary.json.jbuilder +2 -0
  26. data/app/views/admin/document_data_dictionaries/_form.html.erb +17 -0
  27. data/app/views/admin/document_data_dictionaries/edit.html.erb +12 -0
  28. data/app/views/admin/document_data_dictionaries/index.html.erb +45 -0
  29. data/app/views/admin/document_data_dictionaries/index.json.jbuilder +1 -0
  30. data/app/views/admin/document_data_dictionaries/new.html.erb +78 -0
  31. data/app/views/admin/document_data_dictionaries/show.html.erb +78 -0
  32. data/app/views/admin/document_data_dictionaries/show.json.jbuilder +1 -0
  33. data/app/views/admin/document_data_dictionary_entries/_form.html.erb +19 -0
  34. data/app/views/admin/document_data_dictionary_entries/edit.html.erb +12 -0
  35. data/app/views/admin/document_data_dictionary_entries/new.html.erb +16 -0
  36. data/app/views/admin/document_distributions/index.html.erb +2 -2
  37. data/app/views/admin/documents/_form_nav.html.erb +4 -0
  38. data/app/views/admin/import_distributions/_form.html.erb +21 -0
  39. data/app/views/admin/import_distributions/_import_distribution.json.jbuilder +5 -0
  40. data/app/views/admin/import_distributions/_show_failed_tab.html.erb +36 -0
  41. data/app/views/admin/import_distributions/_show_success_tab.html.erb +35 -0
  42. data/app/views/admin/import_distributions/edit.html.erb +8 -0
  43. data/app/views/admin/import_distributions/index.html.erb +58 -0
  44. data/app/views/admin/import_distributions/index.json.jbuilder +3 -0
  45. data/app/views/admin/import_distributions/new.html.erb +7 -0
  46. data/app/views/admin/import_distributions/show.html.erb +121 -0
  47. data/app/views/admin/import_distributions/show.json.jbuilder +3 -0
  48. data/app/views/admin/imports/index.html.erb +2 -2
  49. data/app/views/admin/shared/_navbar.html.erb +2 -2
  50. data/app/views/catalog/_gbl_admin_data_dictionaries.html.erb +3 -0
  51. data/app/views/catalog/_show_gbl_admin_data_dictionaries.html.erb +44 -0
  52. data/app/views/catalog/data_dictionaries.html.erb +12 -0
  53. data/db/migrate/20241204163117_create_document_data_dictionaries.rb +14 -0
  54. data/db/migrate/20241218174455_create_document_data_dictionary_entries.rb +17 -0
  55. data/db/migrate/20250113213655_import_distributions.rb +56 -0
  56. data/lib/generators/geoblacklight_admin/config_generator.rb +61 -4
  57. data/lib/generators/geoblacklight_admin/templates/btaa_sample_document_data_dictionary_entries.csv +9 -0
  58. data/lib/generators/geoblacklight_admin/templates/btaa_sample_document_distributions.csv +17 -0
  59. data/lib/geoblacklight_admin/routes/data_dictionariesable.rb +17 -0
  60. data/lib/geoblacklight_admin/version.rb +1 -1
  61. metadata +49 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: faa51021e7c742dbc13abb29e2114de5f917716955034cff20b433d6a3c275de
4
- data.tar.gz: 71050ece81fb99a0377793467e09e4bb0d67fc892b6f49296a1c278ab4a00460
3
+ metadata.gz: a28b441bd6abd075d9a44800659b3fd9d28a44a7eafc44b6cd5a35105c63c0c6
4
+ data.tar.gz: 98a2f2f6b11314db59f5d52f79b16c8a7d405322a7a59c8b3d3214ad7030e541
5
5
  SHA512:
6
- metadata.gz: 7fef4d2444d40f92f7731471f3647959b440e0a1ff9d74f5fb4973bff02701b626a6b0d83c7175e8bc0a0bb9bc7519edb3ef3f660d1584b0e4bbc22cb9f4df30
7
- data.tar.gz: 3cc6ed9f3b4f25c67b30ba05a601a05022baab8ace7081d609db514f175746d43a5e91225c2889d8963eb74fbf96cca96f4cd112fae5066d65b455eb6cf3d72e
6
+ metadata.gz: 271fd18a5c03887aeffc871857c1c7ab1f79c40d1a2f8e054f5834bca57fcd890a5664353ceb34978e6df1849b00471d15388e41e34b27bfe2fb9dd88317f5eb
7
+ data.tar.gz: 27e668d0c301630f549705d930d8c182b31989f3f36b7e3d22be44b25f82123ddf2552804c0e604b0a30f060304272b150883cf310a88a5cd81152dba0b816f6
data/README.md CHANGED
@@ -69,7 +69,8 @@ The gem is available as open source under the terms of the [Apache 2.0 License](
69
69
  * ~~Debug Rails 7.2 support (remove devise_invitable, see [#915](https://github.com/scambra/devise_invitable/issues/915))~~
70
70
  * ~~Separate dct_references_s support into a separate model~~
71
71
  * ~~Import/Export dct_references_s outside of the main document model~~
72
- * Data Dictionary: Add support for `document_data_dictionary`
72
+ * ~~Distributions: Move import to background queue~~
73
+ * ~~Data Dictionary: Add support for `document_data_dictionary`~~
73
74
  * Gazetteer: Add GeoNames support
74
75
  * Gazetteer: Add Who's On First support
75
76
  * Gazetteer: Add Ollama support
@@ -14,7 +14,7 @@
14
14
  @import "settings/variables";
15
15
 
16
16
  // Vendors
17
- @import 'bootstrap';
17
+ @import "bootstrap";
18
18
 
19
19
  // Modules
20
20
  @import "modules/autocomplete";
@@ -13,3 +13,12 @@ svg.icon.baseline {
13
13
  top: -0.125em;
14
14
  position: relative;
15
15
  }
16
+
17
+ // Sidebar Action Icons
18
+ #dataDictionariesLink {
19
+ margin-left:0rem;
20
+ padding-left: 1.5rem;
21
+ background-position: left center;
22
+ background-repeat:no-repeat;
23
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' version='1.1' width='14' height='14' viewBox='0 0 512 512' aria-label='Table' role='img'%3E%3Ctitle%3ETable%3C/title%3E%3Cpath fill='%236c757d' d='M464 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V80c0-26.51-21.49-48-48-48zM48 80h416v64H48V80zm0 128h128v64H48v-64zm0 128h128v64H48v-64zm192 64v-64h128v64H240zm128-128H240v-64h128v64zm64 128v-64h128v64H432zm0-128h128v64H432v-64z'%3E%3C/path%3E%3C/svg%3E");
24
+ }
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Admin::DocumentDataDictionariesController
4
+ #
5
+ # This controller manages the document data dictionaries within the admin namespace.
6
+ # It provides actions to list, show, edit, update, and destroy data dictionaries.
7
+ module Admin
8
+ class DocumentDataDictionariesController < Admin::AdminController
9
+ before_action :set_document
10
+ before_action :set_document_data_dictionary, only: %i[show edit update destroy]
11
+
12
+ # GET /document_data_dictionaries or /document_data_dictionaries.json
13
+ def index
14
+ @document_data_dictionaries = DocumentDataDictionary.all
15
+ if params[:document_id]
16
+ @document_data_dictionaries = DocumentDataDictionary.where(friendlier_id: @document.friendlier_id).order(position: :asc)
17
+ else
18
+ @pagy, @document_data_dictionaries = pagy(DocumentDataDictionary.all.order(friendlier_id: :asc, updated_at: :desc), items: 20)
19
+ end
20
+ end
21
+
22
+ # GET /document_data_dictionaries/1 or /document_data_dictionaries/1.json
23
+ def show
24
+ @pagy, @document_data_dictionary_entries = pagy(@document_data_dictionary.document_data_dictionary_entries.order(position: :asc), items: 100)
25
+ end
26
+
27
+ # GET /document_data_dictionaries/new
28
+ def new
29
+ @document_data_dictionary = DocumentDataDictionary.new
30
+ end
31
+
32
+ # GET /document_data_dictionaries/1/edit
33
+ def edit
34
+ end
35
+
36
+ # POST /document_data_dictionaries or /document_data_dictionaries.json
37
+ def create
38
+ @document_data_dictionary = DocumentDataDictionary.new(document_data_dictionary_params)
39
+
40
+ respond_to do |format|
41
+ if @document_data_dictionary.save
42
+ format.html { redirect_to admin_document_document_data_dictionaries_path(@document), notice: "Document data dictionary was successfully created." }
43
+ format.json { render :show, status: :created, location: @document_data_dictionary }
44
+ else
45
+ logger.debug("Document data dictionary could not be created. #{@document_data_dictionary.errors.full_messages}")
46
+ format.html { render :new, status: :unprocessable_entity }
47
+ format.json { render json: @document_data_dictionary.errors, status: :unprocessable_entity }
48
+ end
49
+ end
50
+ end
51
+
52
+ # PATCH/PUT /document_data_dictionaries/1 or /document_data_dictionaries/1.json
53
+ def update
54
+ respond_to do |format|
55
+ if @document_data_dictionary.update(document_data_dictionary_params)
56
+ format.html { redirect_to admin_document_document_data_dictionaries_path(@document), notice: "Document data dictionary was successfully updated." }
57
+ format.json { render :show, status: :ok, location: @document_data_dictionary }
58
+ else
59
+ format.html { render :edit, status: :unprocessable_entity }
60
+ format.json { render json: @document_data_dictionary.errors, status: :unprocessable_entity }
61
+ end
62
+ end
63
+ end
64
+
65
+ # DELETE /document_data_dictionaries/1 or /document_data_dictionaries/1.json
66
+ def destroy
67
+ @document_data_dictionary.destroy!
68
+
69
+ respond_to do |format|
70
+ format.html { redirect_to admin_document_document_data_dictionaries_path(@document), status: :see_other, notice: "Document data dictionary was successfully destroyed." }
71
+ format.json { head :no_content }
72
+ end
73
+ end
74
+
75
+ # DELETE /admin/document_data_dictionaries/destroy_all
76
+ #
77
+ # Destroys all document data dictionaries provided in the file parameter. If successful, redirects
78
+ # with a success notice. Otherwise, redirects with an error notice.
79
+ def destroy_all
80
+ return if request.get?
81
+
82
+ logger.debug("Destroy Data Dictionaries")
83
+ unless params.dig(:document_data_dictionary, :data_dictionaries, :file)
84
+ raise ArgumentError, "File does not exist or is invalid."
85
+ end
86
+
87
+ respond_to do |format|
88
+ if DocumentDataDictionary.destroy_all(params.dig(:document_data_dictionary, :data_dictionaries, :file))
89
+ format.html { redirect_to admin_document_document_data_dictionaries_path, notice: "Data dictionaries were destroyed." }
90
+ else
91
+ format.html { redirect_to admin_document_document_data_dictionaries_path, notice: "Data dictionaries could not be destroyed." }
92
+ end
93
+ rescue => e
94
+ format.html { redirect_to admin_document_document_data_dictionaries_path, notice: "Data dictionaries could not be destroyed. #{e}" }
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ # Sets the document based on the document_id parameter.
101
+ # If not nested, it does nothing.
102
+ def set_document
103
+ return unless params[:document_id] # If not nested
104
+
105
+ @document = Document.includes(:leaf_representative).find_by!(friendlier_id: params[:document_id])
106
+ end
107
+
108
+ # Sets the document data dictionary based on the id parameter.
109
+ def set_document_data_dictionary
110
+ @document_data_dictionary = DocumentDataDictionary.find(params[:id])
111
+ end
112
+
113
+ # Only allow a list of trusted parameters through.
114
+ def document_data_dictionary_params
115
+ params.require(:document_data_dictionary).permit(
116
+ :friendlier_id,
117
+ :name,
118
+ :description,
119
+ :staff_notes,
120
+ :tags,
121
+ :csv_file,
122
+ :position
123
+ )
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Admin::DocumentDataDictionaryEntriesController
4
+ #
5
+ # This controller manages the document data dictionary entries within the admin namespace.
6
+ # It provides actions to list, show, edit, update, destroy, and import data dictionaries.
7
+ module Admin
8
+ class DocumentDataDictionaryEntriesController < Admin::AdminController
9
+ before_action :set_document
10
+ before_action :set_document_data_dictionary
11
+ before_action :set_document_data_dictionary_entry, only: %i[edit update destroy]
12
+
13
+ # GET /document_data_dictionaries/1/entries/new
14
+ def new
15
+ @document_data_dictionary_entry = DocumentDataDictionaryEntry.new
16
+ end
17
+
18
+ # GET /document_data_dictionaries/1/entries/1/edit
19
+ def edit
20
+ end
21
+
22
+ # POST /document_data_dictionaries/1/entries or /document_data_dictionaries/1/entries.json
23
+ def create
24
+ @document_data_dictionary_entry = DocumentDataDictionaryEntry.new(document_data_dictionary_entry_params)
25
+
26
+ respond_to do |format|
27
+ if @document_data_dictionary_entry.save
28
+ format.html { redirect_to admin_document_document_data_dictionary_path(@document, @document_data_dictionary), notice: "Document data dictionary entry was successfully created." }
29
+ format.json { render :show, status: :created, location: @document_data_dictionary_entry }
30
+ else
31
+ logger.debug("Document data dictionary entry could not be created. #{@document_data_dictionary_entry.errors.full_messages}")
32
+ format.html { render :new, status: :unprocessable_entity }
33
+ format.json { render json: @document_data_dictionary_entry.errors, status: :unprocessable_entity }
34
+ end
35
+ end
36
+ end
37
+
38
+ # PATCH/PUT /document_data_dictionaries/1/entries/1 or /document_data_dictionaries/1/entries/1.json
39
+ def update
40
+ respond_to do |format|
41
+ if @document_data_dictionary_entry.update(document_data_dictionary_entry_params)
42
+ format.html { redirect_to admin_document_document_data_dictionary_path(@document, @document_data_dictionary), notice: "Document data dictionary entry was successfully updated." }
43
+ format.json { render :show, status: :ok, location: @document_data_dictionary_entry }
44
+ else
45
+ logger.debug("Document data dictionary entry could not be updated. #{@document_data_dictionary_entry.errors.full_messages}")
46
+ format.html { render :edit, status: :unprocessable_entity }
47
+ format.json { render json: @document_data_dictionary_entry.errors, status: :unprocessable_entity }
48
+ end
49
+ end
50
+ end
51
+
52
+ # DELETE /document_data_dictionaries/1/entries/1 or /document_data_dictionaries/1/entries/1.json
53
+ def destroy
54
+ @document_data_dictionary_entry.destroy!
55
+
56
+ respond_to do |format|
57
+ format.html { redirect_to admin_document_document_data_dictionary_path(@document, @document_data_dictionary), status: :see_other, notice: "Document data dictionary entry was successfully destroyed." }
58
+ format.json { head :no_content }
59
+ end
60
+ end
61
+
62
+ # DELETE /admin/document_data_dictionaries/1/entries/destroy_all
63
+ #
64
+ # Destroys all document data dictionaries provided in the file parameter. If successful, redirects
65
+ # with a success notice. Otherwise, redirects with an error notice.
66
+ def destroy_all
67
+ return if request.get?
68
+
69
+ logger.debug("Destroy Data Dictionary Entries")
70
+
71
+ respond_to do |format|
72
+ if @document_data_dictionary.document_data_dictionary_entries.destroy_all
73
+ format.html { redirect_to admin_document_document_data_dictionary_document_data_dictionary_entries_path(@document_data_dictionary.friendlier_id, @document_data_dictionary), notice: "Data dictionary entries were destroyed." }
74
+ else
75
+ format.html { redirect_to admin_document_document_data_dictionary_document_data_dictionary_entries_path(@document_data_dictionary.friendlier_id, @document_data_dictionary), notice: "Data dictionary entries could not be destroyed." }
76
+ end
77
+ rescue => e
78
+ format.html { redirect_to admin_document_document_data_dictionary_document_data_dictionary_entries_path(@document_data_dictionary.friendlier_id, @document_data_dictionary), notice: "Data dictionary entries could not be destroyed. #{e}" }
79
+ end
80
+ end
81
+
82
+ # POST /document_data_dictionaries/1/entries/sort
83
+ # Sorts document data dictionary entries based on the provided list of IDs.
84
+ # Renders an empty response body.
85
+ def sort
86
+ DocumentDataDictionary.sort_entries(params[:id_list])
87
+ render body: nil
88
+ end
89
+
90
+ private
91
+
92
+ # Sets the document based on the document_id parameter.
93
+ # If not nested, it does nothing.
94
+ def set_document
95
+ return unless params[:document_id] # If not nested
96
+ @document = Document.includes(:leaf_representative).find_by!(friendlier_id: params[:document_id])
97
+ end
98
+
99
+ # Sets the document data dictionary based on the id parameter.
100
+ def set_document_data_dictionary
101
+ @document_data_dictionary = DocumentDataDictionary.find(params[:document_data_dictionary_id])
102
+ end
103
+
104
+ # Sets the document data dictionary entry based on the id parameter.
105
+ def set_document_data_dictionary_entry
106
+ @document_data_dictionary_entry = DocumentDataDictionaryEntry.find(params[:id])
107
+ end
108
+
109
+ # Only allow a list of trusted parameters through.
110
+ def document_data_dictionary_entry_params
111
+ params.require(:document_data_dictionary_entry).permit(
112
+ :friendlier_id,
113
+ :document_data_dictionary_id,
114
+ :field_name,
115
+ :field_type,
116
+ :values,
117
+ :definition,
118
+ :definition_source,
119
+ :parent_field_name,
120
+ :position
121
+ )
122
+ end
123
+ end
124
+ end
@@ -112,44 +112,6 @@ module Admin
112
112
  end
113
113
  end
114
114
 
115
- # GET/POST /documents/1/distributions/import
116
- #
117
- # Imports document distributions from a file. If successful, redirects with a success notice.
118
- # Otherwise, redirects with an error notice.
119
- def import
120
- return if request.get?
121
-
122
- logger.debug("Import Distributions")
123
-
124
- unless params.dig(:document_distribution, :distributions, :file)
125
- raise ArgumentError, "File does not exist or is invalid."
126
- end
127
-
128
- success, errors = DocumentDistribution.import(params.dig(:document_distribution, :distributions, :file))
129
- if success == true
130
- logger.debug("Distributions were created successfully.")
131
- if params[:document_id]
132
- redirect_to admin_document_document_distributions_path(@document), notice: "Distributions were created successfully."
133
- else
134
- redirect_to admin_document_distributions_path, notice: "Distributions were created successfully."
135
- end
136
- else
137
- logger.debug("Some distributions could not be created. #{errors.join(", ")}")
138
- if params[:document_id]
139
- redirect_to admin_document_document_distributions_path(@document), notice: "Some distributions could not be created. #{errors.join(", ")}"
140
- else
141
- redirect_to admin_document_distributions_path, notice: "Some distributions could not be created. #{errors.join(", ")}"
142
- end
143
- end
144
- rescue => e
145
- logger.debug("Distributions could not be created. #{e}")
146
- if params[:document_id]
147
- redirect_to admin_document_document_distributions_path(@document), notice: "Distributions could not be created. #{e}"
148
- else
149
- redirect_to admin_document_distributions_path, notice: "Distributions could not be created. #{e}"
150
- end
151
- end
152
-
153
115
  private
154
116
 
155
117
  # Sets the document based on the document_id parameter.
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Admin::ImportDistributionsController
4
+ #
5
+ # This controller handles the CRUD operations for ImportDistribution objects within the admin namespace.
6
+ # It provides actions to list, show, create, update, and delete import distributions, as well as run an import distribution.
7
+ #
8
+ # Before Actions:
9
+ # - set_import_distribution: Sets the @import_distribution instance variable for actions that require an import distribution ID.
10
+ #
11
+ # Actions:
12
+ # - index: Lists all imports with pagination.
13
+ # - show: Displays a specific import and its associated documents, with pagination for success and failed states.
14
+ # - new: Initializes a new Import object.
15
+ # - edit: Prepares an existing Import object for editing.
16
+ # - create: Creates a new Import object and redirects to import mappings if successful.
17
+ # - update: Updates an existing Import object and redirects to the import if successful.
18
+ # - destroy: Deletes an Import object and redirects to the imports list.
19
+ # - run: Executes the import process and redirects to the import show page.
20
+ #
21
+ # Private Methods:
22
+ # - set_import: Finds and sets the import based on the provided ID.
23
+ # - permittable_params: Returns an array of permitted parameters for import.
24
+ # - import_params: Permits parameters for creating or updating an import, including nested attributes.
25
+ module Admin
26
+ class ImportDistributionsController < Admin::AdminController
27
+ before_action :set_import_distribution, only: %i[show edit update destroy run]
28
+
29
+ # GET /import_distributions
30
+ # GET /import_distributions.json
31
+ # Lists all import distributions with pagination.
32
+ def index
33
+ @pagy, @import_distributions = pagy(ImportDistribution.all.order("created_at DESC"), items: 20)
34
+ end
35
+
36
+ # GET /import_distributions/1
37
+ # GET /import_distributions/1.json
38
+ # Displays a specific import distribution and its associated documents, with pagination for success and failed states.
39
+ def show
40
+ @pagy_failed, @import_failed_distributions = pagy(@import_distribution.import_document_distributions.not_in_state(:success), items: 50, page_param: :failed_page)
41
+ @pagy_success, @import_success_distributions = pagy(@import_distribution.import_document_distributions.in_state(:success), items: 50, page_param: :success_page)
42
+ end
43
+
44
+ # GET /import_distributions/new
45
+ # Initializes a new ImportDistribution object.
46
+ def new
47
+ @import_distribution = ImportDistribution.new
48
+ end
49
+
50
+ # GET /import_distributions/1/edit
51
+ # Prepares an existing ImportDistribution object for editing.
52
+ def edit
53
+ end
54
+
55
+ # POST /import_distributions
56
+ # POST /import_distributions.json
57
+ # Creates a new ImportDistribution object
58
+ def create
59
+ @import_distribution = ImportDistribution.new(import_distribution_params)
60
+
61
+ respond_to do |format|
62
+ if @import_distribution.save
63
+ format.html do
64
+ redirect_to admin_import_distribution_path(@import_distribution),
65
+ notice: "Import distribution was successful."
66
+ end
67
+ format.json { render :show, status: :created, location: @import_distribution }
68
+ else
69
+ format.html { render :new, status: :unprocessable_entity }
70
+ format.json { render json: @import_distribution.errors, status: :unprocessable_entity }
71
+ end
72
+ end
73
+ end
74
+
75
+ # PATCH/PUT /import_distributions/1
76
+ # PATCH/PUT /import_distributions/1.json
77
+ # Updates an existing ImportDistribution object and redirects to the import distribution if successful.
78
+ def update
79
+ respond_to do |format|
80
+ if @import_distribution.update(import_distribution_params)
81
+ format.html { redirect_to admin_import_distribution_path(@import_distribution), notice: "Import distribution was successfully updated." }
82
+ format.json { render :show, status: :ok, location: @import_distribution }
83
+ else
84
+ format.html { render :edit, status: :unprocessable_entity }
85
+ format.json { render json: @import_distribution.errors, status: :unprocessable_entity }
86
+ end
87
+ end
88
+ end
89
+
90
+ # DELETE /import_distributions/1
91
+ # DELETE /import_distributions/1.json
92
+ # Deletes an ImportDistribution object and redirects to the import distributions list.
93
+ def destroy
94
+ @import_distribution.destroy
95
+ respond_to do |format|
96
+ format.html { redirect_to admin_import_distributions_url, notice: "Import distribution was successfully destroyed." }
97
+ format.json { head :no_content }
98
+ end
99
+ end
100
+
101
+ # Runs the import process and redirects to the import show page.
102
+ def run
103
+ @import_distribution.run!
104
+ redirect_to admin_import_distribution_url(@import_distribution), notice: "Import distribution is running. Check back soon for results."
105
+ end
106
+
107
+ private
108
+
109
+ # Use callbacks to share common setup or constraints between actions.
110
+ # Finds and sets the import distribution based on the provided ID.
111
+ def set_import_distribution
112
+ @import_distribution = ImportDistribution.find(params[:id])
113
+ end
114
+
115
+ # Returns an array of permitted parameters for import distribution.
116
+ def permittable_params
117
+ %i[name filename source description row_count encoding content_type extension validity validation_result
118
+ csv_file run]
119
+ end
120
+
121
+ # Permits parameters for creating or updating an import distribution, including nested attributes.
122
+ def import_distribution_params
123
+ params.require(:import_distribution).permit(permittable_params)
124
+ end
125
+ end
126
+ 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