summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJean-Philippe Lang <jp_lang@yahoo.fr>2015-08-14 08:20:32 +0000
committerJean-Philippe Lang <jp_lang@yahoo.fr>2015-08-14 08:20:32 +0000
commit035edd39c422c9434147a1b0ac457cb9383c9b5b (patch)
tree4b25e158e04068c535e828c04f336c769ac9db9c
parent763d5dddde2c7dda03fe529c9dfe0d553669c277 (diff)
downloadredmine-035edd39c422c9434147a1b0ac457cb9383c9b5b.tar.gz
redmine-035edd39c422c9434147a1b0ac457cb9383c9b5b.zip
Import issues from CSV file (#950).
git-svn-id: http://svn.redmine.org/redmine/trunk@14493 e93f8b46-1217-0410-a6f0-8f06a7374b81
-rw-r--r--app/controllers/imports_controller.rb124
-rw-r--r--app/helpers/imports_helper.rb33
-rw-r--r--app/models/import.rb229
-rw-r--r--app/models/import_item.rb22
-rw-r--r--app/models/issue.rb10
-rw-r--r--app/models/issue_import.rb145
-rw-r--r--app/views/imports/_fields_mapping.html.erb82
-rw-r--r--app/views/imports/mapping.html.erb52
-rw-r--r--app/views/imports/mapping.js.erb1
-rw-r--r--app/views/imports/new.html.erb15
-rw-r--r--app/views/imports/run.html.erb20
-rw-r--r--app/views/imports/run.js.erb11
-rw-r--r--app/views/imports/settings.html.erb26
-rw-r--r--app/views/imports/show.html.erb30
-rw-r--r--app/views/issues/_sidebar.html.erb4
-rw-r--r--config/locales/en.yml19
-rw-r--r--config/locales/fr.yml19
-rw-r--r--config/routes.rb7
-rw-r--r--db/migrate/20150730122707_create_imports.rb13
-rw-r--r--db/migrate/20150730122735_create_import_items.rb10
-rw-r--r--lib/redmine.rb1
-rw-r--r--lib/redmine/i18n.rb12
-rw-r--r--lib/redmine/utils.rb21
-rw-r--r--public/stylesheets/application.css11
-rw-r--r--test/fixtures/files/import_iso8859-1.csv3
-rw-r--r--test/fixtures/files/import_issues.csv4
-rw-r--r--test/fixtures/roles.yml1
-rw-r--r--test/functional/imports_controller_test.rb200
-rw-r--r--test/integration/routing/imports_test.rb36
-rw-r--r--test/object_helpers.rb19
-rw-r--r--test/test_helper.rb10
-rw-r--r--test/unit/issue_import_test.rb89
32 files changed, 1274 insertions, 5 deletions
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)} --" : "&nbsp;".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 @@
+<div class="splitcontent">
+<div class="splitcontentleft">
+<p>
+ <label><%= l(:label_project) %></label>
+ <%= 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' %>
+</p>
+<p>
+ <label><%= l(:label_tracker) %></label>
+ <%= 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' %>
+</p>
+<p>
+ <label><%= l(:field_subject) %></label>
+ <%= mapping_select_tag @import, 'subject', :required => true %>
+</p>
+<p>
+ <label><%= l(:field_description) %></label>
+ <%= mapping_select_tag @import, 'description' %>
+</p>
+<p>
+ <label><%= l(:field_priority) %></label>
+ <%= mapping_select_tag @import, 'priority' %>
+</p>
+<p>
+ <label><%= l(:field_category) %></label>
+ <%= mapping_select_tag @import, 'category' %>
+ <% if User.current.allowed_to?(:manage_categories, @import.project) %>
+ <label class="block">
+ <%= check_box_tag 'import_settings[mapping][create_categories]', '1', @import.create_categories? %>
+ <%= l(:label_create_missing_values) %>
+ </label>
+ <% end %>
+</p>
+<p>
+ <label><%= l(:field_assigned_to) %></label>
+ <%= mapping_select_tag @import, 'assigned_to' %>
+</p>
+<p>
+ <label><%= l(:field_fixed_version) %></label>
+ <%= mapping_select_tag @import, 'fixed_version' %>
+ <% if User.current.allowed_to?(:manage_versions, @import.project) %>
+ <label class="block">
+ <%= check_box_tag 'import_settings[mapping][create_versions]', '1', @import.create_versions? %>
+ <%= l(:label_create_missing_values) %>
+ </label>
+ <% end %>
+</p>
+<% @custom_fields.each do |field| %>
+ <p>
+ <label><%= field.name %></label>
+ <%= mapping_select_tag @import, "cf_#{field.id}" %>
+ </p>
+<% end %>
+</div>
+
+<div class="splitcontentright">
+<p>
+ <label><%= l(:field_is_private) %></label>
+ <%= mapping_select_tag @import, 'is_private' %>
+</p>
+<p>
+ <label><%= l(:field_parent_issue) %></label>
+ <%= mapping_select_tag @import, 'parent_issue_id' %>
+</p>
+<p>
+ <label><%= l(:field_start_date) %></label>
+ <%= mapping_select_tag @import, 'start_date' %>
+</p>
+<p>
+ <label><%= l(:field_due_date) %></label>
+ <%= mapping_select_tag @import, 'due_date' %>
+</p>
+<p>
+ <label><%= l(:field_done_ratio) %></label>
+ <%= mapping_select_tag @import, 'done_ratio' %>
+</p>
+</div>
+</div>
+
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 @@
+<h2><%= l(:label_import_issues) %></h2>
+
+<%= form_tag(import_mapping_path(@import), :id => "import-form") do %>
+ <fieldset class="box tabular">
+ <legend><%= l(:label_fields_mapping) %></legend>
+ <div id="fields-mapping">
+ <%= render :partial => 'fields_mapping' %>
+ </div>
+ </fieldset>
+
+ <div class="autoscroll">
+ <fieldset class="box">
+ <legend><%= l(:label_file_content_preview) %></legend>
+
+ <table class="sample-data">
+ <% @import.first_rows.each do |row| %>
+ <tr>
+ <%= row.map {|c| content_tag 'td', truncate(c.to_s, :length => 50) }.join("").html_safe %>
+ </tr>
+ <% end %>
+ </table>
+ </fieldset>
+ </div>
+
+ <p>
+ <%= button_tag("\xc2\xab " + l(:label_previous), :name => 'previous') %>
+ <%= submit_tag l(:button_import) %>
+ </p>
+<% 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 @@
+<h2><%= l(:label_import_issues) %></h2>
+
+<%= form_tag(imports_path, :multipart => true) do %>
+ <fieldset class="box">
+ <legend><%= l(:label_select_file_to_import) %> (CSV)</legend>
+ <p>
+ <%= file_field_tag 'file' %>
+ </p>
+ </fieldset>
+ <p><%= submit_tag l(:label_next).html_safe + " &#187;".html_safe, :name => nil %></p>
+<% 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 @@
+<h2><%= l(:label_import_issues) %></h2>
+
+<div id="import-details">
+ <div id="import-progress"><div id="progress-label">0 / <%= @import.total_items.to_i %></div></div>
+</div>
+
+<% 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 @@
+<h2><%= l(:label_import_issues) %></h2>
+
+<%= form_tag(import_settings_path(@import), :id => "import-form") do %>
+ <fieldset class="box tabular">
+ <legend><%= l(:label_options) %></legend>
+ <p>
+ <label><%= l(:label_fields_separator) %></label>
+ <%= select_tag 'import_settings[separator]',
+ options_for_select([[l(:label_coma_char), ','], [l(:label_semi_colon_char), ';']], @import.settings['separator']) %>
+ </p>
+ <p>
+ <label><%= l(:label_fields_wrapper) %></label>
+ <%= select_tag 'import_settings[wrapper]',
+ options_for_select([[l(:label_quote_char), "'"], [l(:label_double_quote_char), '"']], @import.settings['wrapper']) %>
+ </p>
+ <p>
+ <label><%= l(:label_encoding) %></label>
+ <%= select_tag 'import_settings[encoding]', options_for_select(Setting::ENCODINGS, @import.settings['encoding']) %>
+ </p>
+ </fieldset>
+ <p><%= submit_tag l(:label_next).html_safe + " &#187;".html_safe, :name => nil %></p>
+<% 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 @@
+<h2><%= l(:label_import_issues) %></h2>
+
+<% if @import.unsaved_items.count == 0 %>
+ <p><%= l(:notice_import_finished, :count => @import.saved_items.count) %></p>
+
+ <ol>
+ <% @import.saved_objects.each do |issue| %>
+ <li><%= link_to_issue issue %></li>
+ <% end %>
+ </ul>
+<% else %>
+ <p><%= l(:notice_import_finished_with_errors, :count => @import.unsaved_items.count, :total => @import.total_items) %></p>
+
+ <table id="unsaved-items" class="list">
+ <tr>
+ <th>Position</th>
+ <th>Message</th>
+ </tr>
+ <% @import.unsaved_items.each do |item| %>
+ <tr>
+ <td><%= item.position %></td>
+ <td><%= simple_format_without_paragraph item.message %></td>
+ </tr>
+ <% end %>
+ </table>
+<% 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) %>
<li><%= link_to l(:label_gantt), _project_gantt_path(@project) %></li>
<% end %>
+
+<% if User.current.allowed_to?(:import_issues, @project, :global => true) %>
+<li><%= link_to l(:button_import), new_issues_import_path %></li>
+<% end %>
</ul>
<%= 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