Project

General

Profile

Feature #33102 » csv_user_import.patch

Takenori TAKAKI, 2020-04-07 18:02

View differences:

app/models/user_import.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-2020  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
class UserImport < Import
21
  AUTO_MAPPABLE_FIELDS = {
22
    'login' => 'field_login',
23
    'firstname' => 'field_firstname',
24
    'lastname' => 'field_lastname',
25
    'mail' => 'field_mail',
26
    'language' => 'field_language',
27
    'admin' => 'field_admin',
28
    'auth_source' => 'field_auth_source',
29
    'password' => 'field_password',
30
    'must_change_passwd' => 'field_must_change_passwd',
31
    'status' => 'field_status'
32
  }
33

  
34
  def self.menu_item
35
    :users
36
  end
37

  
38
  def self.layout
39
    'admin'
40
  end
41

  
42
  def self.authorized?(user)
43
    user.admin?
44
  end
45

  
46
  # Returns the objects that were imported
47
  def saved_objects
48
    User.where(:id => saved_items.pluck(:obj_id)).order(:id)
49
  end
50

  
51
  def mappable_custom_fields
52
    UserCustomField.all
53
  end
54

  
55
  private
56

  
57
  def build_object(row, item)
58
    object = User.new
59

  
60
    attributes = {
61
      :login     => row_value(row, 'login'),
62
      :firstname => row_value(row, 'firstname'),
63
      :lastname  => row_value(row, 'lastname'),
64
      :mail      => row_value(row, 'mail')
65
    }
66

  
67
    lang = nil
68
    if language = row_value(row, 'language')
69
      lang = find_language(language)
70
    end
71
    attributes[:language] = lang || Setting.default_language
72

  
73
    if admin = row_value(row, 'admin')
74
      if yes?(admin)
75
        attributes['admin'] = '1'
76
      end
77
    end
78

  
79
    if auth_source_name = row_value(row, 'auth_source')
80
      if auth_source = AuthSource.find_by(:name => auth_source_name)
81
        attributes[:auth_source_id] = auth_source.id
82
      end
83
    end
84

  
85
    if password = row_value(row, 'password')
86
      object.password = password
87
      object.password_confirmation = password
88
    end
89

  
90
    if must_change_passwd = row_value(row, 'must_change_passwd')
91
      if yes?(must_change_passwd)
92
        attributes[:must_change_passwd] = '1'
93
      end
94
    end
95

  
96
    if status_name = row_value(row, 'status')
97
      if status = User::LABEL_BY_STATUS.key(status_name)
98
        attributes[:status] = status
99
      end
100
    end
101

  
102
    attributes['custom_field_values'] = object.custom_field_values.inject({}) do |h, v|
103
      value =
104
        case v.custom_field.field_format
105
        when 'date'
106
          row_date(row, "cf_#{v.custom_field.id}")
107
        else
108
          row_value(row, "cf_#{v.custom_field.id}")
109
        end
110
      if value
111
        h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(value, object)
112
      end
113
      h
114
    end
115

  
116
    object.send(:safe_attributes=, attributes, user)
117
    object
118
  end
119
end
app/views/imports/_users_fields_mapping.html.erb
1
<div class="splitcontent">
2
  <div class="splitcontentleft">
3
  <p>
4
    <label for="import_mapping_login"><%= l(:field_login) %></label>
5
    <%= mapping_select_tag @import, 'login', :required => true %>
6
  </p>
7
  <p>
8
    <label for="import_mapping_firstname"><%= l(:field_firstname) %></label>
9
    <%= mapping_select_tag @import, 'firstname', :required => true %>
10
  </p>
11
  <p>
12
    <label for="import_mapping_lastname"><%= l(:field_lastname) %></label>
13
    <%= mapping_select_tag @import, 'lastname', :required => true %>
14
  </p>
15
  <p>
16
    <label for="import_mapping_mail"><%= l(:field_mail) %></label>
17
    <%= mapping_select_tag @import, 'mail' %>
18
  </p>
19
  <p>
20
    <label for="import_mapping_language"><%= l(:field_language) %></label>
21
    <%= mapping_select_tag @import, 'language' %>
