mirror of
https://github.com/fspc/BikeShed-1.git
synced 2025-02-28 08:43:23 -05:00
commit
4a30bc5efd
1
Gemfile
1
Gemfile
@ -35,6 +35,7 @@ group :development, :test do
|
||||
gem 'factory_girl_rails', '~> 1.2'
|
||||
gem 'pry', '~> 0.9.8'
|
||||
gem 'faker', '~> 1.2.0'
|
||||
gem 'colorize'
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
@ -69,6 +69,7 @@ GEM
|
||||
coffee-script-source
|
||||
execjs
|
||||
coffee-script-source (1.10.0)
|
||||
colorize (0.8.1)
|
||||
database_cleaner (1.2.0)
|
||||
decent_exposure (1.0.2)
|
||||
devise (2.0.6)
|
||||
@ -255,6 +256,7 @@ DEPENDENCIES
|
||||
cancan
|
||||
capybara (~> 2.2.1)
|
||||
coffee-rails (~> 3.2.1)
|
||||
colorize
|
||||
database_cleaner (~> 1.2.0)
|
||||
decent_exposure (~> 1.0.1)
|
||||
devise (~> 2.0.4)
|
||||
|
@ -1,13 +1,14 @@
|
||||
class Bike < ActiveRecord::Base
|
||||
acts_as_loggable
|
||||
attr_accessible :shop_id, :serial_number, :bike_brand_id, :model, :color, :bike_style_id, :seat_tube_height,
|
||||
:top_tube_length, :bike_wheel_size_id, :value, :bike_condition_id, :bike_purpose_id, :photo
|
||||
attr_accessible :shop_id, :serial_number, :bike_brand_id, :bike_model_id, :model, :color, :bike_style_id,
|
||||
:seat_tube_height, :top_tube_length, :bike_wheel_size_id, :value, :bike_condition_id, :bike_purpose_id, :photo
|
||||
|
||||
has_many :transactions
|
||||
|
||||
has_one :owner, :class_name => 'User'
|
||||
has_one :task_list, :as => :item, :dependent => :destroy
|
||||
belongs_to :bike_brand
|
||||
belongs_to :bike_model
|
||||
belongs_to :bike_style
|
||||
belongs_to :bike_condition
|
||||
belongs_to :bike_purpose
|
||||
|
160
app/models/bike_csv_importer.rb
Normal file
160
app/models/bike_csv_importer.rb
Normal file
@ -0,0 +1,160 @@
|
||||
require 'csv'
|
||||
|
||||
# Imports data from CSV file into the bikes database.
|
||||
class BikeCsvImporter
|
||||
|
||||
include BikeCsvImporter::Cache
|
||||
include BikeCsvImporter::Cleaner
|
||||
include BikeCsvImporter::BikeAttrs
|
||||
include BikeCsvImporter::Logs
|
||||
|
||||
attr_reader :file
|
||||
|
||||
# Default constructor
|
||||
#
|
||||
# @param [String] file Path to the CSV file
|
||||
def initialize(file)
|
||||
@file = file
|
||||
end
|
||||
|
||||
# Runs the import. Will print out progress to stdout
|
||||
#
|
||||
# @param [Boolean] dry_run If true, does not save data, only shows the progress of validation
|
||||
def run(dry_run)
|
||||
imported_count, skipped_count = 0, 0
|
||||
|
||||
puts "Performing a #{dry_run ? 'DRY RUN' : 'LIVE RUN'} of import"
|
||||
|
||||
fetch do |bike_hash|
|
||||
bike = new_bike bike_hash
|
||||
check_method = dry_run ? :valid? : :save
|
||||
|
||||
if bike.try check_method
|
||||
puts "Imported #{bike.shop_id}: #{bike}".green
|
||||
|
||||
logs = new_logs_entries bike, bike_hash
|
||||
logs.each do |log|
|
||||
if log.send check_method
|
||||
puts "\tLog entry created: #{log.inspect}".green
|
||||
else
|
||||
puts "\tLog entry creation failed: #{log.errors.full_messages.join '; '}".red
|
||||
end
|
||||
end
|
||||
|
||||
imported_count += 1
|
||||
else
|
||||
puts "Skipped #{bike.try(:shop_id) || bike_hash.values.first}: #{bike.try(:errors).try(:full_messages).try :join, '; '}".red
|
||||
skipped_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
puts "#{imported_count} bikes imported, #{skipped_count} bikes skipped, total of #{imported_count + skipped_count} rows in the CSV"
|
||||
end
|
||||
|
||||
# Analyzes and prints out the input CSV file values
|
||||
#
|
||||
# @param [Array<Strong>] fields If passed, analyze only the given fields (names are down cased)
|
||||
def analyze(fields = [])
|
||||
puts "Analyzing CSV values frequency for #{fields.any? ? fields.join(', ') + ' field' : 'all fields'}"
|
||||
|
||||
fields = fields.map &:downcase
|
||||
grouped = {}
|
||||
fetch do |bike_hash|
|
||||
bike_hash.each do |key, value|
|
||||
next if fields.any? && !fields.include?(key)
|
||||
grouped[key] ||= {}
|
||||
grouped[key][value] ||= 0
|
||||
grouped[key][value] += 1
|
||||
end
|
||||
end
|
||||
|
||||
grouped.each do |field, values|
|
||||
puts "#{field}:"
|
||||
values.each do |value, count|
|
||||
puts "\t#{value.inspect}: #{count}"
|
||||
end
|
||||
puts "\tTotal of #{values.count} distinct values"
|
||||
end
|
||||
end
|
||||
|
||||
# Imports new brands from CSV file (field 'make'). Will print out progress to stdout
|
||||
#
|
||||
# @param [Boolean] dry_run If true, does not save data, only shows the progress of validation
|
||||
def brands(dry_run)
|
||||
created_count, skipped_count = 0, 0
|
||||
|
||||
puts "Performing a #{dry_run ? 'DRY RUN' : 'LIVE RUN'} of brands import"
|
||||
|
||||
fetch do |bike_hash|
|
||||
make = clean_value bike_hash['make']
|
||||
brand = bike_attr_bike_brand make, true
|
||||
check_method = dry_run ? :valid? : :save
|
||||
|
||||
if brand.try :persisted?
|
||||
puts "Skipped already existing brand #{brand.brand}"
|
||||
skipped_count +=1
|
||||
elsif brand.try check_method
|
||||
puts "Created brand #{brand.brand}".green
|
||||
created_count += 1
|
||||
else
|
||||
puts "Skipped #{brand.try(:brand) || make}: #{brand.try(:errors).try(:full_messages).try(:join, '; ') || 'object not created'}".red
|
||||
skipped_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
puts "#{created_count} brand created, #{skipped_count} brand skipped, total of #{created_count + skipped_count} rows in the CSV"
|
||||
end
|
||||
|
||||
|
||||
|
||||
private
|
||||
|
||||
# Parses the CSV header & rows, yielding a block for each row (except the header)
|
||||
# Header is down cased!
|
||||
#
|
||||
# @param [Proc] &block The block to yield to
|
||||
def fetch
|
||||
CSV.foreach(file).each_with_index do |row, i|
|
||||
if i.zero?
|
||||
parse_header row
|
||||
else
|
||||
yield parse_bike(row)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Parses & stores the input header, down casing by the way
|
||||
#
|
||||
# @param [Array<String>] row
|
||||
def parse_header(row)
|
||||
@header = row.map(&:downcase)
|
||||
end
|
||||
|
||||
# Parses the input row into a hash with keys from the header, @see #parse_header
|
||||
#
|
||||
# @param [Array<String>] row
|
||||
#
|
||||
# @return [Hash]
|
||||
def parse_bike(row)
|
||||
@header.zip(row).to_h
|
||||
end
|
||||
|
||||
# Constructs a new Bike instance from the given hash from a CSV row
|
||||
#
|
||||
# @param [Hash] bike_hash
|
||||
#
|
||||
# @return [Bike]
|
||||
def new_bike(bike_hash)
|
||||
Bike.new bike_attrs(bike_hash)
|
||||
end
|
||||
|
||||
# Constructs new Bike Log Entries instances from the given hash from a CSV row
|
||||
#
|
||||
# @param [Bike] bike The Bike instance to construct log entries for
|
||||
# @param [Hash] bike_hash The input hash from a CSV row
|
||||
#
|
||||
# @return [Array<ActsAsLoggable::Log>]
|
||||
def new_logs_entries(bike, bike_hash)
|
||||
%i{ acquired comment gone }.map { |x| send :"log_entry_#{x}", bike, bike_hash }.compact
|
||||
end
|
||||
end
|
93
app/models/bike_csv_importer/bike_attrs.rb
Normal file
93
app/models/bike_csv_importer/bike_attrs.rb
Normal file
@ -0,0 +1,93 @@
|
||||
# Helper module to create various Bike instanct fields from a CSV row hash
|
||||
class BikeCsvImporter
|
||||
module BikeAttrs
|
||||
def bike_attr_fields
|
||||
{
|
||||
shop_id: 'velocipede number',
|
||||
bike_purpose_id: 'program',
|
||||
#gone: 'gone',
|
||||
value: 'price',
|
||||
bike_brand_id: 'make',
|
||||
bike_model_id: 'model',
|
||||
model: 'model',
|
||||
bike_style_id: nil,
|
||||
bike_condition_id: nil,
|
||||
seat_tube_height: nil,
|
||||
bike_wheel_size_id: nil,
|
||||
serial_number: nil,
|
||||
}
|
||||
end
|
||||
|
||||
def bike_attrs(bike_hash)
|
||||
bike_attr_fields.each_with_object({}) do |(model_field, csv_field), memo|
|
||||
memo[model_field] = send :"bike_attr_#{model_field}", clean_value(bike_hash[csv_field])
|
||||
end
|
||||
end
|
||||
|
||||
def bike_attr_shop_id(value)
|
||||
value.to_i
|
||||
end
|
||||
|
||||
def bike_attr_bike_purpose_id(value)
|
||||
map = {
|
||||
'SALE' => /shop|as(-|\s+)is|safety\s*check/,
|
||||
'BUILDBIKE' => /build|bikes.*world/,
|
||||
'STORAGE' => nil,
|
||||
'PARTS' => /part|frame/,
|
||||
'SCRAP' => /scrap|strip/,
|
||||
}
|
||||
|
||||
default = 'UNDETERMINED'
|
||||
test_value = value.try :downcase
|
||||
value = map.find { |_, regexp| regexp.try :match, test_value }.try :first
|
||||
|
||||
cached_bike_purpose(value || default).id
|
||||
end
|
||||
|
||||
def bike_attr_gone(value)
|
||||
%w{ yes yeah y }.include? value.try :downcase
|
||||
end
|
||||
|
||||
def bike_attr_value(value)
|
||||
value.try(:gsub, /[$]/, '').try :to_i
|
||||
end
|
||||
|
||||
def bike_attr_bike_brand(value, new_if_empty = false)
|
||||
value = 'Unknown' if !value || value =~ /\Aunknown/i
|
||||
cached_bike_brand value, new_if_empty
|
||||
end
|
||||
|
||||
def bike_attr_bike_brand_id(value)
|
||||
bike_attr_bike_brand(value, false).try :id
|
||||
end
|
||||
|
||||
def bike_attr_bike_model_id(value)
|
||||
return unless value
|
||||
cached_bike_model(value).try :id
|
||||
end
|
||||
|
||||
def bike_attr_model(value)
|
||||
value if value && value !~ /unknown/i
|
||||
end
|
||||
|
||||
def bike_attr_bike_style_id(_)
|
||||
@bike_style_other_cache ||= BikeStyle.find_by_style('OTHER').id
|
||||
end
|
||||
|
||||
def bike_attr_bike_condition_id(_)
|
||||
@bike_condition_undertermined_cache ||= BikeCondition.find_by_condition('UNDETERMINED').id
|
||||
end
|
||||
|
||||
def bike_attr_seat_tube_height(_)
|
||||
0
|
||||
end
|
||||
|
||||
def bike_attr_bike_wheel_size_id(_)
|
||||
@bike_condition_wheel_size_undertermined_cache ||= BikeWheelSize.find_by_description('UNDETERMINED').id
|
||||
end
|
||||
|
||||
def bike_attr_serial_number(_)
|
||||
'UNDETERMINED'
|
||||
end
|
||||
end
|
||||
end
|
35
app/models/bike_csv_importer/cache.rb
Normal file
35
app/models/bike_csv_importer/cache.rb
Normal file
@ -0,0 +1,35 @@
|
||||
# Helper module to create various cached instances for bike CSV imports
|
||||
class BikeCsvImporter
|
||||
module Cache
|
||||
def cached_bike_purpose(purpose)
|
||||
@bike_purpose_cache ||= {}
|
||||
@bike_purpose_cache[purpose] ||= BikePurpose.find_by_purpose purpose
|
||||
end
|
||||
|
||||
def cached_bike_brand(brand, new_if_empty = false)
|
||||
@bike_brand_cache ||= {}
|
||||
if @bike_brand_cache.has_key? brand
|
||||
@bike_brand_cache[brand]
|
||||
else
|
||||
bike_brand = BikeBrand.where('lower(brand) = ?', brand.downcase).first
|
||||
bike_brand ||= BikeBrand.new(brand: brand) if new_if_empty
|
||||
|
||||
@bike_brand_cache[brand] = bike_brand
|
||||
end
|
||||
end
|
||||
|
||||
def cached_bike_model(model)
|
||||
@bike_model_cache ||= {}
|
||||
if @bike_model_cache.has_key? model
|
||||
@bike_model_cache[model]
|
||||
else
|
||||
@bike_model_cache[model] = BikeModel.where('lower(model) = ?', model.downcase).first
|
||||
end
|
||||
end
|
||||
|
||||
def cached_log_bike_action(action)
|
||||
@log_bike_action_id_cache ||= {}
|
||||
@log_bike_action_id_cache[action] ||= ActsAsLoggable::BikeAction.find_by_action(action)
|
||||
end
|
||||
end
|
||||
end
|
16
app/models/bike_csv_importer/cleaner.rb
Normal file
16
app/models/bike_csv_importer/cleaner.rb
Normal file
@ -0,0 +1,16 @@
|
||||
# Helper module to clean the incoming data from CSV fields
|
||||
class BikeCsvImporter
|
||||
module Cleaner
|
||||
def clean_value(value)
|
||||
value_or_nil strip_value(value)
|
||||
end
|
||||
|
||||
def strip_value(value)
|
||||
value.try(:strip).try(:gsub, /\n|\r/, '')
|
||||
end
|
||||
|
||||
def value_or_nil(value)
|
||||
return value unless ['?', 'n/a', 'missing', 'unknown', ''].include? value.try(:downcase)
|
||||
end
|
||||
end
|
||||
end
|
42
app/models/bike_csv_importer/logs.rb
Normal file
42
app/models/bike_csv_importer/logs.rb
Normal file
@ -0,0 +1,42 @@
|
||||
# Helper module to create ActsAsLoggable log entries for a Bike instance from a CSV row hash
|
||||
class BikeCsvImporter
|
||||
module Logs
|
||||
def log_entry_gone(bike, bike_hash)
|
||||
if clean_value(bike_hash['gone']).to_s =~ /y/i
|
||||
log_entry bike, log_entry_date(clean_value(bike_hash['date out'])), 'COMPLETED', 'Gone'
|
||||
end
|
||||
end
|
||||
|
||||
def log_entry_acquired(bike, bike_hash)
|
||||
if clean_value(bike_hash['date in'])
|
||||
log_entry bike, log_entry_date(clean_value(bike_hash['date in'])), 'ACQUIRED'
|
||||
end
|
||||
end
|
||||
|
||||
def log_entry_comment(bike, bike_hash)
|
||||
if clean_value(bike_hash['comment']).present?
|
||||
log_entry bike, nil, 'NOTE', clean_value(bike_hash['comment'])
|
||||
end
|
||||
end
|
||||
|
||||
def log_entry_date(value)
|
||||
return unless value
|
||||
Date.strptime value, '%m/%d/%y' rescue nil
|
||||
end
|
||||
|
||||
def log_entry(bike, date, type, description = nil)
|
||||
date ||= DateTime.now
|
||||
bike_action = cached_log_bike_action(type)
|
||||
|
||||
ActsAsLoggable::Log.new(
|
||||
loggable_type: bike.class.to_s,
|
||||
loggable_id: bike.id || bike.shop_id.to_i, # for dry run
|
||||
log_action_type: bike_action.class.to_s,
|
||||
log_action_id: bike_action.id,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
description: description,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
36
lib/tasks/import.rake
Normal file
36
lib/tasks/import.rake
Normal file
@ -0,0 +1,36 @@
|
||||
namespace :import do
|
||||
namespace :bikes do
|
||||
# Imports bikes info from CSV file
|
||||
#
|
||||
# rake import:bikes:csv[import.csv,dry] # dry run
|
||||
# rake import:bikes:csv[import.csv] # live import
|
||||
task :csv, [:file, :dry_run] => :environment do |t, args|
|
||||
file, dry_run = args.values_at :file, :dry_run
|
||||
next puts "Usage: rake #{t.name}[$csv_file_path[,$dry_run=dry]]" unless file
|
||||
next puts "File #{file} does not exist or is unreachable" unless File.readable? file
|
||||
BikeCsvImporter.new(file).run dry_run == 'dry'
|
||||
end
|
||||
|
||||
# Analyze a single field from CSV file
|
||||
#
|
||||
# rake import:bikes:analyze_csv[import.csv] # dumps all fields data
|
||||
# rake import:bikes:analyze_csv[import.csv,"date in"] # shows only single field
|
||||
task :analyze_csv, [:file, :field] => :environment do |t, args|
|
||||
file, field = args.values_at :file, :field
|
||||
next puts "Usage: rake #{t.name}[$csv_file_path[,\"$field_name\"]]" unless file
|
||||
next puts "File #{file} does not exist or is unreachable" unless File.readable? file
|
||||
BikeCsvImporter.new(file).analyze field ? [field] : []
|
||||
end
|
||||
|
||||
# Imports new brands from CSV file
|
||||
#
|
||||
# rake import:bikes:brands_csv[import.csv,dry] # dry run
|
||||
# rake import:bikes:brands_csv[import.csv] # live import
|
||||
task :brands_csv, [:file, :dry_run] => :environment do |t, args|
|
||||
file, dry_run = args.values_at :file, :dry_run
|
||||
next puts "Usage: rake #{t.name}[$csv_file_path[,$dry_run=dry]]" unless file
|
||||
next puts "File #{file} does not exist or is unreachable" unless File.readable? file
|
||||
BikeCsvImporter.new(file).brands dry_run == 'dry'
|
||||
end
|
||||
end
|
||||
end
|
Loading…
x
Reference in New Issue
Block a user