imp_exp 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (31) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +59 -0
  4. data/Rakefile +5 -0
  5. data/app/services/imp_exp/data_validators/base.rb +92 -0
  6. data/app/services/imp_exp/exporters/base.rb +28 -0
  7. data/app/services/imp_exp/exporters/files.rb +41 -0
  8. data/app/services/imp_exp/exporters/xlsx.rb +34 -0
  9. data/app/services/imp_exp/importers/base.rb +191 -0
  10. data/app/services/imp_exp/importers/xlsx.rb +81 -0
  11. data/app/services/imp_exp/models/base.rb +143 -0
  12. data/app/services/imp_exp/ping_observer.rb +22 -0
  13. data/app/services/imp_exp/serializers/array_like.rb +21 -0
  14. data/app/services/imp_exp/serializers/errors/base.rb +12 -0
  15. data/app/services/imp_exp/serializers/errors/record_not_found.rb +24 -0
  16. data/app/services/imp_exp/serializers/errors/wrong_format.rb +20 -0
  17. data/app/services/imp_exp/serializers/polymorphic_relation_one_to_one.rb +37 -0
  18. data/app/services/imp_exp/serializers/relation_one_to_many.rb +61 -0
  19. data/app/services/imp_exp/serializers/relation_one_to_one.rb +38 -0
  20. data/app/services/imp_exp/serializers/time.rb +17 -0
  21. data/app/services/imp_exp/serializers/to_stable_uuid.rb +25 -0
  22. data/app/services/imp_exp/serializers/whole_records.rb +46 -0
  23. data/app/services/imp_exp/service.rb +9 -0
  24. data/app/services/imp_exp/utils/relation_scoper.rb +26 -0
  25. data/app/services/imp_exp/utils/xlsx_reader.rb +59 -0
  26. data/config/locales/en.yml +10 -0
  27. data/config/locales/fr.yml +10 -0
  28. data/lib/imp_exp/version.rb +5 -0
  29. data/lib/imp_exp.rb +10 -0
  30. data/lib/tasks/imp_exp.rake +8 -0
  31. metadata +130 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b947ce095504a9bc4f41b60310670a169259363d84af4518b281b099590770fa
