]> source.dussan.org Git - redmine.git/commitdiff
Import user accounts from CSV file (#33102).
authorGo MAEDA <maeda@farend.jp>
Sat, 30 May 2020 04:53:24 +0000 (04:53 +0000)
committerGo MAEDA <maeda@farend.jp>
Sat, 30 May 2020 04:53:24 +0000 (04:53 +0000)
Patch by Takenori TAKAKI.

git-svn-id: http://svn.redmine.org/redmine/trunk@19799 e93f8b46-1217-0410-a6f0-8f06a7374b81

18 files changed:
app/helpers/application_helper.rb
app/models/user_import.rb [new file with mode: 0644]
app/views/imports/_users_fields_mapping.html.erb [new file with mode: 0644]
app/views/imports/_users_mapping.html.erb [new file with mode: 0644]
app/views/imports/_users_mapping.js.erb [new file with mode: 0644]
app/views/imports/_users_saved_objects.html.erb [new file with mode: 0644]
app/views/imports/mapping.html.erb
app/views/imports/new.html.erb
app/views/imports/run.html.erb
app/views/imports/settings.html.erb
app/views/imports/show.html.erb
app/views/users/index.html.erb
config/locales/en.yml
config/routes.rb
test/fixtures/files/import_users.csv [new file with mode: 0644]
test/fixtures/views/_partial.html.erb [new file with mode: 0644]
test/helpers/application_helper_test.rb
test/unit/user_import_test.rb [new file with mode: 0644]

index bf55e05a515f02bbfd1b54e391f1d961f34552c6..1b4a65b4c2603739d725feb172bf571ce4a921d2 100644 (file)
@@ -1700,6 +1700,16 @@ module ApplicationHelper
     }
   end
 
+  def render_if_exist(options = {}, locals = {}, &block)
+    if options[:partial]
+      if lookup_context.exists?(options[:partial], lookup_context.prefixes, true)
+        render(options, locals, &block)
+      end
+    else
+      render(options, locals, &block)
+    end
+  end
+
   private
 
   def wiki_helper
