Skip to content

Commit 200da7b

Browse files
authored
Initial implementation of importer (#1)
1 parent 9555a2a commit 200da7b

40 files changed

+1141
-2
lines changed

Gemfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ gem 'jbuilder', '~> 2.5'
2323
# Use Redis adapter to run Action Cable in production
2424
# gem 'redis', '~> 4.0'
2525
# Use ActiveModel has_secure_password
26-
# gem 'bcrypt', '~> 3.1.7'
26+
gem 'bcrypt', '~> 3.1.7'
2727

2828
# Use ActiveStorage variant
2929
# gem 'mini_magick', '~> 4.8'
@@ -34,6 +34,9 @@ gem 'jbuilder', '~> 2.5'
3434
# Reduces boot times through caching; required in config/boot.rb
3535
gem 'bootsnap', '>= 1.1.0', require: false
3636

37+
# All sorts of useful information about every country packaged as pretty little country objects. It includes data from ISO 3166
38+
gem 'countries'
39+
3740
group :development, :test do
3841
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
3942
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
@@ -54,6 +57,7 @@ group :test do
5457
gem 'selenium-webdriver'
5558
# Easy installation and use of chromedriver to run system tests with Chrome
5659
gem 'chromedriver-helper'
60+
gem 'minitest-focus'
5761
end
5862

5963
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem

Gemfile.lock

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ GEM
4747
archive-zip (0.11.0)
4848
io-like (~> 0.3.0)
4949
arel (9.0.0)
50+
bcrypt (3.1.12)
5051
bindex (0.5.0)
5152
bootsnap (1.3.2)
5253
msgpack (~> 1.0)
@@ -66,6 +67,11 @@ GEM
6667
archive-zip (~> 0.10)
6768
nokogiri (~> 1.8)
6869
concurrent-ruby (1.0.5)
70+
countries (2.1.4)
71+
i18n_data (~> 0.8.0)
72+
money (~> 6.9)
73+
sixarm_ruby_unaccent (~> 1.1)
74+
unicode_utils (~> 1.4)
6975
crass (1.0.4)
7076
erubi (1.7.1)
7177
execjs (2.7.0)
@@ -74,6 +80,7 @@ GEM
7480
activesupport (>= 4.2.0)
7581
i18n (1.1.1)
7682
concurrent-ruby (~> 1.0)
83+
i18n_data (0.8.0)
7784
io-like (0.3.0)
7885
jbuilder (2.7.0)
7986
activesupport (>= 4.2.0)
@@ -94,6 +101,10 @@ GEM
94101
mini_mime (1.0.1)
95102
mini_portile2 (2.3.0)
96103
minitest (5.11.3)
104+
minitest-focus (1.1.2)
105+
minitest (>= 4, < 6)
106+
money (6.13.1)
107+
i18n (>= 0.6.4, <= 2)
97108
msgpack (1.2.4)
98109
multi_json (1.13.1)
99110
nio4r (2.3.1)
@@ -150,6 +161,7 @@ GEM
150161
selenium-webdriver (3.14.1)
151162
childprocess (~> 0.5)
152163
rubyzip (~> 1.2, >= 1.2.2)
164+
sixarm_ruby_unaccent (1.2.0)
153165
spring (2.0.2)
154166
activesupport (>= 4.2)
155167
spring-watcher-listen (2.0.1)
@@ -172,6 +184,7 @@ GEM
172184
thread_safe (~> 0.1)
173185
uglifier (4.1.19)
174186
execjs (>= 0.3.0, < 3)
187+
unicode_utils (1.4.0)
175188
web-console (3.7.0)
176189
actionview (>= 5.0)
177190
activemodel (>= 5.0)
@@ -187,12 +200,15 @@ PLATFORMS
187200
ruby
188201

189202
DEPENDENCIES
203+
bcrypt (~> 3.1.7)
190204
bootsnap (>= 1.1.0)
191205
byebug
192206
capybara (>= 2.15)
193207
chromedriver-helper
208+
countries
194209
jbuilder (~> 2.5)
195210
listen (>= 3.0.5, < 3.2)
211+
minitest-focus
196212
pg (>= 0.18, < 2.0)
197213
puma (~> 3.11)
198214
rails (~> 5.2.1)

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Müşteri
2+
3+
(*Müşteri* means customer in Turkish.)
4+
5+
A lot of applications deal with importing customers and/or users from other systems, usually by uploading a tabular file like CSV. I made a test assignment for job applicants at a previous job about this specific need. This codebase is an illustration of how I would implement it with Rails.
6+
7+
## Concepts
8+
9+
- **Space** is the top-level grouping of customers. It is the equivalent of an account.
10+
- **Customer** belongs to a space.
11+
- **User** can have access to multiple customers across spaces with only one set of credentials (email + password).
12+
13+
## Restrictions
14+
15+
- CSV must be delimited with comma `,` and UTF-8 encoded.
16+
- Only CSV is supported.
17+
- CSV file must have a header row with hard-coded column names.
18+
19+
## Improvements
20+
21+
Basically all the listed restrictions should be addressed in an ideal implementation.
22+
23+
## Run
24+
25+
There is nothing special about this Rails app. If you have PostgreSQL installed and have tried running Rails apps before it should be enough to run the following commands
26+
27+
```
28+
./bin/setup
29+
rails test # Tests should be passing
30+
rails server
31+
```

app/assets/stylesheets/main.scss

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
body {
2+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
3+
margin: 20px;
4+
}
5+
6+
a {
7+
color: #4078c0;
8+
}
9+
10+
.plain-table {
11+
border-collapse: collapse;
12+
13+
th {
14+
border-bottom: 1px solid #bbb;
15+
font-weight: bold;
16+
text-align: left;
17+
}
18+
19+
tr:hover td {
20+
background-color: #eee;
21+
}
22+
23+
th, td {
24+
vertical-align: top;
25+
}
26+
27+
th {
28+
padding: 0 20px 10px 0;
29+
}
30+
31+
td {
32+
padding: 10px 20px 10px 0;
33+
}
34+
}
35+
36+
.mv1 {
37+
margin: 24px 0;
38+
}
39+
40+
form.button_to {
41+
display: inline-block;
42+
}
43+
44+
.btn {
45+
display: inline-block;
46+
background-color: #6e5494;
47+
border: none;
48+
border-radius: 4px;
49+
color: #fff;
50+
cursor: pointer;
51+
padding: 0 0.5em;
52+
margin: 0;
53+
font-size: 20px;
54+
line-height: 2em;
55+
text-decoration: none;
56+
}
57+
58+
.btn-bg {
59+
background-color: lighten(#6e5494, 50%);
60+
padding: 12px;
61+
}
62+
63+
.customer-import-row--error-cell {
64+
color: #bd2c00;
65+
font-size: 14px;
66+
font-weight: bold;
67+
}
68+
69+
.customer-import--row-type-link {
70+
display: inline-block;
71+
border: 1px solid transparent;
72+
border-radius: 4px;
73+
padding: 4px 8px;
74+
}
75+
76+
.customer-import--row-type-link.unselected a {
77+
color: #666;
78+
}
79+
80+
.customer-import--row-type-link.selected {
81+
border-color: #4078c0;
82+
}
83+
84+
.password-input-container {
85+
display: inline-block;
86+
margin-right: 12px;
87+
88+
label {
89+
color: #666;
90+
display: inline-block;
91+
margin-bottom: 6px;
92+
}
93+
94+
input {
95+
font-size: 18px;
96+
width: 200px;
97+
padding: 6px;
98+
margin: 0;
99+
}
100+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,9 @@
11
class ApplicationController < ActionController::Base
2+
helper_method :current_space
3+
4+
protected
5+
6+
def current_space
7+
@current_space ||= Space.find_by_slug!(params[:tenant])
8+
end
29
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
class CustomerImportsController < ApplicationController
2+
def new; end
3+
4+
def create
5+
if params[:import_file].blank?
6+
redirect_to url_for(action: "new")
7+
return
8+
end
9+
10+
customer_import = current_space.customer_imports.create!(uploaded_file: params[:import_file])
11+
ParseCustomerImportJob.perform_later(customer_import)
12+
13+
redirect_to url_for(action: "show", id: customer_import.id)
14+
end
15+
16+
def show
17+
@customer_import = current_space.customer_imports.find(params[:id])
18+
19+
if @customer_import.parsing?
20+
render :parsing
21+
elsif @customer_import.finalizing?
22+
render :finalizing
23+
elsif params[:row_type].present?
24+
@rows = @customer_import.rows_of_type(params[:row_type])
25+
render
26+
else
27+
redirect_to url_for(row_type: "valid")
28+
end
29+
end
30+
31+
def finalize
32+
customer_import = current_space.customer_imports.find(params[:id])
33+
FinalizeCustomerImportJob.perform_later(customer_import)
34+
35+
redirect_to url_for(action: "show", id: customer_import.id)
36+
end
37+
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
class CustomerMembershipConfirmationsController < ApplicationController
2+
# GET /confirm/:membership_id/access?secret_token=xxx
3+
def new
4+
@membership = CustomerMembership.find_by(id: params[:membership_id], confirmation_token: String(params[:secret_token]))
5+
end
6+
7+
# POST /confirm/:membership_id/access
8+
def create
9+
@membership = CustomerMembership.find_by(id: params[:membership_id], confirmation_token: String(params[:secret_token]))
10+
11+
if @membership && @membership.confirm(confirmation_args)
12+
redirect_to url_for(action: "success")
13+
elsif @membership
14+
render :new
15+
else
16+
redirect_to url_for(action: "error")
17+
end
18+
end
19+
20+
# GET /confirm/:membership_id/success
21+
def success; end
22+
23+
# GET /confirm/:membership_id/error
24+
def error; end
25+
26+
private
27+
28+
def confirmation_args
29+
{
30+
user_password: params[:user_password],
31+
user_password_confirmation: params[:user_password_confirmation],
32+
}
33+
end
34+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class CustomersController < ApplicationController
2+
def index
3+
@customers = current_space.customers.order(:id)
4+
end
5+
6+
def show
7+
@customer = current_space.customers.find(params[:id])
8+
end
9+
end

app/controllers/home_controller.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class HomeController < ApplicationController
2+
def index
3+
default_space = Space.find_or_create_by!(slug: "alpi", title: "ALPI")
4+
5+
redirect_to app_path(tenant: default_space.slug)
6+
end
7+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class FinalizeCustomerImportJob < ApplicationJob
2+
after_enqueue do |job|
3+
job.arguments.first.touch(:started_finalizing_at)
4+
end
5+
6+
def perform(customer_import)
7+
customer_import.finalize!
8+
end
9+
end

app/jobs/parse_customer_import_job.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
class ParseCustomerImportJob < ApplicationJob
2+
after_enqueue do |job|
3+
job.arguments.first.touch(:started_parsing_at)
4+
end
5+
6+
def perform(customer_import)
7+
customer_import.parse!
8+
rescue => e
9+
customer_import.update(
10+
parsing_failed_at: Time.zone.now,
11+
parsing_failure_message: "Something went wrong while parsing the file",
12+
)
13+
14+
Rails.logger.error e.message
15+
end
16+
end
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class CustomerMembershipMailer < ApplicationMailer
2+
def confirmation(customer_membership)
3+
@customer_membership = customer_membership
4+
@user = customer_membership.user
5+
@customer = customer_membership.customer
6+
@space = customer_membership.customer.space
7+
8+
mail(to: @customer_membership.user.email)
9+
end
10+
end

app/models/customer.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class Customer < ApplicationRecord
2+
belongs_to :space
3+
has_many :memberships, class_name: "CustomerMembership"
4+
has_many :users, through: :memberships
5+
6+
def country
7+
ISO3166::Country.new(country_code) if country_code?
8+
end
9+
10+
def country_name
11+
country.name if country
12+
end
13+
end

0 commit comments

Comments
 (0)