22
  </p>
23
  <p>
24
    <label for="import_mapping_admin"><%= l(:field_admin) %></label>
25
    <%= mapping_select_tag @import, 'admin' %>
26
  </p>
27
  <p>
28
    <label for="import_mapping_auth_source_id"><%= l(:field_auth_source) %></label>
29
    <%= mapping_select_tag @import, 'auth_source' %>
30
  </p>
31
  <p>
32
    <label for="import_mapping_password"><%= l(:field_password) %></label>
33
    <%= mapping_select_tag @import, 'password' %>
34
  </p>
35
  <p>
36
    <label for="import_mapping_must_change_passwd"><%= l(:field_must_change_passwd) %></label>
37
    <%= mapping_select_tag @import, 'must_change_passwd' %>
38
  </p>
39
  <p>
40
    <label for="import_mapping_status"><%= l(:field_status) %></label>
41
    <%= mapping_select_tag @import, 'status' %>
42
  </p>
43
  </div>
44

  
45
  <div class="splitcontentright">
46
  <% @custom_fields.each do |field| %>
47
    <p>
48
      <label for="import_mapping_cf_<%= field.id %>"><%= field.name %></label>
49
      <%= mapping_select_tag @import, "cf_#{field.id}", :required => field.is_required? %>
50
    </p>
51
  <% end %>
52
  </div>
53
</div>
app/views/imports/_users_mapping.html.erb
1
<fieldset class="box tabular">
2
  <legend><%= l(:label_fields_mapping) %></legend>
3
  <div id="fields-mapping">
4
    <%= render :partial => 'users_fields_mapping' %>
5
  </div>
6
</fieldset>
app/views/imports/_users_mapping.js.erb
1
$('#fields-mapping').html('<%= escape_javascript(render :partial => 'users_fields_mapping') %>');
app/views/imports/_users_saved_objects.html.erb
1
<table id="saved-items" class="list">
2
  <thead>
3
  <tr>
4
    <th><%= t(:field_login) %></th>
5
    <th><%= t(:field_firstname) %></th>
6
    <th><%= t(:field_lastname) %></th>
7
    <th><%= t(:field_mail) %></th>
8
    <th><%= t(:field_admin) %></th>
9
    <th><%= t(:field_status) %></th>
10
  </tr>
11
  </thead>
12
  <tbody>
13
  <% saved_objects.each do |user| %>
14
  <tr>
15
    <td><%= avatar(user, :size => "14") %><%= link_to user.login, edit_user_path(user) %></td>
16
    <td><%= user.firstname %></td>
17
    <td><%= user.lastname %></td>
18
    <td><%= mail_to(user.mail) %></td>
19
    <td><%= checked_image user.admin? %></td>
20
    <td><%= l(("status_#{User::LABEL_BY_STATUS[user.status]}")) %>
21
  </tr>
22
  <% end %>
23
  </tbody>
24
</table>
app/views/imports/mapping.html.erb
23 23
  </p>