diff --git a/app/models/user_import.rb b/app/models/user_import.rb
new file mode 100644 (file)
index 0000000..21c3e53
--- /dev/null
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-2020  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+class UserImport < Import
+  AUTO_MAPPABLE_FIELDS = {
+    'login' => 'field_login',
+    'firstname' => 'field_firstname',
+    'lastname' => 'field_lastname',
+    'mail' => 'field_mail',
+    'language' => 'field_language',
+    'admin' => 'field_admin',
+    'auth_source' => 'field_auth_source',
+    'password' => 'field_password',
+    'must_change_passwd' => 'field_must_change_passwd',
+    'status' => 'field_status'
+  }
+
+  def self.menu_item
+    :users
+  end
+
+  def self.layout
+    'admin'
+  end
+
+  def self.authorized?(user)
+    user.admin?
+  end
+
+  # Returns the objects that were imported
+  def saved_objects
+    User.where(:id => saved_items.pluck(:obj_id)).order(:id)
+  end
+
+  def mappable_custom_fields
+    UserCustomField.all
+  end
+
+  private
+
+  def build_object(row, item)
+    object = User.new
+
+    attributes = {
+      :login     => row_value(row, 'login'),
+      :firstname => row_value(row, 'firstname'),
+      :lastname  => row_value(row, 'lastname'),
+      :mail      => row_value(row, 'mail')
+    }
+
+    lang = nil
+    if language = row_value(row, 'language')
+      lang = find_language(language)
+    end
+    attributes[:language] = lang || Setting.default_language
+
+    if admin = row_value(row, 'admin')
+      if yes?(admin)
+        attributes['admin'] = '1'
+      end
+    end
+
+    if auth_source_name = row_value(row, 'auth_source')
+      if auth_source = AuthSource.find_by(:name => auth_source_name)
+        attributes[:auth_source_id] = auth_source.id
+      end
+    end
+
+    if password = row_value(row, 'password')
+      object.password = password
+      object.password_confirmation = password
+    end
+
+    if must_change_passwd = row_value(row, 'must_change_passwd')
+      if yes?(must_change_passwd)
+        attributes[:must_change_passwd] = '1'
+      end
+    end
+
+    if status_name = row_value(row, 'status')
+      if status = User::LABEL_BY_STATUS.key(status_name)
+        attributes[:status] = status
+      end
+    end
+
+    attributes['custom_field_values'] = object.custom_field_values.each_with_object({}) do |v, h|
+      value =
+        case v.custom_field.field_format
+        when 'date'
+          row_date(row, "cf_#{v.custom_field.id}")
+        else
+          row_value(row, "cf_#{v.custom_field.id}")
+        end
+      if value
+        h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(value, object)
+      end
+    end
+
+    object.send(:safe_attributes=, attributes, user)
+    object
+  end
+end
diff --git a/app/views/imports/_users_fields_mapping.html.erb b/app/views/imports/_users_fields_mapping.html.erb
new file mode 100644 (file)
index 0000000..f2b2aaf
--- /dev/null
@@ -0,0 +1,53 @@
+<div class="splitcontent">
+  <div class="splitcontentleft">
+  <p>
+    <label for="import_mapping_login"><%= l(:field_login) %></label>
+    <%= mapping_select_tag @import, 'login', :required => true %>
+  </p>
+  <p>
+    <label for="import_mapping_firstname"><%= l(:field_firstname) %></label>
+    <%= mapping_select_tag @import, 'firstname', :required => true %>
+  </p>
+  <p>
+    <label for="import_mapping_lastname"><%= l(:field_lastname) %></label>
+    <%= mapping_select_tag @import, 'lastname', :required => true %>
+  </p>
+  <p>
+    <label for="import_mapping_mail"><%= l(:field_mail) %></label>
+    <%= mapping_select_tag @import, 'mail' %>
+  </p>
+  <p>
+    <label for="import_mapping_language"><%= l(:field_language) %></label>
+    <%= mapping_select_tag @import, 'language' %>
+  </p>
+  <p>
+    <label for="import_mapping_admin"><%= l(:field_admin) %></label>
+    <%= mapping_select_tag @import, 'admin' %>
+  </p>
+  <p>
+    <label for="import_mapping_auth_source_id"><%= l(:field_auth_source) %></label>
+    <%= mapping_select_tag @import, 'auth_source' %>
+  </p>
+  <p>
+    <label for="import_mapping_password"><%= l(:field_password) %></label>
+    <%= mapping_select_tag @import, 'password' %>
+  </p>
+  <p>
+    <label for="import_mapping_must_change_passwd"><%= l(:field_must_change_passwd) %></label>
+    <%= mapping_select_tag @import, 'must_change_passwd' %>
+  </p>
+  <p>
+    <label for="import_mapping_status"><%= l(:field_status) %></label>
+    <%= mapping_select_tag @import, 'status' %>
+  </p>
+  </div>
+
+  <div class="splitcontentright">
+  <% @custom_fields.each do |field| %>
+    <p>
+      <label for="import_mapping_cf_<%= field.id %>"><%= field.name %></label>
+      <%= mapping_select_tag @import, "cf_#{field.id}", :required => field.is_required? %>
+    </p>
+  <% end %>
+  </div>
+</div>
diff --git a/app/views/imports/_users_mapping.html.erb b/app/views/imports/_users_mapping.html.erb
new file mode 100644 (file)
index 0000000..8b9c758
--- /dev/null
@@ -0,0 +1,6 @@
+<fieldset class="box tabular">
+  <legend><%= l(:label_fields_mapping) %></legend>
+  <div id="fields-mapping">
+    <%= render :partial => 'users_fields_mapping' %>
+  </div>
+</fieldset>
diff --git a/app/views/imports/_users_mapping.js.erb b/app/views/imports/_users_mapping.js.erb
new file mode 100644 (file)
index 0000000..a24d3bb
--- /dev/null
@@ -0,0 +1 @@
+$('#fields-mapping').html('<%= escape_javascript(render :partial => 'users_fields_mapping') %>');
diff --git a/app/views/imports/_users_saved_objects.html.erb b/app/views/imports/_users_saved_objects.html.erb
new file mode 100644 (file)
index 0000000..5e11a92
--- /dev/null
@@ -0,0 +1,24 @@
+<table id="saved-items" class="list">
+  <thead>
+  <tr>
+    <th><%= t(:field_login) %></th>
+    <th><%= t(:field_firstname) %></th>
+    <th><%= t(:field_lastname) %></th>
+    <th><%= t(:field_mail) %></th>
+    <th><%= t(:field_admin) %></th>
+    <th><%= t(:field_status) %></th>
+  </tr>
+  </thead>
+  <tbody>
+  <% saved_objects.each do |user| %>
+  <tr>
+    <td><%= avatar(user, :size => "14") %><%= link_to user.login, edit_user_path(user) %></td>
+    <td><%= user.firstname %></td>
+    <td><%= user.lastname %></td>
+    <td><%= mail_to(user.mail) %></td>
+    <td><%= checked_image user.admin? %></td>
+    <td><%= l(("status_#{User::LABEL_BY_STATUS[user.status]}")) %>
+  </tr>
+  <% end %>
+  </tbody>
+</table>
index d5095bf50b6765e0388ce3b9e33d60e7f4afd576..448ed8e0daf21feba31fc4393091b65ae89b5191 100644 (file)
@@ -23,7 +23,7 @@
   </p>
 <% end %>
 
