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: []
         |