mirror of
https://github.com/redmine/redmine.git
synced 2025-10-26 07:46:17 +01:00
Import issues from CSV file (#950).
git-svn-id: http://svn.redmine.org/redmine/trunk@14493 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
124
app/controllers/imports_controller.rb
Normal file
124
app/controllers/imports_controller.rb
Normal file
@@ -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
|
||||
33
app/helpers/imports_helper.rb
Normal file
33
app/helpers/imports_helper.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
# encoding: utf-8
|
||||
#
|
||||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2015 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module ImportsHelper
|
||||
def options_for_mapping_select(import, field, options={})
|
||||
tags = "".html_safe
|
||||
blank_text = options[:required] ? "-- #{l(:actionview_instancetag_blank_option)} --" : " ".html_safe
|
||||
tags << content_tag('option', blank_text, :value => '')
|
||||
tags << options_for_select(import.columns_options, import.mapping[field])
|
||||
tags
|
||||
end
|
||||
|
||||
def mapping_select_tag(import, field, options={})
|
||||
name = "import_settings[mapping][#{field}]"
|
||||
select_tag name, options_for_mapping_select(import, field, options)
|
||||
end
|
||||
end
|
||||
229
app/models/import.rb
Normal file
229
app/models/import.rb
Normal file
@@ -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
|
||||
22
app/models/import_item.rb
Normal file
22
app/models/import_item.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
145
app/models/issue_import.rb
Normal file
145
app/models/issue_import.rb
Normal file
@@ -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
|
||||
82
app/views/imports/_fields_mapping.html.erb
Normal file
82
app/views/imports/_fields_mapping.html.erb
Normal file
@@ -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>
|
||||
|
||||
52
app/views/imports/mapping.html.erb
Normal file
52
app/views/imports/mapping.html.erb
Normal file
@@ -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 %>
|
||||
1
app/views/imports/mapping.js.erb
Normal file
1
app/views/imports/mapping.js.erb
Normal file
@@ -0,0 +1 @@
|
||||
$('#fields-mapping').html('<%= escape_javascript(render :partial => 'fields_mapping') %>');
|
||||
15
app/views/imports/new.html.erb
Normal file
15
app/views/imports/new.html.erb
Normal file
@@ -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 + " »".html_safe, :name => nil %></p>
|
||||
<% end %>
|
||||
|
||||
<% content_for :sidebar do %>
|
||||
<%= render :partial => 'issues/sidebar' %>
|
||||
<% end %>
|
||||
20
app/views/imports/run.html.erb
Normal file
20
app/views/imports/run.html.erb
Normal file
@@ -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 %>
|
||||
11
app/views/imports/run.js.erb
Normal file
11
app/views/imports/run.js.erb
Normal file
@@ -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 %>
|
||||
26
app/views/imports/settings.html.erb
Normal file
26
app/views/imports/settings.html.erb
Normal file
@@ -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 + " »".html_safe, :name => nil %></p>
|
||||
<% end %>
|
||||
|
||||
<% content_for :sidebar do %>
|
||||
<%= render :partial => 'issues/sidebar' %>
|
||||
<% end %>
|
||||
30
app/views/imports/show.html.erb
Normal file
30
app/views/imports/show.html.erb
Normal file
@@ -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 %>
|
||||
@@ -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) %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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é
|
||||
|
||||
@@ -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
|
||||
|
||||
13
db/migrate/20150730122707_create_imports.rb
Normal file
13
db/migrate/20150730122707_create_imports.rb
Normal file
@@ -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
|
||||
10
db/migrate/20150730122735_create_import_items.rb
Normal file
10
db/migrate/20150730122735_create_import_items.rb
Normal file
@@ -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
|
||||
@@ -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|
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;}
|
||||
|
||||
|
||||
3
test/fixtures/files/import_iso8859-1.csv
vendored
Normal file
3
test/fixtures/files/import_iso8859-1.csv
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
column A;column B;column C
|
||||
Contenu en fran<61>ais;value1B;value1C
|
||||
value2A;value2B;value2C
|
||||
|
4
test/fixtures/files/import_issues.csv
vendored
Normal file
4
test/fixtures/files/import_issues.csv
vendored
Normal file
@@ -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
|
||||
|
1
test/fixtures/roles.yml
vendored
1
test/fixtures/roles.yml
vendored
@@ -61,6 +61,7 @@ roles_001:
|
||||
- :view_changesets
|
||||
- :manage_related_issues
|
||||
- :manage_project_activities
|
||||
- :import_issues
|
||||
|
||||
position: 1
|
||||
roles_002:
|
||||
|
||||
200
test/functional/imports_controller_test.rb
Normal file
200
test/functional/imports_controller_test.rb
Normal file
@@ -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
|
||||
36
test/integration/routing/imports_test.rb
Normal file
36
test/integration/routing/imports_test.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
89
test/unit/issue_import_test.rb
Normal file
89
test/unit/issue_import_test.rb
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user