4
+ data.tar.gz: d959c36041d86fee8c74190434ccc2d23f93259b29ce627ceaabc72a002454eb
5
+ SHA512:
6
+ metadata.gz: 361153041533b315f3e2277366511738aade7726a802652b19da6a54590f3f2d36b4bd6ff7a13e28fd1690c13791e6e8e3bb6355e425ac299d6f829fe3ddee71
7
+ data.tar.gz: 4129a1910c806240b85dfbb1d5bf0e0cf07afe8321d23b47dac4da37da9361ee2189ee2427c6122147302553e2f9571ec17cc040f40518f6a652a29d35e8829b
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2023 Nicolas Florentin
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # ImpExp
2
+
3
+ ## Fonctionnement
4
+
5
+ Features:
6
+ - Export de données brutes (ImpExp::Exporters::Base) et d'un fichier xlsx (ImpExp::Exporters::Xlsx)
7
+ - Import de données brutes (ImpExp::Importers::Base) et depuis un fichier xlsx (ImpExp::Importers::Xlsx) ainsi que les fichiers attachés (facultatifs)
8
+ - Exporter des fichiers (ImpExp::Exporters::Files)
9
+
10
+ Configuration:
11
+ - un fichier de configuration par modèle (= un onglet dans un classeur xlsx)/ La configuration d'un modèle liste les informations exportés et comment elles doivent être exportées (`serializers`)
12
+ - les imports qui listent les modèles à exporter pour chaque type d'export (voir exemple ImpExp::Imports::Account)
13
+ - les exports qui listent les modèles à exporter pour chaque type d'export (voir exemple ImpExp::Exports::Account)
14
+
15
+ Extensions:
16
+ - Des serializers peuvent être ajoutés, ils sont des services simples implémentant une méthod `load` et une méthode `dump` permettant de sérialiser et déserialiser une donnée d'un modèle (= une cellule d'une feuille de classeur)
17
+ - On peut ajouter un service de validation pour l'import Xlsx (voir exemple ImpExp::AccountSheetValidator), cette validation est bloquante pour l'import
18
+ - On peut ajouter un service de validation des données brutes (voir exemple ImpExp::DummyDataValidator), cette validation est bloquante pour l'import
19
+ - On peut facilement rajouter un format d'export ou d'import en s'inspirant de ImpExp::Exporters::Xlsx et ImpExp::Importers::Xlsx, en héritant de ImpExp::Exporters::Base et ImpExp::Importers::Base
20
+
21
+ Autres:
22
+ - la gem définit un service `Utils::XlsxReader` permettant d'abstraire la lecture d'un xlsx (et switcher de librairie facilement)
23
+ - la gem définit un service `PingObserver`, qui permet d'implémenter le pattern observer pour les imports donc suivre l'avancement de l'import
24
+
25
+ Les fichiers les plus importants à comprendre sont :
26
+ - `imp_exp/models/base.rb` : classe parente de tous les modèles
27
+ - `imp_exp/importers/base.rb` : service contenant la logique d'import
28
+
29
+ ## Bon à savoir pour les développeurs
30
+
31
+ ### Générer un export xlsx à partir des fixtures
32
+
33
+ ```bash
34
+ cd test/dummy
35
+ RAILS_ENV=test bundle exec rake imp_exp:generate_account_export
36
+ ```
37
+
38
+ ### Charger les fixtures dans la base
39
+
40
+ ```bash
41
+ cd test/dummy
42
+ FIXTURES_PATH=../fixtures RAILS_ENV=test bundle exec rails db:fixtures:load
43
+ ```
44
+
45
+ ### Créer des nouveaux modèles pour l'app dummy
46
+
47
+ Lorsqu'on créé des nouveaux modèles pour la base dummy, les fichiers (tests et fixtures) sont générés au mauvaise endroit (dans le dossier dummy),
48
+ il faut les déplacement manuellement dans le répertoire parent (imp_exp/test).
49
+
50
+ ### Exécuter les tests
51
+
52
+ ```bash
53
+ cd imp_exp
54
+ bin/test
55
+ ```
56
+
57
+ ## License
58
+
59
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ require "bundler/gem_tasks"
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module DataValidators
5
+ class Base
6
+ attr_reader :active_record_class, :columns, :row_offset
7
+
8
+ def initialize(model, row_offset = 0)
9
+ @active_record_class = model.active_record_class
10
+ @columns = model.active_record_class.columns.index_by(&:name).with_indifferent_access
11
+ @row_offset = row_offset
12
+ end
13
+
14
+ def call(attributes = [])
15
+ errors = []
16
+ attributes.each_with_index do |attrs, i|
17
+ attrs.each do |attr_name, attr_value|
18
+ column = columns[attr_name]
19
+
20
+ next if column&.array?
21
+
22
+ column_type = column&.type || :string
23
+
24
+ enums = active_record_class.defined_enums[column&.name]
25
+
26
+ if enums.present?
27
+ possible_values = enums.keys + enums.values
28
+ unless attr_value.in? possible_values
29
+ errors << error_hash(row_offset + i + 1, attr_name, attr_value, possible_values)
30
+ end
31
+ else
32
+ unless valid?(attr_value, column_type)
33
+ errors << error_hash(row_offset + i + 1, attr_name, attr_value, column_type)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ errors
40
+ end
41
+
42
+ def valid?(attribute_value, column_type)
43
+ return true if attribute_value.to_s.blank?
44
+
45
+ case column_type
46
+ when :string
47
+ return true
48
+ when :boolean
49
+ return false unless valid_boolean?(attribute_value)
50
+ when :bigint, :integer
51
+ return false unless valid_integer?(attribute_value)
52
+ when :decimal, :float
53
+ return false unless valid_decimal?(attribute_value)
54
+ when :date, :datetime
55
+ return false unless valid_datetime?(attribute_value)
56
+ when :time
57
+ return false unless valid_time?(attribute_value)
58
+ end
59
+
60
+ true
61
+ end
62
+
63
+ private
64
+
65
+ def error_hash(line, column, value, expected)
66
+ { line: line, column: column.to_s, value: value, expected: expected }
67
+ end
68
+
69
+ def valid_boolean?(value)
70
+ value.in? [true, false, "true", "false", "t", "f", 0, 1, "0", "1"]
71
+ end
72
+
73
+ def valid_integer?(value)
74
+ value.is_a?(Integer) || (value.is_a?(String) && value.to_i.to_s == value)
75
+ end
76
+
77
+ def valid_decimal?(value)
78
+ BigDecimal(value.to_s)
79
+ rescue ArgumentError
80
+ false
81
+ end
82
+
83
+ def valid_datetime?(value)
84
+ value.is_a?(Date) || value.is_a?(Time) || value.is_a?(DateTime)
85
+ end
86
+
87
+ def valid_time?(value)
88
+ Time.zone.parse(value.to_s) != nil
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Exporters
5
+ class Base
6
+ attr_reader :scoping_parent, :models, :logger
7
+
8
+ def initialize(scoping_parent, models, logger: nil)
9
+ @scoping_parent = scoping_parent
10
+ @models = models
11
+ @logger = logger || Logger.new('log/exports.log', 1)
12
+ end
13
+
14
+ def call
15
+ logger.info "Starting export #{self.class.name} for #{scoping_parent.model_name.singular} " \
16
+ "#{scoping_parent.name} (#{scoping_parent.id})"
17
+ begin
18
+ export_result = yield
19
+ logger.info "Export completed\n---------"
20
+ export_result
21
+ rescue StandardError => e
22
+ logger.error "Export interrupted with error : #{e.class.name} #{e.message}"
23
+ raise e
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zip'
4
+
5
+ module ImpExp
6
+ module Exporters
7
+ class Files < Base
8
+ def call
9
+ super do
10
+ tmp_file = Tempfile.new("exporters_files")
11
+ zip = Zip::OutputStream.write_buffer(tmp_file) do |output_stream|
12
+ models.each do |model|
13
+ files = model.files(scoping_parent)
14
+ next if files.empty?
15
+
16
+ files.each do |active_storage_file|
17
+ full_path = "#{model.files_directory_path}/#{model.filename(active_storage_file)}"
18
+ add_entry_to_zip(output_stream, active_storage_file, full_path)
19
+ end
20
+ end
21
+ end
22
+ zip.flush
23
+ tmp_file.rewind
24
+ { zip_stream: tmp_file }
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def add_entry_to_zip(output_stream, active_storage_file, full_path)
31
+ output_stream.put_next_entry(full_path)
32
+ output_stream.write active_storage_file.download
33
+ rescue ActiveStorage::FileNotFoundError
34
+ record = active_storage_file.record
35
+ logger.error "File not found for entity #{record.class} #{record.id} " \
36
+ "attribute #{active_storage_file.name}"
37
+ logger.info "Continuing..."
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'axlsx'
4
+
5
+ module ImpExp
6
+ module Exporters
7
+ class Xlsx < Base
8
+ def call
9
+ super do
10
+ rows_count = 0
11
+ package = Axlsx::Package.new
12
+ models.each do |model|
13
+ worksheet = package.workbook.add_worksheet(name: model.model_name)
14
+ worksheet.add_row model.headers
15
+ worksheet.add_row model.headers_translated
16
+ model.rows(scoping_parent).each do |row|
17
+ worksheet.add_row row, style: row_style(worksheet, model)
18
+ rows_count += 1
19
+ end
20
+ end
21
+ { xlsx_stream: package.to_stream, rows_count: rows_count }
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def row_style(worksheet, model)
28
+ {
29
+ datetime: worksheet.styles.add_style(num_fmt: Axlsx::NUM_FMT_YYYYMMDDHHMMSS)
30
+ }.values_at(*model.attribute_types)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "observer"
4
+
5
+ module ImpExp
6
+ module Importers
7
+ class Base
8
+ include Observable
9
+
10
+ attr_reader :scoping_parent, :models, :logger, :exception_notifier, :errors, :imported
11
+
12
+ ROW_OFFSET = 2
13
+
14
+ def initialize(scoping_parent, models, logger: nil, exception_notifier: nil)
15
+ @scoping_parent = scoping_parent
16
+ @models = models
17
+ @logger = logger || Logger.new('log/imports.log', 1)
18
+ @exception_notifier = exception_notifier
19
+ end
20
+
21
+ def call(data, files_directory_path = nil)
22
+ @errors = []
23
+ @imported = data.keys.map(&:model_name).index_with { 0 }
24
+ logger.info "Starting import #{self.class.name} for #{scoping_parent.model_name.singular} " \
25
+ "#{scoping_parent.name} (#{scoping_parent.id})"
26
+ begin
27
+ ping(stage: 'validation')
28
+ import(data, files_directory_path)
29
+ logger.info "Import completed\n---------"
30
+ import_result
31
+ rescue StandardError => e
32
+ logger.error "Import interrupted with error : #{e.class.name} #{e.message}:"
33
+ logger.error e.backtrace.join("\n")
34
+
35
+ raise e if raise_rescued_standard_error?
36
+
37
+ exception_notifier&.call(e)
38
+ import_result.merge(exception_raised: "#{e.class.name} #{e.message}")
39
+ end
40
+ end
41
+
42
+ def import(data, files_directory_path)
43
+ data.each do |model, model_data|
44
+ ping(forced: true, stage: 'import', model_name: model.model_name)
45
+ import_model_data(model, model_data, files_directory_path)
46
+ end
47
+ end
48
+
49
+ def import_result
50
+ { imported: imported, import_errors: errors }
51
+ end
52
+
53
+ def import_model_data(model, model_data, files_directory_path)
54
+ model_data.each_with_index do |row, i|
55
+ line = ROW_OFFSET + i + 1
56
+ model_name = model.model_name
57
+
58
+ relation_attributes = model.relation_names.index_with do |relation_name|
59
+ relation_attribute(model, relation_name, row[relation_name])
60
+ end
61
+
62
+ relation_attributes = process_errors(relation_attributes, model, line, row.to_s)
63
+
64
+ attrs = attributes_from_row(row, model).merge!(relation_attributes)
65
+
66
+ record = find_or_initialize_record(row.merge(relation_attributes), scoping_parent, model)
67
+
68
+ upsert_no_record_error(row, model, line) && next unless record
69
+
70
+ assign_attributes(record, attrs, model_name, line, row)
71
+
72
+ record.assign_attributes(model.extra_attributes_imported_at_runtime(scoping_parent, record, row))
73
+
74
+ serialize_attributes_if_needed(record, row, model)
75
+
76
+ attach_files(record, files_directory_path, model, row, line) if files_directory_path.present?
77
+
78
+ if record.save(context: :import)
79
+ @imported[model.model_name] += 1
80
+ next
81
+ end
82
+
83
+ upsert_error_messages(model_name, line, row.to_s, record.errors.full_messages)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def raise_rescued_standard_error?
90
+ Rails.env.test? || Rails.env.development?
91
+ end
92
+
93
+ def attributes_from_row(row, model)
94
+ row.slice(*model.attribute_to_import_names)
95
+ end
96
+
97
+ def find_or_initialize_record(attributes, scoping_parent, model)
98
+ record = model.find_record(attributes, scoping_parent)
99
+ return if !record && model.strict_find_by_attribute && attributes[model.strict_find_by_attribute.to_s].present?
100
+
101
+ record || model.active_record_class.new
102
+ end
103
+
104
+ def upsert_no_record_error(row, model, line)
105
+ conditions = row.slice(model.strict_find_by_attribute).map { |k, v| "#{k}=#{v}" }.join(' ')
106
+ message = I18n.t('imp_exp.importer.record_not_found', model_name: model.active_record_class.model_name,
107
+ conditions: conditions)
108
+ upsert_error_messages(model.model_name, line, row.to_s, message)
109
+ end
110
+
111
+ def assign_attributes(record, attributes, model_name, line, row)
112
+ record.assign_attributes(attributes)
113
+ rescue ActiveRecord::RecordNotSaved => e
114
+ upsert_error_messages(model_name, line, row.to_s, e.message)
115
+ end
116
+
117
+ def relation_attribute(model, relation_name, relation_datum)
118
+ serializer = model.relation_options(relation_name)[:serializer]
119
+ serializer.load(relation_datum, model: model, relation_name: relation_name, scoping_parent: scoping_parent)
120
+ end
121
+
122
+ def attach_files(record, files_directory_path, model, row, line)
123
+ model.file_attribute_names.each do |file_attribute_name|
124
+ file_path = row[file_attribute_name]
125
+ next if file_path.blank?
126
+
127
+ full_file_path = File.join(files_directory_path, file_path.strip)
128
+ begin
129
+ file = File.open(full_file_path)
130
+ record.send(file_attribute_name).attach(io: file, filename: File.basename(file_path))
131
+ rescue Errno::ENOENT
132
+ error_msg = I18n.t('imp_exp.importer.file_not_found', file_path: file_path)
133
+ upsert_error_messages(model.model_name, line, row.to_s, error_msg)
134
+ end
135
+ end
136
+ end
137
+
138
+ def process_errors(relation_attributes, model, line, row_str)
139
+ relation_attributes.transform_values do |data|
140
+ if data.is_a?(Array)
141
+ data.delete_if do |datum|
142
+ if data_is_an_error?(datum)
143
+ upsert_error_messages(model.model_name, line, row_str, datum.message)
144
+ true
145
+ end
146
+ end
147
+ elsif data_is_an_error?(data)
148
+ upsert_error_messages(model.model_name, line, row_str, data.message)
149
+ nil
150
+ else
151
+ data
152
+ end
153
+ end
154
+ end
155
+
156
+ def data_is_an_error?(data)
157
+ data.is_a?(ImpExp::Serializers::Errors::Base)
158
+ end
159
+
160
+ def upsert_error_messages(model_name, line, row_str, messages)
161
+ error = @errors.find { |e| e[:model_name] == model_name && e[:line] == line }
162
+ if error
163
+ error[:messages] = error[:messages] + Array(messages)
164
+ else
165
+ @errors << { model_name: model_name, line: line, row: row_str, messages: Array(messages) }
166
+ end
167
+ end
168
+
169
+ def serialize_attributes_if_needed(record, row, model)
170
+ model.attribute_to_import_names.each do |attribute_name|
171
+ serializer = model.attribute_options(attribute_name)[:serializer]
172
+ serializer && record.assign_attributes(attribute_name => serializer.load(row[attribute_name],
173
+ model: model,
174
+ record: record,
175
+ scoping_parent: scoping_parent))
176
+ end
177
+ end
178
+
179
+ def ping(stage: 'import', model_name: nil, forced: false)
180
+ if forced
181
+ changed
182
+ notify_observers({ stage: stage, model_name: model_name })
183
+ elsif @last_ping.nil? || @last_ping + ::Import::PING_TIMEOUT < Time.zone.now
184
+ changed
185
+ notify_observers({ stage: stage, model_name: model_name })
186
+ @last_ping = Time.zone.now
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Importers
5
+ class Xlsx < Base
6
+ attr_reader :xlsx_reader, :format_errors, :data_validation_errors,
7
+ :extra_format_validator_klass, :extra_data_validator_klass
8
+
9
+ # rubocop : disable Metrics/ParameterLists
10
+ def initialize(scoping_parent, models, logger: nil, extra_format_validator_klass: nil, exception_notifier: nil,
11
+ extra_data_validator_klass: nil)
12
+ # rubocop : enable Metrics/ParameterLists
13
+ super(scoping_parent, models, logger: logger, exception_notifier: exception_notifier)
14
+ @extra_format_validator_klass = extra_format_validator_klass
15
+ @extra_data_validator_klass = extra_data_validator_klass
16
+ @format_errors = []
17
+ @data_validation_errors = []
18
+ end
19
+
20
+ def call(xlsx_stream, files_directory_path = nil)
21
+ ping(forced: true, stage: "validation")
22
+ logger.info "validating xlsx import file"
23
+
24
+ @xlsx_reader = ImpExp::Utils::XlsxReader.new(xlsx_stream)
25
+
26
+ logger.info "validating sheets..."
27
+ validate_presence_of_sheets(xlsx_reader.sheet_names)
28
+
29
+ logger.info "validating columns..."
30
+ validate_presence_of_columns(xlsx_reader)
31
+
32
+ format_errors.concat extra_format_validator_klass.new(self).call if extra_format_validator_klass
33
+
34
+ logger.info "validating data..."
35
+ validate_data(xlsx_reader)
36
+
37
+ import_result = if format_errors.none? && data_validation_errors.none?
38
+ data = models.index_with { |model| xlsx_reader.sheet_data(model.model_name) }
39
+ super(data, files_directory_path)
40
+ else
41
+ { imported: {}, import_errors: [] }
42
+ end
43
+
44
+ { format_errors: format_errors, data_validation_errors: data_validation_errors }.merge(import_result)
45
+ end
46
+
47
+ private
48
+
49
+ def validate_presence_of_sheets(sheet_names)
50
+ expected_model_names = models.map(&:model_name).sort
51
+ return unless sheet_names.sort != expected_model_names
52
+
53
+ format_errors << { name: :invalid_sheets,
54
+ expected_sheets: expected_model_names,
55
+ in_file_sheets: sheet_names.sort }
56
+ end
57
+
58
+ def validate_presence_of_columns(xlsx_reader)
59
+ models.each do |model|
60
+ sheet_headers = xlsx_reader.sheet_header_names(model.model_name)
61
+ model_headers = model.headers
62
+ next if sheet_headers.sort == model_headers.sort
63
+
64
+ format_errors << { name: :invalid_columns, model_name: model.model_name,
65
+ expected_columns: model_headers.map(&:to_s),
66
+ in_file_columns: sheet_headers.map(&:to_s) }
67
+ end
68
+ end
69
+
70
+ def validate_data(xlsx_reader)
71
+ models.select { |m| m.model_name.in? xlsx_reader.sheet_names }.each do |model|
72
+ data = xlsx_reader.sheet_data(model.model_name)
73
+ errors = ImpExp::DataValidators::Base.new(model, ROW_OFFSET).call(data)
74
+ errors.concat extra_data_validator_klass.new(model, ROW_OFFSET).call(data) if extra_data_validator_klass
75
+ data_validation_errors.concat(errors.map { |err| err.merge(model_name: model.model_name) }) if errors.any?
76
+ end
77
+ data_validation_errors.flatten
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Models
5
+ class Base
6
+ def active_record_class
7
+ "::#{self.class.name.demodulize}".constantize
8
+ end
9
+
10
+ def find_record(attrs, scoping_parent)
11
+ scoped(scoping_parent).find_by(code: attrs[:code])
12
+ end
13
+
14
+ def strict_find_by_attribute
15
+ nil
16
+ end
17
+
18
+ def attributes
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def attribute_names
23
+ attributes.map(&:first)
24
+ end
25
+
26
+ def attribute_types
27
+ attribute_names.map { active_record_class.column_for_attribute(_1)&.type }
28
+ end
29
+
30
+ def attribute_to_import_names
31
+ attribute_names.reject { |attribute_name| attribute_options(attribute_name)[:imported] == false }
32
+ end
33
+
34
+ def extra_attributes_imported_at_runtime(scoping_parent, _record, _row)
35
+ scoping_parent_fk = scoping_parent_foreign_key_column_name(scoping_parent)
36
+ if scoping_parent && active_record_class.attribute_names.include?(scoping_parent_fk)
37
+ return { scoping_parent_fk.to_sym => scoping_parent.id }
38
+ end
39
+
40
+ {}
41
+ end
42
+
43
+ def file_attributes
44
+ []
45
+ end
46
+
47
+ def file_attribute_names
48
+ file_attributes.map(&:first)
49
+ end
50
+
51
+ def relations
52
+ []
53
+ end
54
+
55
+ def relation_names
56
+ relations.map(&:first)
57
+ end
58
+
59
+ def scoping_parent_foreign_key_column_name(scoping_parent)
60
+ "#{scoping_parent.model_name.singular}_id"
61
+ end
62
+
63
+ def scoped(scoping_parent)
64
+ active_record_class.rewhere({ scoping_parent_foreign_key_column_name(scoping_parent) => scoping_parent.id })
65
+ end
66
+
67
+ def preload_associations(collection)
68
+ collection
69
+ end
70
+
71
+ def order(collection)
72
+ collection.order(:created_at)
73
+ end
74
+
75
+ def model_name
76
+ self.class.name.demodulize
77
+ end
78
+
79
+ def relation_options(relation_name)
80
+ relations.find { |relation| relation[0] == relation_name.to_sym }&.slice(1) || {}
81
+ end
82
+
83
+ def attribute_options(attribute_name)
84
+ attributes.find { |attribute| attribute[0] == attribute_name.to_sym }&.slice(1) || {}
85
+ end
86
+
87
+ def headers_translated
88
+ (attributes + relations).map do |name, opts = {}|
89
+ i18n_path = opts[:i18n_path].presence || "#{active_record_class.model_name.i18n_key}.#{name}"
90
+ key = "activerecord.attributes.#{i18n_path}"
91
+ I18n.t(key, raise: true)
92
+ end
93
+ end
94
+
95
+ def headers
96
+ attribute_names + relation_names + file_attribute_names
97
+ end
98
+
99
+ def rows(scoping_parent)
100
+ rows = []
101
+ preload_associations(order(scoped(scoping_parent))).each do |record|
102
+ data = record.slice(attribute_names)
103
+ attribute_names.each do |attribute_name|
104
+ serializer = attribute_options(attribute_name)[:serializer]
105
+ data[attribute_name] = serializer.dump(record, attribute_name) if serializer
106
+ end
107
+ data = data.values
108
+ relation_names.each do |relation_name|
109
+ serializer = relation_options(relation_name)[:serializer]
110
+ data << serializer.dump(record, relation_name)
111
+ end
112
+ data.concat(file_attribute_names.map do |file_attribute_name|
113
+ file = record.send(file_attribute_name)
114
+ "#{files_directory_path}/#{filename(file)}" if file.attached?
115
+ end)
116
+ rows << data
117
+ end
118
+ rows
119
+ end
120
+
121
+ def files(scoping_parent)
122
+ scoped(scoping_parent).flat_map do |record|
123
+ record.slice(file_attribute_names).values.select(&:attached?)
124
+ end
125
+ end
126
+
127
+ def files_directory_path
128
+ active_record_class.model_name.element
129
+ end
130
+
131
+ def filename(active_storage_file)
132
+ "#{active_storage_file.name}-#{active_storage_file.record.code}#{file_extension(active_storage_file)}"
133
+ end
134
+
135
+ private
136
+
137
+ def file_extension(active_storage_file)
138
+ active_storage_file.filename.extension_with_delimiter.presence ||
139
+ ".#{Marcel::EXTENSIONS.invert[active_storage_file.content_type]}"
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ class PingObserver
5
+ attr_reader :import_service, :active_record_import
6
+
7
+ def initialize(import_service, active_record_import)
8
+ @import_service = import_service
9
+ @active_record_import = active_record_import
10
+ end
11
+
12
+ def setup
13
+ import_service.importers.each do |importer|
14
+ importer.add_observer(self)
15
+ end
16
+ end
17
+
18
+ def update(data_hash)
19
+ active_record_import.ping(**data_hash)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class ArrayLike
6
+ SEPARATOR = "|"
7
+
8
+ def dump(record, attribute_name)
9
+ record.send(attribute_name)&.join(SEPARATOR)
10
+ end
11
+
12
+ # rubocop: disable Lint/UnusedMethodArgument
13
+ def load(data, model: nil, record: nil, scoping_parent: nil)
14
+ return [] if data.blank?
15
+
16
+ data.to_s.split(SEPARATOR).map(&:strip)
17
+ end
18
+ # rubocop: enable Lint/UnusedMethodArgument
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop : disable Lint/EmptyClass
4
+ module ImpExp
5
+ module Serializers
6
+ module Errors
7
+ class Base
8
+ end
9
+ end
10
+ end
11
+ end
12
+ # rubocop : enable Lint/EmptyClass
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ module Errors
6
+ class RecordNotFound < Base
7
+ attr_reader :active_record_class, :find_args
8
+
9
+ def initialize(active_record_class, find_args)
10
+ super()
11
+ @active_record_class = active_record_class
12
+ @active_record_class = @active_record_class.constantize if @active_record_class.is_a?(String)
13
+ @find_args = find_args&.symbolize_keys
14
+ end
15
+
16
+ def message
17
+ conditions = find_args.map { |k, v| "#{k}=#{v}" }.join(' ')
18
+ I18n.t('imp_exp.importer.record_not_found', model_name: active_record_class.model_name,
19
+ conditions: conditions)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ module Errors
6
+ class WrongFormat < Base
7
+ attr_reader :raw_message
8
+
9
+ def initialize(raw_message = nil)
10
+ super()
11
+ @raw_message = raw_message
12
+ end
13
+
14
+ def message
15
+ I18n.t('imp_exp.importer.wrong_format', message: raw_message)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class PolymorphicRelationOneToOne
6
+ attr_reader :column, :relation_classes
7
+
8
+ def initialize(column, relation_classes)
9
+ @column = column
10
+ @relation_classes = relation_classes
11
+ end
12
+
13
+ def dump(record, relation_name)
14
+ relation = record.send(relation_name)
15
+ value = relation&.send(column)
16
+
17
+ "#{relation.model_name.name}|#{value}" if relation && value.present?
18
+ end
19
+
20
+ # rubocop: disable Lint/UnusedMethodArgument
21
+ def load(data, scoping_parent:, model: nil, relation_name: nil)
22
+ return if data.blank?
23
+
24
+ relation_class, value = data.strip.split("|")
25
+
26
+ return if relation_class.blank? || value.blank? || !relation_class.in?(relation_classes)
27
+
28
+ record = ImpExp::Utils::RelationScoper.call(relation_class.constantize, relation_class, scoping_parent)
29
+ .find_by({ column => value })
30
+ return record if record
31
+
32
+ ImpExp::Serializers::Errors::RecordNotFound.new(relation_class, { column => value })
33
+ end
34
+ # rubocop: enable Lint/UnusedMethodArgument
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class RelationOneToMany
6
+ SEPARATOR = ','
7
+
8
+ attr_reader :columns, :class_name
9
+
10
+ def initialize(*columns, class_name: nil)
11
+ @columns = columns
12
+ @class_name = class_name
13
+ end
14
+
15
+ def dump(record, relation_name)
16
+ records = record.send(relation_name)
17
+ records.map do |r|
18
+ columns.map do |column|
19
+ r.send(column)
20
+ end.join(COMPOSITE_IDENTIFIER_SEPARATOR)
21
+ end
22
+ .join(SEPARATOR)
23
+ end
24
+
25
+ def load(data, model:, relation_name:, scoping_parent:)
26
+ values_array = data.to_s.split(SEPARATOR).map do |chunk|
27
+ chunk.strip.split(COMPOSITE_IDENTIFIER_SEPARATOR).map(&:strip)
28
+ end
29
+
30
+ relation_class = class_name || relation_class_from_reflection(model, relation_name)
31
+ relation_class = relation_class.to_s.constantize
32
+
33
+ if values_array.empty?
34
+ records = relation_class.none
35
+ else
36
+ records = relation_class.where(columns.zip(values_array[0]).to_h)
37
+ end
38
+
39
+ values_array.uniq!
40
+
41
+ values_array[1..]&.each do |values|
42
+ records = records.or(relation_class.where(columns.zip(values).to_h))
43
+ end
44
+
45
+ records = Utils::RelationScoper.call(records, relation_class, scoping_parent)
46
+
47
+ values_array.map do |values|
48
+ record = records.find { |r| r.slice(columns).values.map(&:to_s) == values }
49
+ record || ImpExp::Serializers::Errors::RecordNotFound.new(relation_class, columns.zip(values).to_h)
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def relation_class_from_reflection(model, relation_name)
56
+ reflection = model.active_record_class.reflections[relation_name.to_s]
57
+ reflection.options[:class_name] || reflection.name.to_s.classify
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class RelationOneToOne
6
+ attr_reader :columns
7
+
8
+ def initialize(*columns)
9
+ @columns = columns
10
+ end
11
+
12
+ def dump(record, relation_name)
13
+ columns.map do |column|
14
+ record.send(relation_name)&.send(column)
15
+ end.join(COMPOSITE_IDENTIFIER_SEPARATOR)
16
+ end
17
+
18
+ def load(data, model:, relation_name:, scoping_parent:)
19
+ return if data.blank?
20
+
21
+ relation_class = model.active_record_class.reflections[relation_name.to_s].options[:class_name]
22
+ relation_class ||= relation_name.to_s.classify
23
+ relation_class = relation_class.constantize
24
+
25
+ data = data.to_s.split(COMPOSITE_IDENTIFIER_SEPARATOR).map(&:strip)
26
+
27
+ conditions = columns.zip(data).to_h
28
+
29
+ query_scoped = Utils::RelationScoper.call(relation_class.all, relation_class, scoping_parent)
30
+
31
+ record = query_scoped.find_by(conditions)
32
+ return record if record
33
+
34
+ ImpExp::Serializers::Errors::RecordNotFound.new(relation_class, conditions)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class Time
6
+ def dump(record, attribute_name)
7
+ record.send(attribute_name)&.strftime("%H:%M:%S")
8
+ end
9
+
10
+ # rubocop: disable Lint/UnusedMethodArgument
11
+ def load(data, model: nil, record: nil, scoping_parent: nil)
12
+ data&.strip
13
+ end
14
+ # rubocop: enable Lint/UnusedMethodArgument
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class ToStableUuid
6
+ # ce serializer permet de transformer un uuid en un autre uuid
7
+ # de manière stable, c'est à dire qu'appeler 2 fois le serializer
8
+ # avec le même argument (même uuid) donnera toujours le même uuid en
9
+ # résultat du dump
10
+
11
+ def dump(record, attribute_name)
12
+ value = record.send(attribute_name)
13
+ return if value.blank?
14
+
15
+ Digest::MD5.hexdigest(value).gsub(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '\1-\2-\3-\4-\5')
16
+ end
17
+
18
+ # rubocop: disable Lint/UnusedMethodArgument
19
+ def load(data, model: nil, record: nil, scoping_parent: nil)
20
+ data&.strip
21
+ end
22
+ # rubocop: enable Lint/UnusedMethodArgument
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ class WholeRecords
6
+ attr_reader :columns
7
+
8
+ def initialize(columns)
9
+ @columns = columns
10
+ end
11
+
12
+ def dump(record, relation_name)
13
+ record.send(relation_name).map do |associated_records|
14
+ associated_records.slice(columns)
15
+ end.to_json
16
+ end
17
+
18
+ # rubocop: disable Lint/UnusedMethodArgument
19
+ def load(data, model:, relation_name:, scoping_parent: nil)
20
+ return [] if data.blank?
21
+
22
+ begin
23
+ data = JSON.parse(data)
24
+ rescue JSON::ParserError => e
25
+ message = e.message.gsub("859: ", "") # Ruby 3.1 + 3.2 compatibility
26
+ return [ImpExp::Serializers::Errors::WrongFormat.new("#{relation_name} error | #{message}")]
27
+ end
28
+
29
+ unless data.is_a?(Array) && data.all? { |datum| datum.is_a?(Hash) }
30
+ return [ImpExp::Serializers::Errors::WrongFormat.new("#{relation_name} should be an array of hashes")]
31
+ end
32
+
33
+ data = data.map(&:deep_symbolize_keys)
34
+
35
+ relation_class = model.active_record_class.reflections[relation_name.to_s].options[:class_name]
36
+ relation_class ||= relation_name.to_s.classify
37
+ relation_class = relation_class.constantize
38
+
39
+ data.map do |hash|
40
+ relation_class.new(hash.slice(*columns))
41
+ end
42
+ end
43
+ # rubocop: enable Lint/UnusedMethodArgument
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ class Service
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Utils
5
+ class RelationScoper < Service
6
+ attr_reader :records, :relation_class, :scoping_parent
7
+
8
+ def initialize(records, relation_class, scoping_parent)
9
+ super()
10
+ @records = records
11
+ @relation_class = relation_class
12
+ @scoping_parent = scoping_parent
13
+ end
14
+
15
+ def call
16
+ records.where(id: imp_exp_model.scoped(scoping_parent))
17
+ end
18
+
19
+ private
20
+
21
+ def imp_exp_model
22
+ "ImpExp::Models::#{relation_class}".constantize.new
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xsv'
4
+
5
+ module ImpExp
6
+ module Utils
7
+ class XlsxReader
8
+ attr_reader :workbook, :skip_rows, :parse_headers
9
+
10
+ delegate :sheets, to: :workbook
11
+
12
+ def initialize(xlsx_stream, skip_rows: 1, parse_headers: true)
13
+ @workbook = Xsv.open(xlsx_stream, parse_headers: parse_headers)
14
+ @parse_headers = parse_headers
15
+ @skip_rows = skip_rows
16
+ end
17
+
18
+ def sheet_names
19
+ sheets.map(&:name)
20
+ end
21
+
22
+ def sheet_by_name(sheet_name)
23
+ workbook.sheets_by_name(sheet_name)[0]
24
+ end
25
+
26
+ def sheet_header_names(sheet_name)
27
+ return [] unless parse_headers
28
+
29
+ sheet = sheet_by_name(sheet_name)
30
+ return [] unless sheet
31
+
32
+ sheet[0].keys.compact.map(&:to_sym)
33
+ end
34
+
35
+ def sheet_row_values(sheet_name, row_nb)
36
+ sheet = sheet_by_name(sheet_name)
37
+ return [] unless sheet
38
+
39
+ data = sheet[row_nb + skip_rows]
40
+ return data unless parse_headers
41
+
42
+ data.values
43
+ end
44
+
45
+ def sheet_data(sheet_name)
46
+ data = sheet_by_name(sheet_name).entries[skip_rows..]
47
+
48
+ return [] if data.nil?
49
+
50
+ if parse_headers
51
+ data.map { |h| h.delete_if { |k, _v| k.nil? }.with_indifferent_access }
52
+ .delete_if { |h| h.values.all?(&:blank?) }
53
+ else
54
+ data.delete_if { |a| a.all?(&:blank?) }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,10 @@
1
+ en:
2
+ imp_exp:
3
+ importer:
4
+ invalid_columns: "The %{model_name} sheet does not contain the expected columns. Expected columns: %{expected_columns} Columns in the file: %{in_file_columns}."
5
+ invalid_sheets: "The file does not contain the expected sheets. Expected sheets: %{expected_sheets} Sheets in the file: %{in_file_sheets}."
6
+ record_not_found: "%{model_name} with %{conditions} conditions has not been found."
7
+ wrong_format: "Format error: %{message}."
8
+ model_errors: "%{model_name} sheet - Row %{line}: %{messages}"
9
+ data_validation_error: "%{model_name} sheet - Row %{line} - Column \"%{column}\": value \"%{value}\" incorrect, expected value: %{expected}"
10
+ file_not_found: "%{file_path} file not found."
@@ -0,0 +1,10 @@
1
+ fr:
2
+ imp_exp:
3
+ importer:
4
+ invalid_columns: "La feuille %{model_name} ne contient pas les colonnes attendues. Colonnes attendues : %{expected_columns}. Colonnes dans le fichier : %{in_file_columns}."
5
+ invalid_sheets: "Le fichier ne contient pas les feuilles attendues. Feuilles attendues : %{expected_sheets}. Feuilles dans le fichier : %{in_file_sheets}."
6
+ record_not_found: "%{model_name} avec les conditions %{conditions} n'a pas été trouvé."
7
+ wrong_format: "Erreur de format : %{message}."
8
+ model_errors: "Feuille %{model_name} - Ligne %{line} : %{messages}"
9
+ data_validation_error: "Feuille %{model_name} - Ligne %{line} - Colonne \"%{column}\" : valeur \"%{value}\" incorrecte, valeur attendue : %{expected}"
10
+ file_not_found: "Fichier %{file_path} non trouvé."
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ VERSION = "1.0.0"
5
+ end
data/lib/imp_exp.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImpExp
4
+ module Serializers
5
+ COMPOSITE_IDENTIFIER_SEPARATOR = '|'
6
+ end
7
+
8
+ class Engine < ::Rails::Engine
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :imp_exp do
4
+ task generate_account_export: :environment do
5
+ xlsx_stream = ImpExp::Exports::Account.new(Account.find_by!(code: "C1")).call[:xlsx_stream]
6
+ File.binwrite('../../account_export.xlsx', xlsx_stream.read)
7
+ end
8
+ end
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: imp_exp
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Nicolas Florentin
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-10-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: caxlsx
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 7.0.5
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 7.0.5
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubyzip
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 2.3.2
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 2.3.2
55
+ - !ruby/object:Gem::Dependency
56
+ name: xsv
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.2'
69
+ description: Import data from xlsx into a rails app and export data to xlsx from a
70
+ rails app
71
+ email:
72
+ - nicolas@sleede.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - MIT-LICENSE
78
+ - README.md
79
+ - Rakefile
80
+ - app/services/imp_exp/data_validators/base.rb
81
+ - app/services/imp_exp/exporters/base.rb
82
+ - app/services/imp_exp/exporters/files.rb
83
+ - app/services/imp_exp/exporters/xlsx.rb
84
+ - app/services/imp_exp/importers/base.rb
85
+ - app/services/imp_exp/importers/xlsx.rb
86
+ - app/services/imp_exp/models/base.rb
87
+ - app/services/imp_exp/ping_observer.rb
88
+ - app/services/imp_exp/serializers/array_like.rb
89
+ - app/services/imp_exp/serializers/errors/base.rb
90
+ - app/services/imp_exp/serializers/errors/record_not_found.rb
91
+ - app/services/imp_exp/serializers/errors/wrong_format.rb
92
+ - app/services/imp_exp/serializers/polymorphic_relation_one_to_one.rb
93
+ - app/services/imp_exp/serializers/relation_one_to_many.rb
94
+ - app/services/imp_exp/serializers/relation_one_to_one.rb
95
+ - app/services/imp_exp/serializers/time.rb
96
+ - app/services/imp_exp/serializers/to_stable_uuid.rb
97
+ - app/services/imp_exp/serializers/whole_records.rb
98
+ - app/services/imp_exp/service.rb
99
+ - app/services/imp_exp/utils/relation_scoper.rb
100
+ - app/services/imp_exp/utils/xlsx_reader.rb
101
+ - config/locales/en.yml
102
+ - config/locales/fr.yml
103
+ - lib/imp_exp.rb
104
+ - lib/imp_exp/version.rb
105
+ - lib/tasks/imp_exp.rake
106
+ homepage:
107
+ licenses:
108
+ - MIT
109
+ metadata: {}
110
+ post_install_message:
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '3.1'
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 3.4.10
126
+ signing_key:
127
+ specification_version: 4
128
+ summary: Import data from xlsx into a rails app and export data to xlsx from a rails
129
+ app
130
+ test_files: []