]> source.dussan.org Git - redmine.git/commitdiff
Import issues from CSV file (#950).
authorJean-Philippe Lang <jp_lang@yahoo.fr>
Fri, 14 Aug 2015 08:20:32 +0000 (08:20 +0000)
committerJean-Philippe Lang <jp_lang@yahoo.fr>
Fri, 14 Aug 2015 08:20:32 +0000 (08:20 +0000)
git-svn-id: http://svn.redmine.org/redmine/trunk@14493 e93f8b46-1217-0410-a6f0-8f06a7374b81

32 files changed:
app/controllers/imports_controller.rb [new file with mode: 0644]
app/helpers/imports_helper.rb [new file with mode: 0644]
app/models/import.rb [new file with mode: 0644]
app/models/import_item.rb [new file with mode: 0644]
app/models/issue.rb
app/models/issue_import.rb [new file with mode: 0644]
app/views/imports/_fields_mapping.html.erb [new file with mode: 0644]
app/views/imports/mapping.html.erb [new file with mode: 0644]
app/views/imports/mapping.js.erb [new file with mode: 0644]
app/views/imports/new.html.erb [new file with mode: 0644]
app/views/imports/run.html.erb [new file with mode: 0644]
app/views/imports/run.js.erb [new file with mode: 0644]
app/views/imports/settings.html.erb [new file with mode: 0644]
app/views/imports/show.html.erb [new file with mode: 0644]
app/views/issues/_sidebar.html.erb
config/locales/en.yml
config/locales/fr.yml
config/routes.rb
db/migrate/20150730122707_create_imports.rb [new file with mode: 0644]
db/migrate/20150730122735_create_import_items.rb [new file with mode: 0644]
lib/redmine.rb
lib/redmine/i18n.rb
lib/redmine/utils.rb
public/stylesheets/application.css
test/fixtures/files/import_iso8859-1.csv [new file with mode: 0644]
test/fixtures/files/import_issues.csv [new file with mode: 0644]
test/fixtures/roles.yml
test/functional/imports_controller_test.rb [new file with mode: 0644]
test/integration/routing/imports_test.rb [new file with mode: 0644]
test/object_helpers.rb
test/test_helper.rb
test/unit/issue_import_test.rb [new file with mode: 0644]

diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb
new file mode 100644 (file)
index 0000000..2499a20
--- /dev/null
@@ -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 (file)
index 0000000..63cffac
--- /dev/null
@@ -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 (file)
index 0000000..b7064d9
--- /dev/null
@@ -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 (file)
index 0000000..67fb46c
--- /dev/null
@@ -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
index a4a7614d4c4e8ae8b406143443dc7f8cb65e602a..c7ad2a06382230ce4fc48ac1b7eefb55d646971b 100644 (file)
@@ -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 (file)
index 0000000..2ff1276
--- /dev/null
@@ -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 (file)
index 0000000..97dac0e
--- /dev/null
@@ -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 (file)
index 0000000..283bddb
--- /dev/null
@@ -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 (file)
index 0000000..8fdf14a
--- /dev/null
@@ -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 (file)
index 0000000..e20be35
--- /dev/null
@@ -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 (file)
index 0000000..2a72353
--- /dev/null
@@ -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 (file)
index 0000000..232904d
--- /dev/null
@@ -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 (file)
index 0000000..7afbb84
--- /dev/null
@@ -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 (file)
index 0000000..ad6ae36
--- /dev/null
@@ -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 %>
index dbf19181352f10c956407dc8f5ad1d1d7581bf90..df9f43b720b96e6f106d62fc00d55140b4a82d5a 100644 (file)
 <% 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) %>
index 039df462f9fbe1e5e7354e31ccc9070f3cd4429c..56f9cacebee8fb3099972ad41199bc50b5867049 100644 (file)
@@ -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
index 3432514befbc2c21f893a15b5649a564d6f0fc0d..be08413b777b3e2ebb8704b201d6869056469de7 100644 (file)
@@ -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é
index 1fc5bebb3824c643fe248ed9b489491a810aa264..532546fdaf77a580441666444c6e46a6d8b5576b 100644 (file)
@@ -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 (file)
index 0000000..8b58d06
--- /dev/null
@@ -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 (file)
index 0000000..7e9cfb7
--- /dev/null
@@ -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
index e00d089f5efcecb643b60a7a97c0a270b45fe8fb..8652a1fd363674f5bcd2d599aef7c48f0ac35b66 100644 (file)
@@ -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|
index 0091186f40a69eb95867ef49cd5772d6b8fa331a..b028e3e30608189fc151be3c321b066a8f8127d8 100644 (file)
@@ -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)
index 56350052fcefa1aec64670f6d6e9556f4c2b777e..ed27d7e844712dd55a088f201414edf86c442d48 100644 (file)
@@ -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
index be41d08ffd59726fcbd589083ba2de38adc9e56e..abda05c11c8affb134bd6e48296b72c3e13a4b07 100644 (file)
@@ -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 (file)
index 0000000..6d1c04b
--- /dev/null
@@ -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 (file)
index 0000000..e4cb5aa
--- /dev/null
@@ -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
index 674c6d4597de877c431f287d3a3d7eca0fb70d50..2e1b7c6605a3f34dadda371b7b1a057a1e8f7026 100644 (file)
@@ -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 (file)
index 0000000..5227274
--- /dev/null
@@ -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 (file)
index 0000000..a06b1d9
--- /dev/null
@@ -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
index 74f82b68b4bcaebc89f07b9f83e10a6a21cbece3..82d8d20fc5b5962beb7e7e503cc5660c586ecfba 100644 (file)
@@ -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
index 2b75a0a34310c94c8526e59cce57717af9cd039d..0ee9b635737b0c2e8ee0f47ece82e0718c9dccaa 100644 (file)
@@ -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 (file)
index 0000000..7665bf4
--- /dev/null
@@ -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