-<%= render :partial => "#{import_partial_prefix}_sidebar" %>
+<%= render_if_exist :partial => "#{import_partial_prefix}_sidebar" %>
 
 <%= javascript_tag do %>
 $(document).ready(function() {
index e7ca8242826ba1f65a33a91c09ab797f5ebdddff..e91ea80a770561e694f9708388b2b2bba9ff82ec 100644 (file)
@@ -12,4 +12,4 @@
   <p><%= submit_tag l(:label_next).html_safe + " &#187;".html_safe, :name => nil %></p>
 <% end %>
 
-<%= render :partial => "#{import_partial_prefix}_sidebar" %>
+<%= render_if_exist :partial => "#{import_partial_prefix}_sidebar" %>
index 50b47836d2912e4bf60d3d0c5db587c368de5814..4a1650f43454bb440ad1102334ecaf3ebdcc2904 100644 (file)
@@ -4,7 +4,7 @@
   <div id="import-progress"><div id="progress-label">0 / <%= @import.total_items.to_i %></div></div>
 </div>
 
-<%= render :partial => "#{import_partial_prefix}_sidebar" %>
+<%= render_if_exist :partial => "#{import_partial_prefix}_sidebar" %>
 
 <%= javascript_tag do %>
 $(document).ready(function() {
index c538ea9834811e56607233e109f30832a381dead..09a7d5d6ffd737f390df03771732be3c62d628dd 100644 (file)
@@ -31,4 +31,4 @@
   <p><%= submit_tag l(:label_next).html_safe + " &#187;".html_safe, :name => nil %></p>
 <% end %>
 
-<%= render :partial => "#{import_partial_prefix}_sidebar" %>
+<%= render_if_exist :partial => "#{import_partial_prefix}_sidebar" %>
index ca963ab3713ae36387477449f3c0d63d0d2397aa..6530812ace4b686658861d2439d9e680c5b37eb4 100644 (file)
@@ -27,4 +27,4 @@
   </table>
 <% end %>
 
-<%= render :partial => "#{import_partial_prefix}_sidebar" %>
+<%= render_if_exist :partial => "#{import_partial_prefix}_sidebar" %>
index 75c9eb465c4d000862c32b401c266e7b3b8209c7..b92df96f1f3150d6c162dd694b01b32d265d229d 100644 (file)
@@ -1,5 +1,10 @@
 <div class="contextual">
 <%= link_to l(:label_user_new), new_user_path, :class => 'icon icon-add' %>
+  <%= actions_dropdown do %>
+    <% if User.current.allowed_to?(:import_users, nil, :global => true) %>
+      <%= link_to l(:button_import), new_users_import_path, :class => 'icon icon-import' %>
+    <% end %>
+  <% end %>
 </div>
 
 <h2><%=l(:label_user_plural)%></h2>
index 89fd151c43351fce80e02b862a6ad688ab020b59..0836d2593d18f262aafe7be1a62df8391ad268b9 100644 (file)
@@ -1295,3 +1295,4 @@ en:
   text_project_is_public_non_member: Public projects and their contents are available to all logged-in users.
   text_project_is_public_anonymous: Public projects and their contents are openly available on the network.
   label_import_time_entries: Import time entries
+  label_import_users: Import users
index 7e8cdeac9bb0d346a51ccdf352a6106c70ceffbe..03071fad9256659a3bcad473d3c566b949e0c67f 100644 (file)
@@ -66,6 +66,7 @@ Rails.application.routes.draw do
 
   get   '/issues/imports/new', :to => 'imports#new', :defaults => { :type => 'IssueImport' }, :as => 'new_issues_import'
   get   '/time_entries/imports/new', :to => 'imports#new', :defaults => { :type => 'TimeEntryImport' }, :as => 'new_time_entries_import'
+  get   '/users/imports/new', :to => 'imports#new', :defaults => { :type => 'UserImport' }, :as => 'new_users_import'
   post  '/imports', :to => 'imports#create', :as => 'imports'
   get   '/imports/:id', :to => 'imports#show', :as => 'import'
   match '/imports/:id/settings', :to => 'imports#settings', :via => [:get, :post], :as => 'import_settings'
diff --git a/test/fixtures/files/import_users.csv b/test/fixtures/files/import_users.csv
new file mode 100644 (file)
index 0000000..e31f4e0
--- /dev/null
@@ -0,0 +1,4 @@
+row;login;firstname;lastname;mail;language;admin;auth_source;password;must_change_passwd;status;phone_number
+1;user1;One;CSV;user1@somenet.foo;en;yes;;password;yes;active;000-1111-2222
+2;user2;Two;Import;user2@somenet.foo;ja;no;;password;no;locked;333-4444-5555
+3;user3;Three;User;user3@somenet.foo;-;no;LDAP test server;password;no;registered;666-7777-8888
diff --git a/test/fixtures/views/_partial.html.erb b/test/fixtures/views/_partial.html.erb
new file mode 100644 (file)
index 0000000..29ccbb1
--- /dev/null
@@ -0,0 +1 @@
+partial html
index 344f728238861e12c64b71307e96333bf7a29cd5..c027338802f13f648fd9134f113d7c2ebeb4dbda 100644 (file)
@@ -1911,6 +1911,16 @@ class ApplicationHelperTest < Redmine::HelperTest
     assert_match(/name="new_issue-[a-z0-9]{8}"/, labelled_form_for(Issue.new){})
   end
 
+  def test_redner_if_exist_should_be_render_partial
+    controller.prepend_view_path "test/fixtures/views"
+    assert_equal "partial html\n", render_if_exist(:partial => 'partial')
+  end
+
+  def test_redner_if_exist_should_be_render_nil
+    controller.prepend_view_path "test/fixtures/views"
+    assert_nil render_if_exist(:partial => 'non_exist_partial')
+  end
+
   private
 
   def wiki_links_with_special_characters
diff --git a/test/unit/user_import_test.rb b/test/unit/user_import_test.rb
new file mode 100644 (file)
index 0000000..fe1201d
--- /dev/null
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-2020  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require File.expand_path('../../test_helper', __FILE__)
+
+class UserImportTest < ActiveSupport::TestCase
+  include Redmine::I18n
+
+  def setup
+    set_language_if_valid 'en'
+    User.current = nil
+  end
+
+  def test_authorized
+    assert  UserImport.authorized?(User.find(1)) # admins
+    assert !UserImport.authorized?(User.find(2)) # dose not admin
+    assert !UserImport.authorized?(User.find(6)) # dows not admin
+  end
+
+  def test_maps_login
+    import = generate_import_with_mapping
+    first, second, third = new_records(User, 3) { import.run }
+    assert_equal 'user1', first.login
+    assert_equal 'user2', second.login
+    assert_equal 'user3', third.login
+  end
+
+  def test_maps_firstname
+    import = generate_import_with_mapping
+    first, second, third = new_records(User, 3) { import.run }
+    assert_equal 'One', first.firstname
+    assert_equal 'Two', second.firstname
+    assert_equal 'Three', third.firstname
+  end
+
+  def test_maps_lastname
+    import = generate_import_with_mapping
+    first, second, third = new_records(User, 3) { import.run }
+    assert_equal 'CSV', first.lastname
+    assert_equal 'Import', second.lastname
+    assert_equal 'User', third.lastname
+  end
+
+  def test_maps_mail
+    import = generate_import_with_mapping
+    first, second, third = new_records(User, 3) { import.run }
+    assert_equal 'user1@somenet.foo', first.mail
+    assert_equal 'user2@somenet.foo', second.mail
+    assert_equal 'user3@somenet.foo', third.mail
+  end
+
+  def test_maps_language
+    default_language = 'fr'
+    with_settings :default_language => default_language do
+      import = generate_import_with_mapping
+      first, second, third = new_records(User, 3) { import.run }
+      assert_equal 'en', first.language
+      assert_equal 'ja', second.language
+      assert_equal default_language, third.language
+    end
+  end
+
+  def test_maps_admin
+    import = generate_import_with_mapping
+    first, second, third = new_records(User, 3) { import.run }
+    assert first.admin?
+    assert_not second.admin?
+    assert_not third.admin?
+  end
+
+  def test_maps_auth_information
+    import = generate_import_with_mapping
+    first, second, third = new_records(User, 3) { import.run }
+    # use password
+    assert User.try_to_login(first.login, 'password', false)
+    assert User.try_to_login(second.login, 'password', false)
+    # use auth_source
+    assert_nil first.auth_source
+    assert_nil second.auth_source
+    assert third.auth_source
+    assert_equal 'LDAP test server', third.auth_source.name
+    AuthSourceLdap.any_instance.expects(:authenticate).with(third.login, 'ldapassword').returns(true)
+    assert User.try_to_login(third.login, 'ldapassword', false)
+  end
+
+  def test_map_must_change_password
+    import = generate_import_with_mapping
+    first, second, third = new_records(User, 3) { import.run }
+    assert first.must_change_password?
+    assert_not second.must_change_password?
+    assert_not third.must_change_password?
+  end
+
+  def test_maps_status
+    import = generate_import_with_mapping
+    first, second, third = new_records(User, 3) { import.run }
+    assert first.active?
+    assert second.locked?
+    assert third.registered?
+  end
+
+  def test_maps_custom_fields
+    phone_number_cf = UserCustomField.find(4)
+
+    import = generate_import_with_mapping
+    import.mapping["cf_#{phone_number_cf.id}"] = '11'
+    import.save!
+    first, second, third = new_records(User, 3) { import.run }
+
+    assert_equal '000-1111-2222', first.custom_field_value(phone_number_cf)
+    assert_equal '333-4444-5555', second.custom_field_value(phone_number_cf)
+    assert_equal '666-7777-8888', third.custom_field_value(phone_number_cf)
+  end
+
+  protected
+
+  def generate_import(fixture_name='import_users.csv')
+    import = UserImport.new
+    import.user_id = 1
+    import.file = uploaded_test_file(fixture_name, 'text/csv')
+    import.save!
+    import
+  end
+
+  def generate_import_with_mapping(fixture_name='import_users.csv')
+    import = generate_import(fixture_name)
+
+    import.settings = {
+      'separator' => ';', 'wrapper' => '"', 'encoding' => 'UTF-8',
+      'mapping' => {
+        'login' => '1',
+        'firstname' => '2',
+        'lastname' => '3',
+        'mail' => '4',
+        'language' => '5',
+        'admin' => '6',
+        'auth_source' => '7',
+        'password' => '8',
+        'must_change_passwd' => '9',
+        'status' => '10',
+      }
+    }
+    import.save!
+    import
+  end
+end