24 24
<% end %>
25 25

  
26
<%= render :partial => "#{import_partial_prefix}_sidebar" %>
26
<%= render :partial => "#{import_partial_prefix}_sidebar" rescue nil %>
27 27

  
28 28
<%= javascript_tag do %>
29 29
$(document).ready(function() {
app/views/imports/new.html.erb
12 12
  <p><%= submit_tag l(:label_next).html_safe + " &#187;".html_safe, :name => nil %></p>
13 13
<% end %>
14 14

  
15
<%= render :partial => "#{import_partial_prefix}_sidebar" %>
15
<%= render :partial => "#{import_partial_prefix}_sidebar" rescue nil %>
app/views/imports/run.html.erb
4 4
  <div id="import-progress"><div id="progress-label">0 / <%= @import.total_items.to_i %></div></div>
5 5
</div>
6 6

  
7
<%= render :partial => "#{import_partial_prefix}_sidebar" %>
7
<%= render :partial => "#{import_partial_prefix}_sidebar" rescue nil %>
8 8

  
9 9
<%= javascript_tag do %>
10 10
$(document).ready(function() {
app/views/imports/settings.html.erb
31 31
  <p><%= submit_tag l(:label_next).html_safe + " &#187;".html_safe, :name => nil %></p>
32 32
<% end %>
33 33

  
34
<%= render :partial => "#{import_partial_prefix}_sidebar" %>
34
<%= render :partial => "#{import_partial_prefix}_sidebar" rescue nil %>
app/views/imports/show.html.erb
27 27
  </table>
28 28
<% end %>
29 29

  
30
<%= render :partial => "#{import_partial_prefix}_sidebar" %>
30
<%= render :partial => "#{import_partial_prefix}_sidebar" rescue nil %>
app/views/users/index.html.erb
1 1
<div class="contextual">
2 2
<%= link_to l(:label_user_new), new_user_path, :class => 'icon icon-add' %>
3
  <%= actions_dropdown do %>
4
    <% if User.current.allowed_to?(:import_users, nil, :global => true) %>
5
      <%= link_to l(:button_import), new_users_import_path, :class => 'icon icon-import' %>
6
    <% end %>
7
  <% end %>
3 8
</div>
4 9

  
5 10
<h2><%=l(:label_user_plural)%></h2>
config/locales/en.yml
1290 1290
  text_project_is_public_non_member: Public projects and their contents are available to all logged-in users.
1291 1291
  text_project_is_public_anonymous: Public projects and their contents are openly available on the network.
1292 1292
  label_import_time_entries: Import time entries
1293
  label_import_users: Import users
config/routes.rb
66 66

  
67 67
  get   '/issues/imports/new', :to => 'imports#new', :defaults => { :type => 'IssueImport' }, :as => 'new_issues_import'
68 68
  get   '/time_entries/imports/new', :to => 'imports#new', :defaults => { :type => 'TimeEntryImport' }, :as => 'new_time_entries_import'
69
  get   '/users/imports/new', :to => 'imports#new', :defaults => { :type => 'UserImport' }, :as => 'new_users_import'
69 70
  post  '/imports', :to => 'imports#create', :as => 'imports'
70 71
  get   '/imports/:id', :to => 'imports#show', :as => 'import'
71 72
  match '/imports/:id/settings', :to => 'imports#settings', :via => [:get, :post], :as => 'import_settings'
test/fixtures/files/import_users.csv
1
row;login;firstname;lastname;mail;language;admin;auth_source;password;must_change_passwd;status;phone_number
2
1;user1;One;CSV;user1@somenet.foo;en;yes;;password;yes;active;000-1111-2222
3
2;user2;Two;Import;user2@somenet.foo;ja;no;;password;no;locked;333-4444-5555
4
3;user3;Three;User;user3@somenet.foo;-;no;LDAP test server;password;no;registered;666-7777-8888
test/integration/routing/imports_test.rb
22 22
class RoutingImportsTest < Redmine::RoutingTest
23 23
  def test_imports
24 24
    should_route 'GET /issues/imports/new' => 'imports#new', :type => 'IssueImport'
25
    should_route 'GET /users/imports/new' => 'imports#new', :type => 'UserImport'
25 26

  
26 27
    should_route 'POST /imports' => 'imports#create'
27 28

  
test/unit/user_import_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-2020  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require File.expand_path('../../test_helper', __FILE__)
21

  
22
class UserImportTest < ActiveSupport::TestCase
23

  
24
  include Redmine::I18n
25

  
26
  def setup
27
    set_language_if_valid 'en'
28
    User.current = nil
29
  end
30

  
31
  def test_authorized
32
    assert  UserImport.authorized?(User.find(1)) # admins
33
    assert !UserImport.authorized?(User.find(2)) # dose not admin
34
    assert !UserImport.authorized?(User.find(6)) # dows not admin
35
  end
36

  
37
  def test_maps_login
38
    import = generate_import_with_mapping
39
    first, second, third = new_records(User, 3) { import.run }
40
    assert_equal 'user1', first.login
41
    assert_equal 'user2', second.login
42
    assert_equal 'user3', third.login
43
  end
44

  
45
  def test_maps_firstname
46
    import = generate_import_with_mapping
47
    first, second, third = new_records(User, 3) { import.run }
48
    assert_equal 'One', first.firstname
49
    assert_equal 'Two', second.firstname
50
    assert_equal 'Three', third.firstname
51
  end
52

  
53
  def test_maps_lastname
54
    import = generate_import_with_mapping
55
    first, second, third = new_records(User, 3) { import.run }
56
    assert_equal 'CSV', first.lastname
57
    assert_equal 'Import', second.lastname
58
    assert_equal 'User', third.lastname
59
  end
60

  
61
  def test_maps_mail
62
    import = generate_import_with_mapping
63
    first, second, third = new_records(User, 3) { import.run }
64
    assert_equal 'user1@somenet.foo', first.mail
65
    assert_equal 'user2@somenet.foo', second.mail
66
    assert_equal 'user3@somenet.foo', third.mail
67
  end
68

  
69
  def test_maps_language
70
    default_language = 'fr'
71
    with_settings :default_language => default_language do
72
      import = generate_import_with_mapping
73
      first, second, third = new_records(User, 3) { import.run }
74
      assert_equal 'en', first.language
75
      assert_equal 'ja', second.language
76
      assert_equal default_language, third.language
77
    end
78
  end
79

  
80
  def test_maps_admin
81
    import = generate_import_with_mapping
82
    first, second, third = new_records(User, 3) { import.run }
83
    assert first.admin?
84
    assert_not second.admin?
85
    assert_not third.admin?
86
  end
87

  
88
  def test_maps_auth_information
89
    import = generate_import_with_mapping
90
    first, second, third = new_records(User, 3) { import.run }
91
    # use password
92
    assert User.try_to_login(first.login, 'password', false)
93
    assert User.try_to_login(second.login, 'password', false)
94
    # use auth_source
95
    assert_nil first.auth_source
96
    assert_nil second.auth_source
97
    assert third.auth_source
98
    assert_equal 'LDAP test server', third.auth_source.name
99
    AuthSourceLdap.any_instance.expects(:authenticate).with(third.login, 'ldapassword').returns(true)
100
    assert User.try_to_login(third.login, 'ldapassword', false)
101
  end
102

  
103
  def test_map_must_change_password
104
    import = generate_import_with_mapping
105
    first, second, third = new_records(User, 3) { import.run }
106
    assert first.must_change_password?
107
    assert_not second.must_change_password?
108
    assert_not third.must_change_password?
109
  end
110

  
111
  def test_maps_status
112
    import = generate_import_with_mapping
113
    first, second, third = new_records(User, 3) { import.run }
114
    assert first.active?
115
    assert second.locked?
116
    assert third.registered?
117
  end
118

  
119
  def test_maps_custom_fields
120
    phone_number_cf = UserCustomField.find(4)
121

  
122
    import = generate_import_with_mapping
123
    import.mapping.merge!("cf_#{phone_number_cf.id}" => '11')
124
    import.save!
125
    first, second, third = new_records(User, 3) { import.run }
126

  
127
    assert_equal '000-1111-2222', first.custom_field_value(phone_number_cf)
128
    assert_equal '333-4444-5555', second.custom_field_value(phone_number_cf)
129
    assert_equal '666-7777-8888', third.custom_field_value(phone_number_cf)
130
  end
131

  
132
  protected
133

  
134
  def generate_import(fixture_name='import_users.csv')
135
    import = UserImport.new
136
    import.user_id = 1
137
    import.file = uploaded_test_file(fixture_name, 'text/csv')
138
    import.save!
139
    import
140
  end
141

  
142
  def generate_import_with_mapping(fixture_name='import_users.csv')
143
    import = generate_import(fixture_name)
144

  
145
    import.settings = {
146
      'separator' => ';', 'wrapper' => '"', 'encoding' => 'UTF-8',
147
      'mapping' => {
148
        'login' => '1',
149
        'firstname' => '2',
150
        'lastname' => '3',
151
        'mail' => '4',
152
        'language' => '5',
153
        'admin' => '6',
154
        'auth_source' => '7',
155
        'password' => '8',
156
        'must_change_passwd' => '9',
157
        'status' => '10',
158
      }
159
    }
160
    import.save!
161
    import
162
  end
163
end
(1-1/4)