imp_exp 1.0.0
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/MIT-LICENSE +20 -0
- data/README.md +59 -0
- data/Rakefile +5 -0
- data/app/services/imp_exp/data_validators/base.rb +92 -0
- data/app/services/imp_exp/exporters/base.rb +28 -0
- data/app/services/imp_exp/exporters/files.rb +41 -0
- data/app/services/imp_exp/exporters/xlsx.rb +34 -0
- data/app/services/imp_exp/importers/base.rb +191 -0
- data/app/services/imp_exp/importers/xlsx.rb +81 -0
- data/app/services/imp_exp/models/base.rb +143 -0
- data/app/services/imp_exp/ping_observer.rb +22 -0
- data/app/services/imp_exp/serializers/array_like.rb +21 -0
- data/app/services/imp_exp/serializers/errors/base.rb +12 -0
- data/app/services/imp_exp/serializers/errors/record_not_found.rb +24 -0
- data/app/services/imp_exp/serializers/errors/wrong_format.rb +20 -0
- data/app/services/imp_exp/serializers/polymorphic_relation_one_to_one.rb +37 -0
- data/app/services/imp_exp/serializers/relation_one_to_many.rb +61 -0
- data/app/services/imp_exp/serializers/relation_one_to_one.rb +38 -0
- data/app/services/imp_exp/serializers/time.rb +17 -0
- data/app/services/imp_exp/serializers/to_stable_uuid.rb +25 -0
- data/app/services/imp_exp/serializers/whole_records.rb +46 -0
- data/app/services/imp_exp/service.rb +9 -0
- data/app/services/imp_exp/utils/relation_scoper.rb +26 -0
- data/app/services/imp_exp/utils/xlsx_reader.rb +59 -0
- data/config/locales/en.yml +10 -0
- data/config/locales/fr.yml +10 -0
- data/lib/imp_exp/version.rb +5 -0
- data/lib/imp_exp.rb +10 -0
- data/lib/tasks/imp_exp.rake +8 -0
- 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,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,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,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é."
|
data/lib/imp_exp.rb
ADDED
@@ -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: []
|