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.
@@ -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