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:
Jean-Philippe Lang
2015-08-14 08:20:32 +00:00
parent 763d5dddde
commit 035edd39c4
32 changed files with 1274 additions and 5 deletions

View 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

View 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)} --" : "&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

229
app/models/import.rb Normal file
View 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
View 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

View 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

145
app/models/issue_import.rb Normal file
View 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

View 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>

View 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 %>

View File

@@ -0,0 +1 @@
$('#fields-mapping').html('<%= escape_javascript(render :partial => 'fields_mapping') %>');

View 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 + " &#187;".html_safe, :name => nil %></p>
<% end %>
<% content_for :sidebar do %>
<%= render :partial => 'issues/sidebar' %>
<% end %>

View 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 %>

View 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 %>

View 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 + " &#187;".html_safe, :name => nil %></p>
<% end %>
<% content_for :sidebar do %>
<%= render :partial => 'issues/sidebar' %>
<% end %>

View 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 %>

View File

@@ -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) %>

View 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

View 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é

View 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

View 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

View 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

View 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|

View 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)

View 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

View 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;}

View File

@@ -0,0 +1,3 @@
column A;column B;column C
Contenu en fran<61>ais;value1B;value1C
value2A;value2B;value2C
1 column A column B column C
2 Contenu en français value1B value1C
3 value2A value2B value2C

4
test/fixtures/files/import_issues.csv vendored Normal file
View 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 priority subject description start_date due_date parent private progress custom version category
2 High First First description 2015-07-08 2015-08-25 no PostgreSQL New category
3 Normal Child 1 Child description 1 yes 10 MySQL 2.0 New category
4 Normal Child of existing issue Child description #2 no 20 2.1 Printing

View File

@@ -61,6 +61,7 @@ roles_001:
- :view_changesets
- :manage_related_issues
- :manage_project_activities
- :import_issues
position: 1
roles_002:

View 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

View 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

View 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

View 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)

View 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