From 035edd39c422c9434147a1b0ac457cb9383c9b5b Mon Sep 17 00:00:00 2001 From: Jean-Philippe Lang Date: Fri, 14 Aug 2015 08:20:32 +0000 Subject: [PATCH] Import issues from CSV file (#950). git-svn-id: http://svn.redmine.org/redmine/trunk@14493 e93f8b46-1217-0410-a6f0-8f06a7374b81 --- app/controllers/imports_controller.rb | 124 ++++++++++ app/helpers/imports_helper.rb | 33 +++ app/models/import.rb | 229 ++++++++++++++++++ app/models/import_item.rb | 22 ++ app/models/issue.rb | 10 +- app/models/issue_import.rb | 145 +++++++++++ app/views/imports/_fields_mapping.html.erb | 82 +++++++ app/views/imports/mapping.html.erb | 52 ++++ app/views/imports/mapping.js.erb | 1 + app/views/imports/new.html.erb | 15 ++ app/views/imports/run.html.erb | 20 ++ app/views/imports/run.js.erb | 11 + app/views/imports/settings.html.erb | 26 ++ app/views/imports/show.html.erb | 30 +++ app/views/issues/_sidebar.html.erb | 4 + config/locales/en.yml | 19 ++ config/locales/fr.yml | 19 ++ config/routes.rb | 7 + db/migrate/20150730122707_create_imports.rb | 13 + .../20150730122735_create_import_items.rb | 10 + lib/redmine.rb | 1 + lib/redmine/i18n.rb | 12 +- lib/redmine/utils.rb | 21 ++ public/stylesheets/application.css | 11 + test/fixtures/files/import_iso8859-1.csv | 3 + test/fixtures/files/import_issues.csv | 4 + test/fixtures/roles.yml | 1 + test/functional/imports_controller_test.rb | 200 +++++++++++++++ test/integration/routing/imports_test.rb | 36 +++ test/object_helpers.rb | 19 ++ test/test_helper.rb | 10 +- test/unit/issue_import_test.rb | 89 +++++++ 32 files changed, 1274 insertions(+), 5 deletions(-) create mode 100644 app/controllers/imports_controller.rb create mode 100644 app/helpers/imports_helper.rb create mode 100644 app/models/import.rb create mode 100644 app/models/import_item.rb create mode 100644 app/models/issue_import.rb create mode 100644 app/views/imports/_fields_mapping.html.erb create mode 100644 app/views/imports/mapping.html.erb create mode 100644 app/views/imports/mapping.js.erb create mode 100644 app/views/imports/new.html.erb create mode 100644 app/views/imports/run.html.erb create mode 100644 app/views/imports/run.js.erb create mode 100644 app/views/imports/settings.html.erb create mode 100644 app/views/imports/show.html.erb create mode 100644 db/migrate/20150730122707_create_imports.rb create mode 100644 db/migrate/20150730122735_create_import_items.rb create mode 100644 test/fixtures/files/import_iso8859-1.csv create mode 100644 test/fixtures/files/import_issues.csv create mode 100644 test/functional/imports_controller_test.rb create mode 100644 test/integration/routing/imports_test.rb create mode 100644 test/unit/issue_import_test.rb diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb new file mode 100644 index 000000000..2499a20ad --- /dev/null +++ b/app/controllers/imports_controller.rb @@ -0,0 +1,124 @@ +# Redmine - project management software +# Copyright (C) 2006-2015 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 'csv' + +class ImportsController < ApplicationController + + before_filter :find_import, :only => [:show, :settings, :mapping, :run] + before_filter :authorize_global + + helper :issues + + def new + end + + def create + @import = IssueImport.new + @import.user = User.current + @import.file = params[:file] + @import.set_default_settings + + if @import.save + redirect_to import_settings_path(@import) + else + render :action => 'new' + end + end + + def show + end + + def settings + if request.post? && @import.parse_file + redirect_to import_mapping_path(@import) + end + + rescue CSV::MalformedCSVError => e + flash.now[:error] = l(:error_invalid_csv_file_or_settings) + rescue ArgumentError, Encoding::InvalidByteSequenceError => e + flash.now[:error] = l(:error_invalid_file_encoding, :encoding => ERB::Util.h(@import.settings['encoding'])) + rescue SystemCallError => e + flash.now[:error] = l(:error_can_not_read_import_file) + end + + def mapping + issue = Issue.new + issue.project = @import.project + issue.tracker = @import.tracker + @attributes = issue.safe_attribute_names + @custom_fields = issue.editable_custom_field_values.map(&:custom_field) + + if request.post? + respond_to do |format| + format.html { + if params[:previous] + redirect_to import_settings_path(@import) + else + redirect_to import_run_path(@import) + end + } + format.js # updates mapping form on project or tracker change + end + end + end + + def run + if request.post? + @current = @import.run( + :max_items => max_items_per_request, + :max_time => 10.seconds + ) + respond_to do |format| + format.html { + if @import.finished? + redirect_to import_path(@import) + else + redirect_to import_run_path(@import) + end + } + format.js + end + end + end + + private + + def find_import + @import = Import.where(:user_id => User.current.id, :filename => params[:id]).first + if @import.nil? + render_404 + return + elsif @import.finished? && action_name != 'show' + redirect_to import_path(@import) + return + end + update_from_params if request.post? + end + + def update_from_params + if params[:import_settings].is_a?(Hash) + @import.settings ||= {} + @import.settings.merge!(params[:import_settings]) + @import.save! + end + end + + def max_items_per_request + 5 + end +end diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb new file mode 100644 index 000000000..63cffaca9 --- /dev/null +++ b/app/helpers/imports_helper.rb @@ -0,0 +1,33 @@ +# encoding: utf-8 +# +# Redmine - project management software +# Copyright (C) 2006-2015 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. + +module ImportsHelper + def options_for_mapping_select(import, field, options={}) + tags = "".html_safe + blank_text = options[:required] ? "-- #{l(:actionview_instancetag_blank_option)} --" : " ".html_safe + tags << content_tag('option', blank_text, :value => '') + tags << options_for_select(import.columns_options, import.mapping[field]) + tags + end + + def mapping_select_tag(import, field, options={}) + name = "import_settings[mapping][#{field}]" + select_tag name, options_for_mapping_select(import, field, options) + end +end diff --git a/app/models/import.rb b/app/models/import.rb new file mode 100644 index 000000000..b7064d932 --- /dev/null +++ b/app/models/import.rb @@ -0,0 +1,229 @@ +# Redmine - project management software +# Copyright (C) 2006-2015 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 'csv' + +class Import < ActiveRecord::Base + has_many :items, :class_name => 'ImportItem', :dependent => :delete_all + belongs_to :user + serialize :settings + + before_destroy :remove_file + + validates_presence_of :filename, :user_id + validates_length_of :filename, :maximum => 255 + + def initialize(*args) + super + self.settings ||= {} + end + + def file=(arg) + return unless arg.present? && arg.size > 0 + + self.filename = generate_filename + Redmine::Utils.save_upload(arg, filepath) + end + + def set_default_settings + separator = lu(user, :general_csv_separator) + if file_exists? + begin + content = File.read(filepath, 256, "rb") + separator = [',', ';'].sort_by {|sep| content.count(sep) }.last + rescue Exception => e + end + end + wrapper = '"' + encoding = lu(user, :general_csv_encoding) + + self.settings.merge!( + 'separator' => separator, + 'wrapper' => wrapper, + 'encoding' => encoding + ) + end + + def to_param + filename + end + + # Returns the full path of the file to import + # It is stored in tmp/imports with a random hex as filename + def filepath + if filename.present? && filename =~ /\A[0-9a-f]+\z/ + File.join(Rails.root, "tmp", "imports", filename) + else + nil + end + end + + # Returns true if the file to import exists + def file_exists? + filepath.present? && File.exists?(filepath) + end + + # Returns the headers as an array that + # can be used for select options + def columns_options(default=nil) + i = -1 + headers.map {|h| [h, i+=1]} + end + + # Parses the file to import and updates the total number of items + def parse_file + count = 0 + read_items {|row, i| count=i} + update_attribute :total_items, count + count + end + + # Reads the items to import and yields the given block for each item + def read_items + i = 0 + headers = true + read_rows do |row| + if i == 0 && headers + headers = false + next + end + i+= 1 + yield row, i if block_given? + end + end + + # Returns the count first rows of the file (including headers) + def first_rows(count=4) + rows = [] + read_rows do |row| + rows << row + break if rows.size >= count + end + rows + end + + # Returns an array of headers + def headers + first_rows(1).first || [] + end + + # Returns the mapping options + def mapping + settings['mapping'] || {} + end + + # Imports items and returns the position of the last processed item + def run(options={}) + max_items = options[:max_items] + max_time = options[:max_time] + current = 0 + imported = 0 + resume_after = items.maximum(:position) || 0 + interrupted = false + started_on = Time.now + + read_items do |row, position| + if (max_items && imported >= max_items) || (max_time && Time.now >= started_on + max_time) + interrupted = true + break + end + if position > resume_after + item = items.build + item.position = position + + if object = build_object(row) + if object.save + item.obj_id = object.id + else + item.message = object.errors.full_messages.join("\n") + end + end + + item.save! + imported += 1 + end + current = position + end + + if imported == 0 || interrupted == false + if total_items.nil? + update_attribute :total_items, current + end + update_attribute :finished, true + remove_file + end + + current + end + + def unsaved_items + items.where(:obj_id => nil) + end + + def saved_items + items.where("obj_id IS NOT NULL") + end + + private + + def read_rows + return unless file_exists? + + csv_options = {:headers => false} + csv_options[:encoding] = settings['encoding'].to_s.presence || 'UTF-8' + separator = settings['separator'].to_s + csv_options[:col_sep] = separator if separator.size == 1 + wrapper = settings['wrapper'].to_s + csv_options[:quote_char] = wrapper if wrapper.size == 1 + + CSV.foreach(filepath, csv_options) do |row| + yield row if block_given? + end + end + + def row_value(row, key) + if index = mapping[key].presence + row[index.to_i].presence + end + end + + # Builds a record for the given row and returns it + # To be implemented by subclasses + def build_object(row) + end + + # Generates a filename used to store the import file + def generate_filename + Redmine::Utils.random_hex(16) + end + + # Deletes the import file + def remove_file + if file_exists? + begin + File.delete filepath + rescue Exception => e + logger.error "Unable to delete file #{filepath}: #{e.message}" if logger + end + end + end + + # Returns true if value is a string that represents a true value + def yes?(value) + value == lu(user, :general_text_yes) || value == '1' + end +end diff --git a/app/models/import_item.rb b/app/models/import_item.rb new file mode 100644 index 000000000..67fb46cd7 --- /dev/null +++ b/app/models/import_item.rb @@ -0,0 +1,22 @@ +# Redmine - project management software +# Copyright (C) 2006-2015 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 ImportItem < ActiveRecord::Base + belongs_to :import + + validates_presence_of :import_id, :position +end diff --git a/app/models/issue.rb b/app/models/issue.rb index a4a7614d4..c7ad2a063 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -914,6 +914,14 @@ class Issue < ActiveRecord::Base end end + def notify? + @notify != false + end + + def notify=(arg) + @notify = arg + end + # Returns the number of hours spent on this issue def spent_hours @spent_hours ||= time_entries.sum(:hours) || 0 @@ -1625,7 +1633,7 @@ class Issue < ActiveRecord::Base end def send_notification - if Setting.notified_events.include?('issue_added') + if notify? && Setting.notified_events.include?('issue_added') Mailer.deliver_issue_add(self) end end diff --git a/app/models/issue_import.rb b/app/models/issue_import.rb new file mode 100644 index 000000000..2ff127605 --- /dev/null +++ b/app/models/issue_import.rb @@ -0,0 +1,145 @@ +# Redmine - project management software +# Copyright (C) 2006-2015 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 IssueImport < Import + + # Returns the objects that were imported + def saved_objects + object_ids = saved_items.pluck(:obj_id) + objects = Issue.where(:id => object_ids).order(:id).preload(:tracker, :priority, :status) + end + + # Returns a scope of projects that user is allowed to + # import issue to + def allowed_target_projects + Project.allowed_to(user, :import_issues) + end + + def project + project_id = mapping['project_id'].to_i + allowed_target_projects.find_by_id(project_id) || allowed_target_projects.first + end + + # Returns a scope of trackers that user is allowed to + # import issue to + def allowed_target_trackers + project.trackers + end + + def tracker + tracker_id = mapping['tracker_id'].to_i + allowed_target_trackers.find_by_id(tracker_id) || allowed_target_trackers.first + end + + # Returns true if missing categories should be created during the import + def create_categories? + user.allowed_to?(:manage_categories, project) && + mapping['create_categories'] == '1' + end + + # Returns true if missing versions should be created during the import + def create_versions? + user.allowed_to?(:manage_versions, project) && + mapping['create_versions'] == '1' + end + + private + + def build_object(row) + issue = Issue.new + issue.author = user + issue.notify = false + + attributes = { + 'project_id' => mapping['project_id'], + 'tracker_id' => mapping['tracker_id'], + 'subject' => row_value(row, 'subject'), + 'description' => row_value(row, 'description') + } + issue.send :safe_attributes=, attributes, user + + attributes = {} + if priority_name = row_value(row, 'priority') + if priority_id = IssuePriority.active.named(priority_name).first.try(:id) + attributes['priority_id'] = priority_id + end + end + if issue.project && category_name = row_value(row, 'category') + if category = issue.project.issue_categories.named(category_name).first + attributes['category_id'] = category.id + elsif create_categories? + category = issue.project.issue_categories.build + category.name = category_name + if category.save + attributes['category_id'] = category.id + end + end + end + if assignee_name = row_value(row, 'assigned_to') + if assignee = issue.assignable_users.detect {|u| u.name.downcase == assignee_name.downcase} + attributes['assigned_to_id'] = assignee.id + end + end + if issue.project && version_name = row_value(row, 'fixed_version') + if version = issue.project.versions.detect {|v| v.name.downcase == version_name.downcase} + attributes['fixed_version_id'] = version.id + elsif create_versions? + version = issue.project.versions.build + version.name = version_name + if version.save + attributes['fixed_version_id'] = version.id + end + end + end + if is_private = row_value(row, 'is_private') + if yes?(is_private) + attributes['is_private'] = '1' + end + end + if parent_issue_id = row_value(row, 'parent_issue_id') + if parent_issue_id =~ /\A(#)?(\d+)\z/ + parent_issue_id = $2 + if $1 + attributes['parent_issue_id'] = parent_issue_id + elsif issue_id = items.where(:position => parent_issue_id).first.try(:obj_id) + attributes['parent_issue_id'] = issue_id + end + else + attributes['parent_issue_id'] = parent_issue_id + end + end + if start_date = row_value(row, 'start_date') + attributes['start_date'] = start_date + end + if due_date = row_value(row, 'due_date') + attributes['due_date'] = due_date + end + if done_ratio = row_value(row, 'done_ratio') + attributes['done_ratio'] = done_ratio + end + + attributes['custom_field_values'] = issue.custom_field_values.inject({}) do |h, v| + if value = row_value(row, "cf_#{v.custom_field.id}") + h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(value, issue) + end + h + end + + issue.send :safe_attributes=, attributes, user + issue + end +end diff --git a/app/views/imports/_fields_mapping.html.erb b/app/views/imports/_fields_mapping.html.erb new file mode 100644 index 000000000..97dac0e66 --- /dev/null +++ b/app/views/imports/_fields_mapping.html.erb @@ -0,0 +1,82 @@ +
+
+

+ + <%= select_tag 'import_settings[mapping][project_id]', + options_for_select(project_tree_options_for_select(@import.allowed_target_projects, :selected => @import.project)), + :id => 'issue_project_id' %> +

+

+ + <%= select_tag 'import_settings[mapping][tracker_id]', + options_for_select(@import.allowed_target_trackers.sorted.map {|t| [t.name, t.id]}, @import.tracker.try(:id)), + :id => 'issue_tracker_id' %> +

+

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

+

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

+

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

+

+ + <%= mapping_select_tag @import, 'category' %> + <% if User.current.allowed_to?(:manage_categories, @import.project) %> + + <% end %> +

+

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

+

+ + <%= mapping_select_tag @import, 'fixed_version' %> + <% if User.current.allowed_to?(:manage_versions, @import.project) %> + + <% end %> +

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

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

+<% end %> +
+ +
+

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

+

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

+

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

+

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

+

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

+
+
+ diff --git a/app/views/imports/mapping.html.erb b/app/views/imports/mapping.html.erb new file mode 100644 index 000000000..283bddb04 --- /dev/null +++ b/app/views/imports/mapping.html.erb @@ -0,0 +1,52 @@ +

<%= l(:label_import_issues) %>

+ +<%= form_tag(import_mapping_path(@import), :id => "import-form") do %> +
+ <%= l(:label_fields_mapping) %> +
+ <%= render :partial => 'fields_mapping' %> +
+
+ +
+
+ <%= l(:label_file_content_preview) %> + + + <% @import.first_rows.each do |row| %> + + <%= row.map {|c| content_tag 'td', truncate(c.to_s, :length => 50) }.join("").html_safe %> + + <% end %> +
+
+
+ +

+ <%= button_tag("\xc2\xab " + l(:label_previous), :name => 'previous') %> + <%= submit_tag l(:button_import) %> +

+<% end %> + +<% content_for :sidebar do %> + <%= render :partial => 'issues/sidebar' %> +<% end %> + + +<%= javascript_tag do %> +$(document).ready(function() { + $('#fields-mapping').on('change', '#issue_project_id, #issue_tracker_id', function(){ + $.ajax({ + url: '<%= import_mapping_path(@import, :format => 'js') %>', + type: 'post', + data: $('#import-form').serialize() + }); + }); + + $('#import-form').submit(function(){ + $('#import-details').show().addClass('ajax-loading'); + $('#import-progress').progressbar({value: 0, max: <%= @import.total_items || 0 %>}); + }); + +}); +<% end %> diff --git a/app/views/imports/mapping.js.erb b/app/views/imports/mapping.js.erb new file mode 100644 index 000000000..8fdf14a36 --- /dev/null +++ b/app/views/imports/mapping.js.erb @@ -0,0 +1 @@ +$('#fields-mapping').html('<%= escape_javascript(render :partial => 'fields_mapping') %>'); diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb new file mode 100644 index 000000000..e20be353a --- /dev/null +++ b/app/views/imports/new.html.erb @@ -0,0 +1,15 @@ +

<%= l(:label_import_issues) %>

+ +<%= form_tag(imports_path, :multipart => true) do %> +
+ <%= l(:label_select_file_to_import) %> (CSV) +

+ <%= file_field_tag 'file' %> +

+
+

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

+<% end %> + +<% content_for :sidebar do %> + <%= render :partial => 'issues/sidebar' %> +<% end %> diff --git a/app/views/imports/run.html.erb b/app/views/imports/run.html.erb new file mode 100644 index 000000000..2a723537e --- /dev/null +++ b/app/views/imports/run.html.erb @@ -0,0 +1,20 @@ +

<%= l(:label_import_issues) %>

+ +
+
0 / <%= @import.total_items.to_i %>
+
+ +<% content_for :sidebar do %> + <%= render :partial => 'issues/sidebar' %> +<% end %> + +<%= javascript_tag do %> +$(document).ready(function() { + $('#import-details').addClass('ajax-loading'); + $('#import-progress').progressbar({value: 0, max: <%= @import.total_items.to_i %>}); + $.ajax({ + url: '<%= import_run_path(@import, :format => 'js') %>', + type: 'post' + }); +}); +<% end %> diff --git a/app/views/imports/run.js.erb b/app/views/imports/run.js.erb new file mode 100644 index 000000000..232904d7c --- /dev/null +++ b/app/views/imports/run.js.erb @@ -0,0 +1,11 @@ +$('#import-progress').progressbar({value: <%= @current.to_i %>}); +$('#progress-label').text("<%= @current.to_i %> / <%= @import.total_items.to_i %>"); + +<% if @import.finished? %> +window.location.href='<%= import_path(@import) %>'; +<% else %> +$.ajax({ + url: '<%= import_run_path(@import, :format => 'js') %>', + type: 'post' +}); +<% end %> diff --git a/app/views/imports/settings.html.erb b/app/views/imports/settings.html.erb new file mode 100644 index 000000000..7afbb84d5 --- /dev/null +++ b/app/views/imports/settings.html.erb @@ -0,0 +1,26 @@ +

<%= l(:label_import_issues) %>

+ +<%= form_tag(import_settings_path(@import), :id => "import-form") do %> +
+ <%= l(:label_options) %> +

+ + <%= select_tag 'import_settings[separator]', + options_for_select([[l(:label_coma_char), ','], [l(:label_semi_colon_char), ';']], @import.settings['separator']) %> +

+

+ + <%= select_tag 'import_settings[wrapper]', + options_for_select([[l(:label_quote_char), "'"], [l(:label_double_quote_char), '"']], @import.settings['wrapper']) %> +

+

+ + <%= select_tag 'import_settings[encoding]', options_for_select(Setting::ENCODINGS, @import.settings['encoding']) %> +

+
+

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

+<% end %> + +<% content_for :sidebar do %> + <%= render :partial => 'issues/sidebar' %> +<% end %> diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb new file mode 100644 index 000000000..ad6ae3681 --- /dev/null +++ b/app/views/imports/show.html.erb @@ -0,0 +1,30 @@ +

<%= l(:label_import_issues) %>

+ +<% if @import.unsaved_items.count == 0 %> +

<%= l(:notice_import_finished, :count => @import.saved_items.count) %>

+ +
    + <% @import.saved_objects.each do |issue| %> +
  1. <%= link_to_issue issue %>
  2. + <% end %> + +<% else %> +

    <%= l(:notice_import_finished_with_errors, :count => @import.unsaved_items.count, :total => @import.total_items) %>

    + + + + + + + <% @import.unsaved_items.each do |item| %> + + + + + <% end %> +
    PositionMessage
    <%= item.position %><%= simple_format_without_paragraph item.message %>
    +<% end %> + +<% content_for :sidebar do %> + <%= render :partial => 'issues/sidebar' %> +<% end %> diff --git a/app/views/issues/_sidebar.html.erb b/app/views/issues/_sidebar.html.erb index dbf191813..df9f43b72 100644 --- a/app/views/issues/_sidebar.html.erb +++ b/app/views/issues/_sidebar.html.erb @@ -12,6 +12,10 @@ <% if User.current.allowed_to?(:view_gantt, @project, :global => true) %>
  3. <%= link_to l(:label_gantt), _project_gantt_path(@project) %>
  4. <% end %> + +<% if User.current.allowed_to?(:import_issues, @project, :global => true) %> +
  5. <%= link_to l(:button_import), new_issues_import_path %>
  6. +<% end %> <%= call_hook(:view_issues_sidebar_issues_bottom) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 039df462f..56f9caceb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -182,6 +182,8 @@ en: notice_account_deleted: "Your account has been permanently deleted." notice_user_successful_create: "User %{id} created." notice_new_password_must_be_different: The new password must be different from the current password + notice_import_finished: "All %{count} items have been imported." + notice_import_finished_with_errors: "%{count} out of %{total} items could not be imported." error_can_t_load_default_data: "Default configuration could not be loaded: %{value}" error_scm_not_found: "The entry or revision was not found in the repository." @@ -205,6 +207,9 @@ en: error_session_expired: "Your session has expired. Please login again." warning_attachments_not_saved: "%{count} file(s) could not be saved." error_password_expired: "Your password has expired or the administrator requires you to change it." + error_invalid_file_encoding: "The file is not a valid %{encoding} encoded file" + error_invalid_csv_file_or_settings: "The file is not a CSV file or does not match the settings below" + error_can_not_read_import_file: "An error occurred while reading the file to import" mail_subject_lost_password: "Your %{value} password" mail_body_lost_password: 'To change your password, click on the following link:' @@ -484,6 +489,7 @@ en: permission_export_wiki_pages: Export wiki pages permission_manage_subtasks: Manage subtasks permission_manage_related_issues: Manage related issues + permission_import_issues: Import issues project_module_issue_tracking: Issue tracking project_module_time_tracking: Time tracking @@ -952,6 +958,18 @@ en: label_member_management: Member management label_member_management_all_roles: All roles label_member_management_selected_roles_only: Only these roles + label_import_issues: Import issues + label_select_file_to_import: Select the file to import + label_fields_separator: Field separator + label_fields_wrapper: Field wrapper + label_encoding: Encoding + label_coma_char: Coma + label_semi_colon_char: Semi colon + label_quote_char: Quote + label_double_quote_char: Double quote + label_fields_mapping: Fields mapping + label_file_content_preview: File content preview + label_create_missing_values: Create missing values button_login: Login button_submit: Submit @@ -1005,6 +1023,7 @@ en: button_delete_my_account: Delete my account button_close: Close button_reopen: Reopen + button_import: Import status_active: active status_registered: registered diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 3432514be..be08413b7 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -202,6 +202,8 @@ fr: notice_account_deleted: "Votre compte a été définitivement supprimé." notice_user_successful_create: "Utilisateur %{id} créé." notice_new_password_must_be_different: Votre nouveau mot de passe doit être différent de votre mot de passe actuel + notice_import_finished: "Les %{count} éléments ont été importé(s)." + notice_import_finished_with_errors: "%{count} élément(s) sur %{total} n'ont pas pu être importé(s)." error_can_t_load_default_data: "Une erreur s'est produite lors du chargement du paramétrage : %{value}" error_scm_not_found: "L'entrée et/ou la révision demandée n'existe pas dans le dépôt." @@ -225,6 +227,9 @@ fr: error_session_expired: "Votre session a expiré. Veuillez vous reconnecter." warning_attachments_not_saved: "%{count} fichier(s) n'ont pas pu être sauvegardés." error_password_expired: "Votre mot de passe a expiré ou nécessite d'être changé." + error_invalid_file_encoding: "Le fichier n'est pas un fichier %{encoding} valide" + error_invalid_csv_file_or_settings: "Le fichier n'est pas un fichier CSV ou n'est pas conforme aux paramètres sélectionnés" + error_can_not_read_import_file: "Une erreur est survenue lors de la lecture du fichier à importer" mail_subject_lost_password: "Votre mot de passe %{value}" mail_body_lost_password: 'Pour changer votre mot de passe, cliquez sur le lien suivant :' @@ -504,6 +509,7 @@ fr: permission_export_wiki_pages: Exporter les pages permission_manage_subtasks: Gérer les sous-tâches permission_manage_related_issues: Gérer les demandes associées + permission_import_issues: Importer des demandes project_module_issue_tracking: Suivi des demandes project_module_time_tracking: Suivi du temps passé @@ -970,6 +976,18 @@ fr: label_member_management: Gestion des membres label_member_management_all_roles: Tous les rôles label_member_management_selected_roles_only: Ces rôles uniquement + label_import_issues: Importer des demandes + label_select_file_to_import: Sélectionner le fichier à importer + label_fields_separator: Séparateur de champs + label_fields_wrapper: Délimiteur de texte + label_encoding: Encodage + label_coma_char: Virgule + label_semi_colon_char: Point virgule + label_quote_char: Apostrophe + label_double_quote_char: Double apostrophe + label_fields_mapping: Correspondance des champs + label_file_content_preview: Aperçu du contenu du fichier + label_create_missing_values: Créer les valeurs manquantes button_login: Connexion button_submit: Soumettre @@ -1023,6 +1041,7 @@ fr: button_delete_my_account: Supprimer mon compte button_close: Fermer button_reopen: Réouvrir + button_import: Importer status_active: actif status_registered: enregistré diff --git a/config/routes.rb b/config/routes.rb index 1fc5bebb3..532546fda 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,6 +61,13 @@ Rails.application.routes.draw do get 'projects/:id/issues/report', :to => 'reports#issue_report', :as => 'project_issues_report' get 'projects/:id/issues/report/:detail', :to => 'reports#issue_report_details', :as => 'project_issues_report_details' + get '/issues/imports/new', :to => 'imports#new', :as => 'new_issues_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' + match '/imports/:id/mapping', :to => 'imports#mapping', :via => [:get, :post], :as => 'import_mapping' + match '/imports/:id/run', :to => 'imports#run', :via => [:get, :post], :as => 'import_run' + match 'my/account', :controller => 'my', :action => 'account', :via => [:get, :post] match 'my/account/destroy', :controller => 'my', :action => 'destroy', :via => [:get, :post] match 'my/page', :controller => 'my', :action => 'page', :via => :get diff --git a/db/migrate/20150730122707_create_imports.rb b/db/migrate/20150730122707_create_imports.rb new file mode 100644 index 000000000..8b58d06c8 --- /dev/null +++ b/db/migrate/20150730122707_create_imports.rb @@ -0,0 +1,13 @@ +class CreateImports < ActiveRecord::Migration + def change + create_table :imports do |t| + t.string :type + t.integer :user_id, :null => false + t.string :filename + t.text :settings + t.integer :total_items + t.boolean :finished, :null => false, :default => false + t.timestamps + end + end +end diff --git a/db/migrate/20150730122735_create_import_items.rb b/db/migrate/20150730122735_create_import_items.rb new file mode 100644 index 000000000..7e9cfb7d6 --- /dev/null +++ b/db/migrate/20150730122735_create_import_items.rb @@ -0,0 +1,10 @@ +class CreateImportItems < ActiveRecord::Migration + def change + create_table :import_items do |t| + t.integer :import_id, :null => false + t.integer :position, :null => false + t.integer :obj_id + t.text :message + end + end +end diff --git a/lib/redmine.rb b/lib/redmine.rb index e00d089f5..8652a1fd3 100644 --- a/lib/redmine.rb +++ b/lib/redmine.rb @@ -116,6 +116,7 @@ Redmine::AccessControl.map do |map| map.permission :view_issue_watchers, {}, :read => true map.permission :add_issue_watchers, {:watchers => [:new, :create, :append, :autocomplete_for_user]} map.permission :delete_issue_watchers, {:watchers => :destroy} + map.permission :import_issues, {:imports => [:new, :create, :settings, :mapping, :run, :show]} end map.project_module :time_tracking do |map| diff --git a/lib/redmine/i18n.rb b/lib/redmine/i18n.rb index 0091186f4..b028e3e30 100644 --- a/lib/redmine/i18n.rb +++ b/lib/redmine/i18n.rb @@ -52,8 +52,16 @@ module Redmine "%.2f h" % hours.to_f end - def ll(lang, str, value=nil) - ::I18n.t(str.to_s, :value => value, :locale => lang.to_s.gsub(%r{(.+)\-(.+)$}) { "#{$1}-#{$2.upcase}" }) + def ll(lang, str, arg=nil) + options = arg.is_a?(Hash) ? arg : {:value => arg} + locale = lang.to_s.gsub(%r{(.+)\-(.+)$}) { "#{$1}-#{$2.upcase}" } + ::I18n.t(str.to_s, options.merge(:locale => locale)) + end + + # Localizes the given args with user's language + def lu(user, *args) + lang = user.try(:language) || Setting.default_language + ll(lang, *args) end def format_date(date) diff --git a/lib/redmine/utils.rb b/lib/redmine/utils.rb index 56350052f..ed27d7e84 100644 --- a/lib/redmine/utils.rb +++ b/lib/redmine/utils.rb @@ -15,6 +15,8 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +require 'fileutils' + module Redmine module Utils class << self @@ -40,6 +42,25 @@ module Redmine def random_hex(n) SecureRandom.hex(n) end + + def save_upload(upload, path) + directory = File.dirname(path) + unless File.exists?(directory) + FileUtils.mkdir_p directory + end + File.open(path, "wb") do |f| + if upload.respond_to?(:read) + buffer = "" + while (buffer = upload.read(8192)) + f.write(buffer) + yield buffer if block_given? + end + else + f.write(upload) + yield upload if block_given? + end + end + end end module Shell diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css index be41d08ff..abda05c11 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -1109,6 +1109,17 @@ h2 img { vertical-align:middle; } .hascontextmenu { cursor: context-menu; } +.sample-data {border:1px solid #ccc; border-collapse:collapse; background-color:#fff; margin:0.5em;} +.sample-data td {border:1px solid #ccc; padding: 2px 4px; font-family: Consolas, Menlo, "Liberation Mono", Courier, monospace;} +.sample-data tr:first-child td {font-weight:bold; text-align:center;} + +.ui-progressbar {position: relative;} +#progress-label { +position: absolute; left: 50%; top: 4px; +font-weight: bold; +color: #555; text-shadow: 1px 1px 0 #fff; +} + /* Custom JQuery styles */ .ui-datepicker-title select {width:70px !important; margin-top:-2px !important; margin-right:4px !important;} diff --git a/test/fixtures/files/import_iso8859-1.csv b/test/fixtures/files/import_iso8859-1.csv new file mode 100644 index 000000000..6d1c04b09 --- /dev/null +++ b/test/fixtures/files/import_iso8859-1.csv @@ -0,0 +1,3 @@ +column A;column B;column C +Contenu en français;value1B;value1C +value2A;value2B;value2C diff --git a/test/fixtures/files/import_issues.csv b/test/fixtures/files/import_issues.csv new file mode 100644 index 000000000..e4cb5aa2c --- /dev/null +++ b/test/fixtures/files/import_issues.csv @@ -0,0 +1,4 @@ +priority;subject;description;start_date;due_date;parent;private;progress;custom;version;category +High;First;First description;2015-07-08;2015-08-25;;no;;PostgreSQL;;New category +Normal;Child 1;Child description;;;1;yes;10;MySQL;2.0;New category +Normal;Child of existing issue;Child description;;;#2;no;20;;2.1;Printing diff --git a/test/fixtures/roles.yml b/test/fixtures/roles.yml index 674c6d459..2e1b7c660 100644 --- a/test/fixtures/roles.yml +++ b/test/fixtures/roles.yml @@ -61,6 +61,7 @@ roles_001: - :view_changesets - :manage_related_issues - :manage_project_activities + - :import_issues position: 1 roles_002: diff --git a/test/functional/imports_controller_test.rb b/test/functional/imports_controller_test.rb new file mode 100644 index 000000000..5227274b4 --- /dev/null +++ b/test/functional/imports_controller_test.rb @@ -0,0 +1,200 @@ +# Redmine - project management software +# Copyright (C) 2006-2015 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 ImportsControllerTest < ActionController::TestCase + fixtures :projects, :enabled_modules, + :users, :email_addresses, + :roles, :members, :member_roles, + :issues, :issue_statuses, + :trackers, :projects_trackers, + :versions, + :issue_categories, + :enumerations, + :workflows, + :custom_fields, + :custom_values, + :custom_fields_projects, + :custom_fields_trackers + + def setup + User.current = nil + @request.session[:user_id] = 2 + end + + def teardown + Import.destroy_all + end + + def test_new_should_display_the_upload_form + get :new + assert_response :success + assert_template 'new' + assert_select 'input[name=?]', 'file' + end + + def test_create_should_save_the_file + import = new_record(Import) do + post :create, :file => uploaded_test_file('import_issues.csv', 'text/csv') + assert_response 302 + end + assert_equal 2, import.user_id + assert_match /\A[0-9a-f]+\z/, import.filename + assert import.file_exists? + end + + def test_get_settings_should_display_settings_form + import = generate_import + get :settings, :id => import.to_param + assert_response :success + assert_template 'settings' + end + + def test_post_settings_should_update_settings + import = generate_import + + post :settings, :id => import.to_param, + :import_settings => {:separator => ":", :wrapper => "|", :encoding => "UTF-8"} + assert_redirected_to "/imports/#{import.to_param}/mapping" + + import.reload + assert_equal ":", import.settings['separator'] + assert_equal "|", import.settings['wrapper'] + assert_equal "UTF-8", import.settings['encoding'] + end + + def test_post_settings_should_update_total_items_count + import = generate_import('import_iso8859-1.csv') + + post :settings, :id => import.to_param, + :import_settings => {:separator => ";", :wrapper => '"', :encoding => "ISO-8859-1"} + assert_response 302 + import.reload + assert_equal 2, import.total_items + end + + def test_post_settings_with_wrong_encoding_should_display_error + import = generate_import('import_iso8859-1.csv') + + post :settings, :id => import.to_param, + :import_settings => {:separator => ";", :wrapper => '"', :encoding => "UTF-8"} + assert_response 200 + import.reload + assert_nil import.total_items + assert_select 'div#flash_error', /not a valid UTF-8 encoded file/ + end + + def test_get_mapping_should_display_mapping_form + import = generate_import('import_iso8859-1.csv') + import.settings = {'separator' => ";", 'wrapper' => '"', 'encoding' => "ISO-8859-1"} + import.save! + + get :mapping, :id => import.to_param + assert_response :success + assert_template 'mapping' + + assert_select 'select[name=?]', 'import_settings[mapping][subject]' do + assert_select 'option', 4 + assert_select 'option[value="0"]', :text => 'column A' + end + + assert_select 'table.sample-data' do + assert_select 'tr', 3 + assert_select 'td', 9 + end + end + + def test_post_mapping_should_update_mapping + import = generate_import('import_iso8859-1.csv') + + post :mapping, :id => import.to_param, + :import_settings => {:mapping => {:project_id => '1', :tracker_id => '2', :subject => '0'}} + assert_redirected_to "/imports/#{import.to_param}/run" + import.reload + mapping = import.settings['mapping'] + assert mapping + assert_equal '1', mapping['project_id'] + assert_equal '2', mapping['tracker_id'] + assert_equal '0', mapping['subject'] + end + + def test_get_run + import = generate_import_with_mapping + + get :run, :id => import + assert_response :success + assert_template 'run' + end + + def test_post_run_should_import_the_file + import = generate_import_with_mapping + + assert_difference 'Issue.count', 3 do + post :run, :id => import + assert_redirected_to "/imports/#{import.to_param}" + end + + import.reload + assert_equal true, import.finished + assert_equal 3, import.items.count + + issues = Issue.order(:id => :desc).limit(3).to_a + assert_equal ["Child of existing issue", "Child 1", "First"], issues.map(&:subject) + end + + def test_post_run_should_import_max_items_and_resume + ImportsController.any_instance.stubs(:max_items_per_request).returns(2) + import = generate_import_with_mapping + + assert_difference 'Issue.count', 2 do + post :run, :id => import + assert_redirected_to "/imports/#{import.to_param}/run" + end + + assert_difference 'Issue.count', 1 do + post :run, :id => import + assert_redirected_to "/imports/#{import.to_param}" + end + + issues = Issue.order(:id => :desc).limit(3).to_a + assert_equal ["Child of existing issue", "Child 1", "First"], issues.map(&:subject) + end + + def test_show_without_errors + import = generate_import_with_mapping + import.run + assert_equal 0, import.unsaved_items.count + + get :show, :id => import.to_param + assert_response :success + assert_template 'show' + assert_select 'table#unsaved-items', 0 + end + + def test_show_with_errors_should_show_unsaved_items + import = generate_import_with_mapping + import.mapping.merge! 'subject' => 20 + import.run + assert_not_equal 0, import.unsaved_items.count + + get :show, :id => import.to_param + assert_response :success + assert_template 'show' + assert_select 'table#unsaved-items' + end +end diff --git a/test/integration/routing/imports_test.rb b/test/integration/routing/imports_test.rb new file mode 100644 index 000000000..a06b1d966 --- /dev/null +++ b/test/integration/routing/imports_test.rb @@ -0,0 +1,36 @@ +# Redmine - project management software +# Copyright (C) 2006-2015 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 RoutingImportsTest < Redmine::RoutingTest + def test_imports + should_route 'GET /issues/imports/new' => 'imports#new' + should_route 'POST /imports' => 'imports#create' + + should_route 'GET /imports/4ae6bc' => 'imports#show', :id => '4ae6bc' + + should_route 'GET /imports/4ae6bc/settings' => 'imports#settings', :id => '4ae6bc' + should_route 'POST /imports/4ae6bc/settings' => 'imports#settings', :id => '4ae6bc' + + should_route 'GET /imports/4ae6bc/mapping' => 'imports#mapping', :id => '4ae6bc' + should_route 'POST /imports/4ae6bc/mapping' => 'imports#mapping', :id => '4ae6bc' + + should_route 'GET /imports/4ae6bc/run' => 'imports#run', :id => '4ae6bc' + should_route 'POST /imports/4ae6bc/run' => 'imports#run', :id => '4ae6bc' + end +end diff --git a/test/object_helpers.rb b/test/object_helpers.rb index 74f82b68b..82d8d20fc 100644 --- a/test/object_helpers.rb +++ b/test/object_helpers.rb @@ -206,6 +206,25 @@ module ObjectHelpers query.save! query end + + def generate_import(fixture_name='import_issues.csv') + import = IssueImport.new + import.user_id = 2 + import.file = uploaded_test_file(fixture_name, 'text/csv') + import.save! + import + end + + def generate_import_with_mapping(fixture_name='import_issues.csv') + import = generate_import(fixture_name) + + import.settings = { + 'separator' => ";", 'wrapper' => '"', 'encoding' => "UTF-8", + 'mapping' => {'project_id' => '1', 'tracker_id' => '2', 'subject' => '1'} + } + import.save! + import + end end module TrackerObjectHelpers diff --git a/test/test_helper.rb b/test/test_helper.rb index 2b75a0a34..0ee9b6357 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -183,10 +183,16 @@ class ActiveSupport::TestCase # Asserts that a new record for the given class is created # and returns it def new_record(klass, &block) - assert_difference "#{klass}.count" do + new_records(klass, 1, &block).first + end + + # Asserts that count new records for the given class are created + # and returns them as an array order by object id + def new_records(klass, count, &block) + assert_difference "#{klass}.count", count do yield end - klass.order(:id => :desc).first + klass.order(:id => :desc).limit(count).to_a.reverse end def assert_save(object) diff --git a/test/unit/issue_import_test.rb b/test/unit/issue_import_test.rb new file mode 100644 index 000000000..7665bf405 --- /dev/null +++ b/test/unit/issue_import_test.rb @@ -0,0 +1,89 @@ +# Redmine - project management software +# Copyright (C) 2006-2015 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 IssueImportTest < ActiveSupport::TestCase + fixtures :projects, :enabled_modules, + :users, :email_addresses, + :roles, :members, :member_roles, + :issues, :issue_statuses, + :trackers, :projects_trackers, + :versions, + :issue_categories, + :enumerations, + :workflows, + :custom_fields, + :custom_values, + :custom_fields_projects, + :custom_fields_trackers + + def test_create_versions_should_create_missing_versions + import = generate_import_with_mapping + import.mapping.merge!('fixed_version' => '9', 'create_versions' => '1') + import.save! + + version = new_record(Version) do + assert_difference 'Issue.count', 3 do + import.run + end + end + assert_equal '2.1', version.name + end + + def test_create_categories_should_create_missing_categories + import = generate_import_with_mapping + import.mapping.merge!('category' => '10', 'create_categories' => '1') + import.save! + + category = new_record(IssueCategory) do + assert_difference 'Issue.count', 3 do + import.run + end + end + assert_equal 'New category', category.name + end + + def test_parent_should_be_set + import = generate_import_with_mapping + import.mapping.merge!('parent_issue_id' => '5') + import.save! + + issues = new_records(Issue, 3) { import.run } + assert_nil issues[0].parent + assert_equal issues[0].id, issues[1].parent_id + assert_equal 2, issues[2].parent_id + end + + def test_is_private_should_be_set_based_on_user_locale + import = generate_import_with_mapping + import.mapping.merge!('is_private' => '6') + import.save! + + issues = new_records(Issue, 3) { import.run } + assert_equal [false, true, false], issues.map(&:is_private) + end + + def test_run_should_remove_the_file + import = generate_import_with_mapping + file_path = import.filepath + assert File.exists?(file_path) + + import.run + assert !File.exists?(file_path) + end +end -- 2.39.5