diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index c2d4c59ea..25278e6fc 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -52,4 +52,14 @@ module ImportsHelper [format, f] end 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 end diff --git a/app/models/user_import.rb b/app/models/user_import.rb new file mode 100644 index 000000000..2813ee5ff --- /dev/null +++ b/app/models/user_import.rb @@ -0,0 +1,119 @@ +# 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.inject({}) do |h, v| + 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 + h + 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 index 000000000..f2b2aaf90 --- /dev/null +++ b/app/views/imports/_users_fields_mapping.html.erb @@ -0,0 +1,53 @@ +
+
+

+ + <%= mapping_select_tag @import, 'login', :required => true %> +

+

+ + <%= mapping_select_tag @import, 'firstname', :required => true %> +

+

+ + <%= mapping_select_tag @import, 'lastname', :required => true %> +

+

+ + <%= mapping_select_tag @import, 'mail' %> +

+

+ + <%= mapping_select_tag @import, 'language' %> +

+

+ + <%= mapping_select_tag @import, 'admin' %> +

+

+ + <%= mapping_select_tag @import, 'auth_source' %> +

+

+ + <%= mapping_select_tag @import, 'password' %> +

+

+ + <%= mapping_select_tag @import, 'must_change_passwd' %> +

+

+ + <%= mapping_select_tag @import, 'status' %> +

+
+ +
+ <% @custom_fields.each do |field| %> +

+ + <%= mapping_select_tag @import, "cf_#{field.id}", :required => field.is_required? %> +

+ <% end %> +
+
diff --git a/app/views/imports/_users_mapping.html.erb b/app/views/imports/_users_mapping.html.erb new file mode 100644 index 000000000..8b9c758f9 --- /dev/null +++ b/app/views/imports/_users_mapping.html.erb @@ -0,0 +1,6 @@ +
+ <%= l(:label_fields_mapping) %> +
+ <%= render :partial => 'users_fields_mapping' %> +
+
diff --git a/app/views/imports/_users_mapping.js.erb b/app/views/imports/_users_mapping.js.erb new file mode 100644 index 000000000..a24d3bb86 --- /dev/null +++ b/app/views/imports/_users_mapping.js.erb @@ -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 index 000000000..5e11a9289 --- /dev/null +++ b/app/views/imports/_users_saved_objects.html.erb @@ -0,0 +1,24 @@ + + + + + + + + + + + + + <% saved_objects.each do |user| %> + + + + + + + + <% end %> + +
<%= t(:field_login) %><%= t(:field_firstname) %><%= t(:field_lastname) %><%= t(:field_mail) %><%= t(:field_admin) %><%= t(:field_status) %>
<%= avatar(user, :size => "14") %><%= link_to user.login, edit_user_path(user) %><%= user.firstname %><%= user.lastname %><%= mail_to(user.mail) %><%= checked_image user.admin? %><%= l(("status_#{User::LABEL_BY_STATUS[user.status]}")) %> +
diff --git a/app/views/imports/mapping.html.erb b/app/views/imports/mapping.html.erb index d5095bf50..448ed8e0d 100644 --- a/app/views/imports/mapping.html.erb +++ b/app/views/imports/mapping.html.erb @@ -23,7 +23,7 @@

<% end %> -<%= render :partial => "#{import_partial_prefix}_sidebar" %> +<%= render_if_exist :partial => "#{import_partial_prefix}_sidebar" %> <%= javascript_tag do %> $(document).ready(function() { diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index e7ca82428..e91ea80a7 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -12,4 +12,4 @@

<%= submit_tag l(:label_next).html_safe + " »".html_safe, :name => nil %>

<% end %> -<%= render :partial => "#{import_partial_prefix}_sidebar" %> +<%= render_if_exist :partial => "#{import_partial_prefix}_sidebar" %> diff --git a/app/views/imports/run.html.erb b/app/views/imports/run.html.erb index 50b47836d..4a1650f43 100644 --- a/app/views/imports/run.html.erb +++ b/app/views/imports/run.html.erb @@ -4,7 +4,7 @@
0 / <%= @import.total_items.to_i %>
-<%= render :partial => "#{import_partial_prefix}_sidebar" %> +<%= render_if_exist :partial => "#{import_partial_prefix}_sidebar" %> <%= javascript_tag do %> $(document).ready(function() { diff --git a/app/views/imports/settings.html.erb b/app/views/imports/settings.html.erb index c538ea983..09a7d5d6f 100644 --- a/app/views/imports/settings.html.erb +++ b/app/views/imports/settings.html.erb @@ -31,4 +31,4 @@

<%= submit_tag l(:label_next).html_safe + " »".html_safe, :name => nil %>

<% end %> -<%= render :partial => "#{import_partial_prefix}_sidebar" %> +<%= render_if_exist :partial => "#{import_partial_prefix}_sidebar" %> diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb index ca963ab37..6530812ac 100644 --- a/app/views/imports/show.html.erb +++ b/app/views/imports/show.html.erb @@ -27,4 +27,4 @@ <% end %> -<%= render :partial => "#{import_partial_prefix}_sidebar" %> +<%= render_if_exist :partial => "#{import_partial_prefix}_sidebar" %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 75c9eb465..b92df96f1 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -1,5 +1,10 @@
<%= 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 %>

<%=l(:label_user_plural)%>

diff --git a/config/locales/en.yml b/config/locales/en.yml index 89fd151c4..0836d2593 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 7e8cdeac9..03071fad9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 index 000000000..e31f4e0b9 --- /dev/null +++ b/test/fixtures/files/import_users.csv @@ -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 index 000000000..29ccbb1b9 --- /dev/null +++ b/test/fixtures/views/_partial.html.erb @@ -0,0 +1 @@ +partial html diff --git a/test/helpers/imports_helper_test.rb b/test/helpers/imports_helper_test.rb new file mode 100644 index 000000000..056e89ef5 --- /dev/null +++ b/test/helpers/imports_helper_test.rb @@ -0,0 +1,36 @@ +# 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 ImporstHelperTest < Redmine::HelperTest + include ImportsHelper + + def setup + controller.prepend_view_path "test/fixtures/views" + end + + def test_redner_if_exist_should_be_render_partial + assert_equal "partial html\n", render_if_exist(:partial => 'partial') + end + + def test_redner_if_exist_should_be_render_nil + assert_nil render_if_exist(:partial => 'non_exist_partial') + end +end diff --git a/test/unit/user_import_test.rb b/test/unit/user_import_test.rb new file mode 100644 index 000000000..082cb8360 --- /dev/null +++ b/test/unit/user_import_test.rb @@ -0,0 +1,163 @@ +# 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.merge!("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