diff options
| author | Zog | 2018-01-30 16:49:22 +0100 | 
|---|---|---|
| committer | Zog | 2018-02-20 09:28:26 +0100 | 
| commit | 0fcfccff47d9e63658978239484d2a01a414b5fc (patch) | |
| tree | 247dc64ac91662e22c6f953ba8e14632fc3a983c | |
| parent | a01519fc871e22a220157cfa8c8d6d5b5c80f5cb (diff) | |
| download | chouette-core-0fcfccff47d9e63658978239484d2a01a414b5fc.tar.bz2 | |
Refs #5765 @6h; Add a customizable importer mechanism
| -rw-r--r-- | app/models/chouette/stop_area.rb | 1 | ||||
| -rw-r--r-- | app/models/simple_importer.rb | 185 | ||||
| -rw-r--r-- | config/initializers/apartment.rb | 1 | ||||
| -rw-r--r-- | db/migrate/20180129210928_create_simple_importers.rb | 10 | ||||
| -rw-r--r-- | db/schema.rb | 11 | ||||
| -rw-r--r-- | lib/tasks/imports.rake | 8 | ||||
| -rw-r--r-- | spec/fixtures/simple_importer/stop_area.csv | 2 | ||||
| -rw-r--r-- | spec/fixtures/simple_importer/stop_area_full.csv | 3 | ||||
| -rw-r--r-- | spec/fixtures/simple_importer/stop_area_full_reverse.csv | 3 | ||||
| -rw-r--r-- | spec/fixtures/simple_importer/stop_area_incomplete.csv | 2 | ||||
| -rw-r--r-- | spec/fixtures/simple_importer/stop_area_missing_street_name.csv | 2 | ||||
| -rw-r--r-- | spec/models/simple_importer_spec.rb | 148 | 
12 files changed, 375 insertions, 1 deletions
| diff --git a/app/models/chouette/stop_area.rb b/app/models/chouette/stop_area.rb index bb8747faa..5afe63747 100644 --- a/app/models/chouette/stop_area.rb +++ b/app/models/chouette/stop_area.rb @@ -112,6 +112,7 @@ module Chouette          id.to_s        end      end +    alias_method :local_id, :user_objectid      alias_method :local_id, :user_objectid diff --git a/app/models/simple_importer.rb b/app/models/simple_importer.rb new file mode 100644 index 000000000..4f549c408 --- /dev/null +++ b/app/models/simple_importer.rb @@ -0,0 +1,185 @@ +class SimpleImporter < ActiveRecord::Base +  attr_accessor :configuration + +  def self.define name +    @importers ||= {} +    configuration = Configuration.new name +    yield configuration +    configuration.validate! +    @importers[name.to_sym] = configuration +  end + +  def self.find_configuration name +    @importers ||= {} +    configuration = @importers[name.to_sym] +    raise "Importer not found: #{name}" unless configuration +    configuration +  end + +  def initialize *args +    super *args +    self.configuration = self.class.find_configuration self.configuration_name +    self.journal ||= [] +  end + +  def resolve col_name, value, &block +    val = block.call(value) +    return val if val.present? +    @resolution_queue[[col_name.to_s, value]].push({record: @current_record, attribute: @current_attribute, block: block}) +    nil +  end + +  def import opts={} +    @verbose = opts.delete :verbose +    @resolution_queue = Hash.new{|h,k| h[k] = []} +    number_of_lines = 0 +    padding = 1 +    fail_with_error "File not found: #{self.filepath}" do +      number_of_lines = CSV.read(self.filepath, self.configuration.csv_options).length +      padding = [1, Math.log(number_of_lines, 10).ceil()].max +    end + +    current_line = 0 +    status = :success +    statuses = "" +    log "#{"%#{padding}d" % 0}/#{number_of_lines}", clear: true +    CSV.foreach(filepath, self.configuration.csv_options) do |row| +      @current_record = self.configuration.find_record row +      self.configuration.columns.each do |col| +        @current_attribute = col[:attribute] +        val = col[:value] +        if val.nil? || val.is_a?(Proc) +          if row.has_key? col.name +            if val.is_a?(Proc) +              val = instance_exec(row[col.name], &val) +            else +              val = row[col.name] +            end +          else +            self.journal.push({event: :column_not_found, message: "Column not found: #{col.name}", kind: :warning}) +            status = :success_with_warnings +          end +        end +        @current_record.send "#{@current_attribute}=", val if val +      end + +      fail_with_error ->(){ @current_record.errors.messages } do +        new_record = @current_record.new_record? +        @current_record.save! +        self.journal.push({event: (new_record ? :creation : :update), kind: :log}) +        statuses += new_record ? "✓" : "-" +      end +      self.configuration.columns.each do |col| +        if col.name && @resolution_queue.any? +          val = @current_record.send col[:attribute] +          (@resolution_queue.delete([col.name, val]) || []).each do |res| +            record = res[:record] +            attribute = res[:attribute] +            value = res[:block].call(val, record) +            record.send "#{attribute}=", value +            record.save! +          end +        end +      end +      current_line += 1 +      log "#{"%#{padding}d" % current_line}/#{number_of_lines}: #{statuses}", clear: true +    end +    self.update_attribute :status, status +  rescue FailedImport +    self.update_attribute :status, :failed +  ensure +    self.save +  end + +  protected + +  def fail_with_error msg +    begin +      yield +    rescue => e +      msg = msg.call if msg.is_a?(Proc) +      log "\nFAILED: \n errors: #{msg}\n exception: #{e.message}\n#{e.backtrace.join("\n")}", color: :red +      self.journal.push({message: msg, error: e.message, event: :error, kind: :error}) +      self.save +      raise FailedImport +    end +  end + +  def log msg, opts={} +    return unless @verbose +    out = "" +    if opts[:color] +      color = { +        red: "31", +        green: "32", +        orange: "33", +      }[opts[:color]] || "33" +    end +    out += "\e[#{color}m" if color +    if opts[:clear] && @prev_msg_size +      out += "\b"*@prev_msg_size +    end +    out += msg +    out += "\e[0m" if color +    print out +    @prev_msg_size = msg.size +    @prev_msg_size += 9 if color +  end + +  class FailedImport < RuntimeError +  end + +  class Configuration +    attr_accessor :model, :headers, :separator, :key +    attr_reader :columns + +    def initialize import_name +      @import_name = import_name +      @key = "id" +      @headers = true +      @separator = "," +      @columns = [] +    end + +    def validate! +      raise "Incomplete configuration, missing model for #{@import_name}" unless model.present? +    end + +    def attribute_for_col col_name +      column = self.columns.find{|c| c.name == col_name} +      column && column[:attribute] || col_name +    end + +    def find_record attrs +      model.find_or_initialize_by(attribute_for_col(@key) => attrs[@key.to_s]) +    end + +    def csv_options +      { +        headers: self.headers, +        col_sep: self.separator +      } +    end + +    def add_column name, opts={} +      @columns.push Column.new({name: name.to_s}.update(opts)) +    end + +    def add_value attribute, value +      @columns.push Column.new({attribute: attribute, value: value}) +    end + +    class Column +      attr_accessor :name +      def initialize opts={} +        @name = opts[:name] +        @options = opts +        @options[:attribute] ||= @name +      end + +      def [](key) +        @options[key] +      end +    end +  end +end diff --git a/config/initializers/apartment.rb b/config/initializers/apartment.rb index 2d06fb88b..fc652a2da 100644 --- a/config/initializers/apartment.rb +++ b/config/initializers/apartment.rb @@ -81,6 +81,7 @@ Apartment.configure do |config|      'ComplianceCheckMessage',      'Merge',      'CustomField', +    'SimpleImporter',    ]    # use postgres schemas? diff --git a/db/migrate/20180129210928_create_simple_importers.rb b/db/migrate/20180129210928_create_simple_importers.rb new file mode 100644 index 000000000..c2a918900 --- /dev/null +++ b/db/migrate/20180129210928_create_simple_importers.rb @@ -0,0 +1,10 @@ +class CreateSimpleImporters < ActiveRecord::Migration +  def change +    create_table :simple_importers do |t| +      t.string :configuration_name +      t.string :filepath +      t.string :status +      t.json :journal +    end +  end +end diff --git a/db/schema.rb b/db/schema.rb index 596682ce8..f77961f8d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -15,9 +15,10 @@ ActiveRecord::Schema.define(version: 20180202170009) do    # These are extensions that must be enabled in order to support this database    enable_extension "plpgsql" -  enable_extension "postgis"    enable_extension "hstore" +  enable_extension "postgis"    enable_extension "unaccent" +  enable_extension "objectid"    create_table "access_links", id: :bigserial, force: :cascade do |t|      t.integer  "access_point_id",                        limit: 8 @@ -119,6 +120,7 @@ ActiveRecord::Schema.define(version: 20180202170009) do      t.datetime "updated_at"      t.date     "end_date"      t.string   "date_type" +    t.string   "mode"    end    add_index "clean_ups", ["referential_id"], name: "index_clean_ups_on_referential_id", using: :btree @@ -724,6 +726,13 @@ ActiveRecord::Schema.define(version: 20180202170009) do      t.integer "line_id",      limit: 8    end +  create_table "simple_importers", id: :bigserial, force: :cascade do |t| +    t.string "configuration_name" +    t.string "filepath" +    t.string "status" +    t.json   "journal" +  end +    create_table "stop_area_referential_memberships", id: :bigserial, force: :cascade do |t|      t.integer "organisation_id",          limit: 8      t.integer "stop_area_referential_id", limit: 8 diff --git a/lib/tasks/imports.rake b/lib/tasks/imports.rake index 02e32fd3d..006945c07 100644 --- a/lib/tasks/imports.rake +++ b/lib/tasks/imports.rake @@ -8,4 +8,12 @@ namespace :import do    task netex_abort_old: :environment do      NetexImport.abort_old    end + +  desc "import the given file with the corresponding importer" +  task :import, [:configuration_name, :filepath] => :environment do |t, args| +    importer = SimpleImporter.create configuration_name: args[:configuration_name], filepath: args[:filepath] +    puts "\e[33m***\e[0m Start importing" +    importer.import(verbose: true) +    puts "\n\e[33m***\e[0m Import done, status: " + (importer.status == "success" ? "\e[32m" : "\e[31m" ) + importer.status + "\e[0m" +  end  end diff --git a/spec/fixtures/simple_importer/stop_area.csv b/spec/fixtures/simple_importer/stop_area.csv new file mode 100644 index 000000000..9361d022b --- /dev/null +++ b/spec/fixtures/simple_importer/stop_area.csv @@ -0,0 +1,2 @@ +name;lat;long;type;street_name +Nom du Stop;45.00;12;ZDEP;99 rue des Poissonieres diff --git a/spec/fixtures/simple_importer/stop_area_full.csv b/spec/fixtures/simple_importer/stop_area_full.csv new file mode 100644 index 000000000..250caab30 --- /dev/null +++ b/spec/fixtures/simple_importer/stop_area_full.csv @@ -0,0 +1,3 @@ +"id";"station_code";"uic_code";"country_code";"province";"district";"county";"station_name";"inactive";"change_timestamp";"longitude";"latitude";"parent_station_code";"additional_info";"external_reference";"timezone";"address";"postal_code";"city" +5669;"PAR";"PAR";"FRA";"";"";"";"Paris - All stations";f;"2017-07-17 11:56:53.138";2.35222190000002;48.856614;"";"";"{""Région"":""Ile-de-France""}";"Europe/Paris";"";"";"" +5748;"XED";"XED";"FRA";"";"";"";"Paris MLV";t;"2017-05-29 11:24:34.575";2.783409;48.870569;"PAR";"";"{""Région"":""Ile-de-France""}";"Europe/Paris";"";"";"" diff --git a/spec/fixtures/simple_importer/stop_area_full_reverse.csv b/spec/fixtures/simple_importer/stop_area_full_reverse.csv new file mode 100644 index 000000000..9ea15f6cc --- /dev/null +++ b/spec/fixtures/simple_importer/stop_area_full_reverse.csv @@ -0,0 +1,3 @@ +"id";"station_code";"uic_code";"country_code";"province";"district";"county";"station_name";"inactive";"change_timestamp";"longitude";"latitude";"parent_station_code";"additional_info";"external_reference";"timezone";"address";"postal_code";"city" +5748;"XED";"XED";"FRA";"";"";"";"Paris MLV";t;"2017-05-29 11:24:34.575";2.783409;48.870569;"PAR";"";"{""Région"":""Ile-de-France""}";"Europe/Paris";"";"";"" +5669;"PAR";"PAR";"FRA";"";"";"";"Paris - All stations";f;"2017-07-17 11:56:53.138";2.35222190000002;48.856614;"";"";"{""Région"":""Ile-de-France""}";"Europe/Paris";"";"";"" diff --git a/spec/fixtures/simple_importer/stop_area_incomplete.csv b/spec/fixtures/simple_importer/stop_area_incomplete.csv new file mode 100644 index 000000000..cd9447acd --- /dev/null +++ b/spec/fixtures/simple_importer/stop_area_incomplete.csv @@ -0,0 +1,2 @@ +name;lat;long;type;street_name +;45.00;12;ZDEP diff --git a/spec/fixtures/simple_importer/stop_area_missing_street_name.csv b/spec/fixtures/simple_importer/stop_area_missing_street_name.csv new file mode 100644 index 000000000..aa845c3f5 --- /dev/null +++ b/spec/fixtures/simple_importer/stop_area_missing_street_name.csv @@ -0,0 +1,2 @@ +name;lat;long;type;foo +Nom du Stop;45.00;12;ZDEP;blabla diff --git a/spec/models/simple_importer_spec.rb b/spec/models/simple_importer_spec.rb new file mode 100644 index 000000000..50958970c --- /dev/null +++ b/spec/models/simple_importer_spec.rb @@ -0,0 +1,148 @@ +RSpec.describe SimpleImporter do +  describe "#define" do +    context "with an incomplete configuration" do + +      it "should raise an error" do +        expect do +          SimpleImporter.define :foo +        end.to raise_error +      end +    end +    context "with a complete configuration" do +      before do +        SimpleImporter.define :foo do |config| +          config.model = "example" +        end +      end + +      it "should define an importer" do +        expect{SimpleImporter.find_configuration(:foo)}.to_not raise_error +        expect{SimpleImporter.new(configuration_name: :foo, filepath: "")}.to_not raise_error +        expect{SimpleImporter.find_configuration(:bar)}.to raise_error +        expect{SimpleImporter.new(configuration_name: :bar, filepath: "")}.to raise_error +        expect{SimpleImporter.create(configuration_name: :foo, filepath: "")}.to change{SimpleImporter.count}.by 1 +      end +    end +  end + +  describe "#import" do +    before(:each) do +      SimpleImporter.define :test do |config| +        config.model = Chouette::StopArea +        config.separator = ";" +        config.key = "name" +        config.add_column :name +        config.add_column :lat, attribute: :latitude +        config.add_column :lat, attribute: :longitude, value: ->(raw){ raw.to_f + 1 } +        config.add_column :type, attribute: :area_type, value: ->(raw){ raw&.downcase } +        config.add_column :street_name +        config.add_column :stop_area_referential, value: create(:stop_area_referential, objectid_format: :stif_netex) +      end +    end + +    it "should import the given file" do +      importer = SimpleImporter.new(configuration_name: :test, filepath: Rails.root + "spec/fixtures/simple_importer/stop_area.csv") +      expect{importer.import}.to change{Chouette::StopArea.count}.by 1 +      expect(importer.status).to eq "success" +      stop = Chouette::StopArea.last +      expect(stop.name).to eq "Nom du Stop" +      expect(stop.latitude).to eq 45.00 +      expect(stop.longitude).to eq 46.00 +      expect(stop.area_type).to eq "zdep" +      expect(importer.reload.journal.last["event"]).to eq("creation") +    end + +    context "with an already existing record" do +      before(:each){ +        create :stop_area, name: "Nom du Stop" +      } +      it "should only update the record" do +        importer = SimpleImporter.new(configuration_name: :test, filepath: Rails.root + "spec/fixtures/simple_importer/stop_area.csv") +        expect{importer.import}.to change{Chouette::StopArea.count}.by 0 +        expect(importer.status).to eq "success" +        stop = Chouette::StopArea.last +        expect(stop.name).to eq "Nom du Stop" +        expect(stop.latitude).to eq 45.00 +        expect(stop.longitude).to eq 46.00 +        expect(stop.area_type).to eq "zdep" +        expect(importer.reload.journal.last["event"]).to eq("update") +      end +    end + +    context "with a missing column" do +      it "should set an error message" do +        importer = SimpleImporter.new(configuration_name: :test, filepath: Rails.root + "spec/fixtures/simple_importer/stop_area_missing_street_name.csv") +        expect{importer.import}.to_not raise_error +        expect(importer.status).to eq "success_with_warnings" +        expect(importer.reload.journal.first["event"]).to eq("column_not_found") +      end +    end + +    context "with a incomplete dataset" do +      it "should create a StopArea" do +        importer = SimpleImporter.new(configuration_name: :test, filepath: Rails.root + "spec/fixtures/simple_importer/stop_area_incomplete.csv") +        expect{importer.import}.to_not raise_error +        expect(importer.status).to eq "failed" +        expect(importer.reload.journal.first["message"]).to eq({"name" => ["doit être rempli(e)"]}) +      end +    end + +    context "with a wrong filepath" do +      it "should create a StopArea" do +        importer = SimpleImporter.new(configuration_name: :test, filepath: Rails.root + "spec/fixtures/simple_importer/not_found.csv") +        expect{importer.import}.to_not raise_error +        expect(importer.status).to eq "failed" +        expect(importer.reload.journal.first["message"]).to eq "File not found: #{importer.filepath}" +      end +    end + +    context "with a full file" do +      before(:each) do +        SimpleImporter.define :test do |config| +          config.model = Chouette::StopArea +          config.separator = ";" +          config.key = "station_code" +          config.add_column :station_code, attribute: :registration_number +          config.add_column :country_code +          config.add_column :station_name, attribute: :name +          config.add_column :inactive, attribute: :deleted_at, value: ->(raw){ raw == "t" ? Time.now : nil } +          config.add_column :change_timestamp, attribute: :updated_at +          config.add_column :longitude +          config.add_column :latitude +          config.add_column :parent_station_code, attribute: :parent, value: ->(raw){ raw.present? && resolve(:station_code, raw){|value| Chouette::StopArea.find_by(registration_number: value) } } +          config.add_column :parent_station_code, attribute: :area_type, value: ->(raw){ raw.present? ? "zdep" : "gdl" } +          config.add_column :timezone, attribute: :time_zone +          config.add_column :address, attribute: :street_name +          config.add_column :postal_code, attribute: :zip_code +          config.add_column :city, attribute: :city_name +          config.add_value  :stop_area_referential_id, create(:stop_area_referential, objectid_format: :stif_netex).id +          config.add_value  :long_lat_type, "WGS84" +        end +      end + +      it "should import the given file" do +        importer = SimpleImporter.new(configuration_name: :test, filepath: Rails.root + "spec/fixtures/simple_importer/stop_area_full.csv") +        expect{importer.import}.to change{Chouette::StopArea.count}.by 2 +        expect(importer.status).to eq "success" +        first = Chouette::StopArea.find_by registration_number: "PAR" +        last = Chouette::StopArea.find_by registration_number: "XED" + +        expect(last.parent).to eq first +        expect(first.area_type).to eq "gdl" +        expect(last.area_type).to eq "zdep" +        expect(first.long_lat_type).to eq "WGS84" +      end + +      context "with a relation in reverse order" do +        it "should import the given file" do +          importer = SimpleImporter.new(configuration_name: :test, filepath: Rails.root + "spec/fixtures/simple_importer/stop_area_full_reverse.csv") +          expect{importer.import}.to change{Chouette::StopArea.count}.by 2 +          expect(importer.status).to eq "success" +          first = Chouette::StopArea.find_by registration_number: "XED" +          last = Chouette::StopArea.find_by registration_number: "PAR" +          expect(first.parent).to eq last +        end +      end +    end +  end +end | 
