card-mod-csv_import 0.8
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 +7 -0
- data/config/initializers/import_logger.rb +2 -0
- data/lib/csv_file.rb +137 -0
- data/lib/csv_row.rb +167 -0
- data/lib/csv_row/normalizer.rb +12 -0
- data/lib/import_log.rb +7 -0
- data/lib/import_manager.rb +73 -0
- data/lib/import_manager/act_import_manager.rb +43 -0
- data/lib/import_manager/conflicts.rb +50 -0
- data/lib/import_manager/script_import_manager.rb +48 -0
- data/lib/import_manager/status.rb +50 -0
- data/lib/import_manager/status/counts.rb +31 -0
- data/lib/import_manager/status_log.rb +80 -0
- data/lib/import_manager/validation_manager.rb +40 -0
- data/set/abstract/import.rb +89 -0
- data/set/abstract/import/01_table_row.rb +112 -0
- data/set/abstract/import/execute_import.rb +59 -0
- data/set/abstract/import/import_page.rb +82 -0
- data/set/abstract/import/table.rb +76 -0
- data/set/all/import_act.rb +8 -0
- data/set/right/import_status.rb +163 -0
- data/set/right/imported_rows.rb +20 -0
- data/set/type/file.rb +10 -0
- data/template/self/import_tool.haml +3 -0
- metadata +83 -0
@@ -0,0 +1,50 @@
|
|
1
|
+
class ImportManager
|
2
|
+
# Methods to deal with conflicts with existing cards
|
3
|
+
module Conflicts
|
4
|
+
def override?
|
5
|
+
@conflict_strategy == :override
|
6
|
+
end
|
7
|
+
|
8
|
+
def check_for_duplicates name
|
9
|
+
key = name.to_name.key
|
10
|
+
if @imported_keys.include? key
|
11
|
+
report :duplicate_in_file, name
|
12
|
+
throw :skip_row, :skipped
|
13
|
+
else
|
14
|
+
@imported_keys << key
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def with_conflict_strategy strategy
|
19
|
+
tmp_cs = @conflict_strategy
|
20
|
+
@conflict_strategy = strategy if strategy
|
21
|
+
yield
|
22
|
+
ensure
|
23
|
+
@conflict_strategy = tmp_cs
|
24
|
+
end
|
25
|
+
|
26
|
+
def handle_conflict name, strategy: nil
|
27
|
+
with_conflict_strategy strategy do
|
28
|
+
with_conflict_resolution name do |duplicate|
|
29
|
+
yield duplicate
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def with_conflict_resolution name
|
35
|
+
return yield unless (dup = duplicate(name))
|
36
|
+
|
37
|
+
case @conflict_strategy
|
38
|
+
when :skip then throw :skip_row, :skipped
|
39
|
+
when :skip_card then dup
|
40
|
+
else
|
41
|
+
@status = :overridden
|
42
|
+
yield dup
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def duplicate name
|
47
|
+
Card[name]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
class ImportError < StandardError
|
2
|
+
end
|
3
|
+
|
4
|
+
# {ImportManager} for scripts. Main difference is you don't have an act card and
|
5
|
+
# you can choose a error policy. For example throw an exception on the first error or
|
6
|
+
# collect all errors and report in the console at the end.
|
7
|
+
# You can also specify a user who does the imports.
|
8
|
+
# Unlike the other ImportManagers the ScriptImportManager import doesn't support
|
9
|
+
# extra data to override fields.
|
10
|
+
class ScriptImportManager < ImportManager
|
11
|
+
def initialize csv_file, conflict_strategy: :skip, error_policy: :fail, user: nil
|
12
|
+
super(csv_file, conflict_strategy, {})
|
13
|
+
@error_policy = error_policy
|
14
|
+
@user = user
|
15
|
+
end
|
16
|
+
|
17
|
+
def import_rows row_indices
|
18
|
+
with_user do
|
19
|
+
super
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def with_user
|
24
|
+
if @user
|
25
|
+
Card::Auth.with(@user) { yield }
|
26
|
+
elsif Card::Auth.signed_in?
|
27
|
+
yield
|
28
|
+
else
|
29
|
+
raise StandardError, "can't import as anonymous"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def row_failed _csv_row
|
34
|
+
case @error_policy
|
35
|
+
when :fail then
|
36
|
+
raise ImportError, @import_status[:errors].inspect
|
37
|
+
when :report then
|
38
|
+
puts @import_status[:errors].inspect
|
39
|
+
when :skip then
|
40
|
+
nil
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def log_status
|
45
|
+
puts "#{@current_row.row_index}: #{@current_row.name}"
|
46
|
+
super
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
class ImportManager
|
2
|
+
class Status < Hash
|
3
|
+
require_dependency "import_manager/status/counts"
|
4
|
+
|
5
|
+
# @param initial_status [Hash, Integer, String] a hash, a hash as json string or just
|
6
|
+
# the total number of imports
|
7
|
+
def initialize status
|
8
|
+
hash = normalize_init_args status
|
9
|
+
counts = hash.delete(:counts)
|
10
|
+
replace hash
|
11
|
+
self[:counts] = Counts.new counts
|
12
|
+
init_missing_values
|
13
|
+
end
|
14
|
+
|
15
|
+
def init_missing_values
|
16
|
+
self[:errors] ||= Hash.new { |h, k| h[k] = [] }
|
17
|
+
self[:reports] ||= Hash.new { |h, k| h[k] = [] }
|
18
|
+
%i[imported skipped overridden failed].each do |n|
|
19
|
+
self[n] ||= {}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def normalize_init_args status
|
24
|
+
sym_hash = case status
|
25
|
+
when Integer
|
26
|
+
{ counts: { total: status } }
|
27
|
+
when String
|
28
|
+
JSON.parse status
|
29
|
+
when Hash
|
30
|
+
status
|
31
|
+
else
|
32
|
+
{}
|
33
|
+
end
|
34
|
+
unstringify_keys sym_hash
|
35
|
+
rescue JSON::ParserError => _e
|
36
|
+
{}
|
37
|
+
end
|
38
|
+
|
39
|
+
def unstringify_keys hash
|
40
|
+
hash.deep_symbolize_keys!
|
41
|
+
hash.keys.each do |k|
|
42
|
+
next if k == :counts || !hash[k].is_a?(Hash)
|
43
|
+
hash[k] = hash[k].each_with_object({}) do |(key, value), options|
|
44
|
+
options[(Integer(key.to_s) rescue key)] = value
|
45
|
+
end
|
46
|
+
end
|
47
|
+
hash
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
class ImportManager
|
2
|
+
class Status
|
3
|
+
class Counts < Hash
|
4
|
+
def initialize hash
|
5
|
+
hash ||= {}
|
6
|
+
replace hash
|
7
|
+
end
|
8
|
+
|
9
|
+
def default _key
|
10
|
+
0
|
11
|
+
end
|
12
|
+
|
13
|
+
def count key
|
14
|
+
if key.is_a? Array
|
15
|
+
key.inject(0) { |sum, k| sum + self[k] }
|
16
|
+
else
|
17
|
+
self[key]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def step key
|
22
|
+
self[key] += 1
|
23
|
+
end
|
24
|
+
|
25
|
+
def percentage key
|
26
|
+
return 0 if count(:total) == 0 || count(key).nil?
|
27
|
+
(count(key) / count(:total).to_f * 100).floor(2)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
class ImportManager
|
2
|
+
# Methods to collect errors and report the status of the import
|
3
|
+
module StatusLog
|
4
|
+
def log_status
|
5
|
+
import_status[@current_row.status] ||= {}
|
6
|
+
import_status[@current_row.status][@current_row.row_index] = @current_row.name
|
7
|
+
import_status[:counts].step @current_row.status
|
8
|
+
end
|
9
|
+
|
10
|
+
def report key, msg
|
11
|
+
case key
|
12
|
+
when :duplicate_in_file
|
13
|
+
msg = "#{msg} duplicate in this file"
|
14
|
+
end
|
15
|
+
import_status[:reports][@current_row.row_index] ||= []
|
16
|
+
import_status[:reports][@current_row.row_index] << msg
|
17
|
+
end
|
18
|
+
|
19
|
+
def import_status
|
20
|
+
@import_status || init_import_status
|
21
|
+
end
|
22
|
+
|
23
|
+
# used by {CSVRow} objects
|
24
|
+
def report_error msg
|
25
|
+
import_status[:errors][@current_row.row_index] ||= []
|
26
|
+
import_status[:errors][@current_row.row_index] << msg
|
27
|
+
end
|
28
|
+
|
29
|
+
def errors_by_row_index
|
30
|
+
@import_status[:errors].each do |index, msgs|
|
31
|
+
yield index, msgs
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def pick_up_card_errors card=nil
|
36
|
+
card = yield if block_given?
|
37
|
+
if card
|
38
|
+
card.errors.each do |error_key, msg|
|
39
|
+
report_error "#{card.name} (#{error_key}): #{msg}"
|
40
|
+
end
|
41
|
+
card.errors.clear
|
42
|
+
end
|
43
|
+
card
|
44
|
+
end
|
45
|
+
|
46
|
+
def errors? row=nil
|
47
|
+
if row
|
48
|
+
errors(row).present?
|
49
|
+
else
|
50
|
+
errors.values.flatten.present?
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def errors row=nil
|
55
|
+
if row
|
56
|
+
import_status.dig(:errors, row.row_index) || []
|
57
|
+
else
|
58
|
+
import_status[:errors] || {}
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def error_list
|
63
|
+
@import_status[:errors].each_with_object([]) do |(index, errors), list|
|
64
|
+
next if errors.empty?
|
65
|
+
list << "##{index + 1}: #{errors.join('; ')}"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def init_import_status row_count=nil
|
72
|
+
@import_status = ImportManager::Status.new(row_count || 0)
|
73
|
+
end
|
74
|
+
|
75
|
+
def specify_success_status status
|
76
|
+
return status if status.in? %i[failed skipped]
|
77
|
+
@status == :overridden ? :overridden : :imported
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# ValidateManager doesn't import anything. It is used for collecting invalid data
|
2
|
+
# to show it in the import table interface.
|
3
|
+
class ValidationManager < ImportManager
|
4
|
+
def validate row_indices=nil, &block
|
5
|
+
@after_validation = block
|
6
|
+
validate_rows row_indices
|
7
|
+
end
|
8
|
+
|
9
|
+
def validate_rows row_indices
|
10
|
+
row_count = row_indices ? row_indices.size : @csv_file.row_count
|
11
|
+
@import_status = Status.new counts: { total: row_count }
|
12
|
+
|
13
|
+
@csv_file.each_row self, row_indices do |csv_row|
|
14
|
+
validate_row csv_row
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def validate_row csv_row
|
19
|
+
handle_import csv_row do
|
20
|
+
csv_row.prepare_import
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def add_card args
|
25
|
+
handle_conflict args[:name], strategy: :skip_card do
|
26
|
+
card = Card.new args
|
27
|
+
card.validate
|
28
|
+
pick_up_card_errors card
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def each_row
|
33
|
+
@csv_file.each_row self do |row, i|
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def row_finished row
|
38
|
+
@after_validation.call row
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
card_accessor :import_status
|
2
|
+
card_accessor :imported_rows
|
3
|
+
|
4
|
+
delegate :mark_as_imported, :already_imported?, to: :imported_rows_card
|
5
|
+
|
6
|
+
def import_file?
|
7
|
+
true
|
8
|
+
end
|
9
|
+
|
10
|
+
def csv_file
|
11
|
+
# maybe we have to use file.read ?
|
12
|
+
CSVFile.new file, csv_row_class, headers: :detect
|
13
|
+
end
|
14
|
+
|
15
|
+
def clean_html?
|
16
|
+
false
|
17
|
+
end
|
18
|
+
|
19
|
+
def csv_only? # for override
|
20
|
+
true
|
21
|
+
end
|
22
|
+
|
23
|
+
event :validate_import_format_on_create, :validate,
|
24
|
+
on: :create, when: :save_preliminary_upload? do
|
25
|
+
validate_file_card upload_cache_card
|
26
|
+
end
|
27
|
+
|
28
|
+
event :validate_import_format, :validate,
|
29
|
+
on: :update, when: :save_preliminary_upload? do
|
30
|
+
validate_file_card self
|
31
|
+
end
|
32
|
+
|
33
|
+
def validate_file_card file_card
|
34
|
+
if file_card.csv?
|
35
|
+
validate_csv file_card
|
36
|
+
elsif csv_only?
|
37
|
+
abort :failure, "file must be CSV but was '#{file_card.attachment.content_type}'"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def validate_csv file_card
|
42
|
+
CSVFile.new file_card.attachment, csv_row_class, headers: :detect
|
43
|
+
rescue CSV::MalformedCSVError => e
|
44
|
+
abort :failure, "malformed csv: #{e.message}"
|
45
|
+
end
|
46
|
+
|
47
|
+
format :html do
|
48
|
+
before :new do
|
49
|
+
voo.help = help_text
|
50
|
+
voo.show! :help
|
51
|
+
end
|
52
|
+
|
53
|
+
before :edit do
|
54
|
+
voo.help = help_text
|
55
|
+
voo.show! :help
|
56
|
+
end
|
57
|
+
|
58
|
+
def help_text
|
59
|
+
rows = card.csv_row_class.columns.map { |s| s.to_s.humanize }
|
60
|
+
"expected csv format: #{rows.join ' | '}"
|
61
|
+
end
|
62
|
+
|
63
|
+
def new_view_hidden
|
64
|
+
hidden_tags success: { id: "_self", soft_redirect: false, redirect: true, view: :import }
|
65
|
+
end
|
66
|
+
|
67
|
+
view :core do
|
68
|
+
output [
|
69
|
+
download_link,
|
70
|
+
import_link,
|
71
|
+
last_import_status
|
72
|
+
]
|
73
|
+
end
|
74
|
+
|
75
|
+
def download_link
|
76
|
+
handle_source do |source|
|
77
|
+
%(<a href="#{source}" rel="nofollow">Download "#{_render_title}"</a><br />)
|
78
|
+
end.html_safe
|
79
|
+
end
|
80
|
+
|
81
|
+
def import_link
|
82
|
+
link_to_view :import, "Import ...", rel: "nofollow", remote: false
|
83
|
+
end
|
84
|
+
|
85
|
+
def last_import_status
|
86
|
+
return unless card.import_status.present?
|
87
|
+
link_to_card card.import_status_card, "Status of last import"
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
#! no set module
|
2
|
+
|
3
|
+
# An ImportRow object generates the html for a row in the import table.
|
4
|
+
# It needs a format with `column_keys` and a {CSVRow} object.
|
5
|
+
# For each value in column_keys in generates a cell. It takes the content from the CSVRow
|
6
|
+
# object if it has data for that key.
|
7
|
+
# Otherwise it expects a `<missing_key>_field` method to produce the cell content.
|
8
|
+
class TableRow
|
9
|
+
attr_reader :match_type, :csv_row, :format
|
10
|
+
|
11
|
+
delegate :row_index, :status, to: :csv_row
|
12
|
+
delegate :corrections_input_name, to: :format
|
13
|
+
|
14
|
+
# @param csv_row [CSVRow] a CSVRow object
|
15
|
+
# @param format the format of an import file. It has to respond to `column_keys`.
|
16
|
+
# It is also used to generate form elements.
|
17
|
+
def initialize csv_row, format
|
18
|
+
@csv_row = csv_row
|
19
|
+
@format = format
|
20
|
+
end
|
21
|
+
|
22
|
+
def valid?
|
23
|
+
!@csv_row.errors?
|
24
|
+
end
|
25
|
+
|
26
|
+
def imported?
|
27
|
+
@format.already_imported? @csv_row.row_index
|
28
|
+
end
|
29
|
+
|
30
|
+
# The return value is supposed to be passed to the table helper method.
|
31
|
+
# @return [Hash] the :content value is an array with the text/html for each cell
|
32
|
+
def render
|
33
|
+
res = { content: fields, data: { csv_row_index: @csv_row.row_index } }
|
34
|
+
format.add_class res, row_css_classes
|
35
|
+
res
|
36
|
+
end
|
37
|
+
|
38
|
+
def row_css_classes
|
39
|
+
valid? ? [] : ["table-danger"]
|
40
|
+
end
|
41
|
+
|
42
|
+
# def render
|
43
|
+
# super.merge class: "table-#{row_context}"
|
44
|
+
# end
|
45
|
+
#
|
46
|
+
private
|
47
|
+
|
48
|
+
# @return [Array] values for every cell in the table row
|
49
|
+
def fields
|
50
|
+
@format.column_keys.map do |key|
|
51
|
+
send "#{key}_field"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def checked?
|
56
|
+
true
|
57
|
+
end
|
58
|
+
|
59
|
+
def extra_data
|
60
|
+
{}
|
61
|
+
end
|
62
|
+
|
63
|
+
def corrections
|
64
|
+
{}
|
65
|
+
end
|
66
|
+
|
67
|
+
def row_index_field
|
68
|
+
@csv_row.row_index + 1
|
69
|
+
end
|
70
|
+
|
71
|
+
def checkbox_field
|
72
|
+
# disable if data is invalid
|
73
|
+
@format.check_box_tag("import_rows[#{@csv_row.row_index}]", true, valid? && checked?,
|
74
|
+
disabled: !valid?) + extra_data_tags.html_safe
|
75
|
+
end
|
76
|
+
|
77
|
+
def extra_data_tags
|
78
|
+
extra_data.each_with_object([]) do |(k, v), a|
|
79
|
+
a << @format.hidden_field_tag(extra_data_input_name(k), v)
|
80
|
+
end.join "\n"
|
81
|
+
end
|
82
|
+
|
83
|
+
def correction_tag key, value, index=@csv_row.row_index
|
84
|
+
name = extra_data_input_name(index, :corrections, key)
|
85
|
+
@format.hidden_field_tag name, value
|
86
|
+
end
|
87
|
+
|
88
|
+
def extra_data_input_name *subfields
|
89
|
+
@format.extra_data_input_name @csv_row.row_index, *subfields
|
90
|
+
end
|
91
|
+
|
92
|
+
def corrections_input_name key
|
93
|
+
@format.corrections_input_name @csv_row.row_index, key
|
94
|
+
end
|
95
|
+
|
96
|
+
def method_missing method, *_args, &_block
|
97
|
+
return super unless (field = field_from_method(method))
|
98
|
+
@csv_row[field.to_sym]
|
99
|
+
end
|
100
|
+
|
101
|
+
def respond_to_missing? method, _include_private=false
|
102
|
+
field_method?(method) || super
|
103
|
+
end
|
104
|
+
|
105
|
+
def field_from_method method
|
106
|
+
method =~ /^(\w+)_field$/ ? Regexp.last_match(1) : nil
|
107
|
+
end
|
108
|
+
|
109
|
+
def field_method? method
|
110
|
+
method =~ /^\w+_field$/
|
111
|
+
end
|
112
|
+
end
|