imp_exp 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|