##// END OF EJS Templates
Set default date format based on user locale (#950)....
Jean-Philippe Lang -
r14114:67ea285dea51
parent child
Show More
@@ -1,245 +1,249
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'csv'
18 require 'csv'
19
19
20 class Import < ActiveRecord::Base
20 class Import < ActiveRecord::Base
21 has_many :items, :class_name => 'ImportItem', :dependent => :delete_all
21 has_many :items, :class_name => 'ImportItem', :dependent => :delete_all
22 belongs_to :user
22 belongs_to :user
23 serialize :settings
23 serialize :settings
24
24
25 before_destroy :remove_file
25 before_destroy :remove_file
26
26
27 validates_presence_of :filename, :user_id
27 validates_presence_of :filename, :user_id
28 validates_length_of :filename, :maximum => 255
28 validates_length_of :filename, :maximum => 255
29
29
30 DATE_FORMATS = [
30 DATE_FORMATS = [
31 '%Y-%m-%d',
31 '%Y-%m-%d',
32 '%d/%m/%Y',
32 '%d/%m/%Y',
33 '%m/%d/%Y',
33 '%m/%d/%Y',
34 '%d.%m.%Y',
34 '%d.%m.%Y',
35 '%d-%m-%Y'
35 '%d-%m-%Y'
36 ]
36 ]
37
37
38 def initialize(*args)
38 def initialize(*args)
39 super
39 super
40 self.settings ||= {}
40 self.settings ||= {}
41 end
41 end
42
42
43 def file=(arg)
43 def file=(arg)
44 return unless arg.present? && arg.size > 0
44 return unless arg.present? && arg.size > 0
45
45
46 self.filename = generate_filename
46 self.filename = generate_filename
47 Redmine::Utils.save_upload(arg, filepath)
47 Redmine::Utils.save_upload(arg, filepath)
48 end
48 end
49
49
50 def set_default_settings
50 def set_default_settings
51 separator = lu(user, :general_csv_separator)
51 separator = lu(user, :general_csv_separator)
52 if file_exists?
52 if file_exists?
53 begin
53 begin
54 content = File.read(filepath, 256, "rb")
54 content = File.read(filepath, 256, "rb")
55 separator = [',', ';'].sort_by {|sep| content.count(sep) }.last
55 separator = [',', ';'].sort_by {|sep| content.count(sep) }.last
56 rescue Exception => e
56 rescue Exception => e
57 end
57 end
58 end
58 end
59 wrapper = '"'
59 wrapper = '"'
60 encoding = lu(user, :general_csv_encoding)
60 encoding = lu(user, :general_csv_encoding)
61
61
62 date_format = lu(user, "date.formats.default", :default => "foo")
63 date_format = DATE_FORMATS.first unless DATE_FORMATS.include?(date_format)
64
62 self.settings.merge!(
65 self.settings.merge!(
63 'separator' => separator,
66 'separator' => separator,
64 'wrapper' => wrapper,
67 'wrapper' => wrapper,
65 'encoding' => encoding
68 'encoding' => encoding,
69 'date_format' => date_format
66 )
70 )
67 end
71 end
68
72
69 def to_param
73 def to_param
70 filename
74 filename
71 end
75 end
72
76
73 # Returns the full path of the file to import
77 # Returns the full path of the file to import
74 # It is stored in tmp/imports with a random hex as filename
78 # It is stored in tmp/imports with a random hex as filename
75 def filepath
79 def filepath
76 if filename.present? && filename =~ /\A[0-9a-f]+\z/
80 if filename.present? && filename =~ /\A[0-9a-f]+\z/
77 File.join(Rails.root, "tmp", "imports", filename)
81 File.join(Rails.root, "tmp", "imports", filename)
78 else
82 else
79 nil
83 nil
80 end
84 end
81 end
85 end
82
86
83 # Returns true if the file to import exists
87 # Returns true if the file to import exists
84 def file_exists?
88 def file_exists?
85 filepath.present? && File.exists?(filepath)
89 filepath.present? && File.exists?(filepath)
86 end
90 end
87
91
88 # Returns the headers as an array that
92 # Returns the headers as an array that
89 # can be used for select options
93 # can be used for select options
90 def columns_options(default=nil)
94 def columns_options(default=nil)
91 i = -1
95 i = -1
92 headers.map {|h| [h, i+=1]}
96 headers.map {|h| [h, i+=1]}
93 end
97 end
94
98
95 # Parses the file to import and updates the total number of items
99 # Parses the file to import and updates the total number of items
96 def parse_file
100 def parse_file
97 count = 0
101 count = 0
98 read_items {|row, i| count=i}
102 read_items {|row, i| count=i}
99 update_attribute :total_items, count
103 update_attribute :total_items, count
100 count
104 count
101 end
105 end
102
106
103 # Reads the items to import and yields the given block for each item
107 # Reads the items to import and yields the given block for each item
104 def read_items
108 def read_items
105 i = 0
109 i = 0
106 headers = true
110 headers = true
107 read_rows do |row|
111 read_rows do |row|
108 if i == 0 && headers
112 if i == 0 && headers
109 headers = false
113 headers = false
110 next
114 next
111 end
115 end
112 i+= 1
116 i+= 1
113 yield row, i if block_given?
117 yield row, i if block_given?
114 end
118 end
115 end
119 end
116
120
117 # Returns the count first rows of the file (including headers)
121 # Returns the count first rows of the file (including headers)
118 def first_rows(count=4)
122 def first_rows(count=4)
119 rows = []
123 rows = []
120 read_rows do |row|
124 read_rows do |row|
121 rows << row
125 rows << row
122 break if rows.size >= count
126 break if rows.size >= count
123 end
127 end
124 rows
128 rows
125 end
129 end
126
130
127 # Returns an array of headers
131 # Returns an array of headers
128 def headers
132 def headers
129 first_rows(1).first || []
133 first_rows(1).first || []
130 end
134 end
131
135
132 # Returns the mapping options
136 # Returns the mapping options
133 def mapping
137 def mapping
134 settings['mapping'] || {}
138 settings['mapping'] || {}
135 end
139 end
136
140
137 # Imports items and returns the position of the last processed item
141 # Imports items and returns the position of the last processed item
138 def run(options={})
142 def run(options={})
139 max_items = options[:max_items]
143 max_items = options[:max_items]
140 max_time = options[:max_time]
144 max_time = options[:max_time]
141 current = 0
145 current = 0
142 imported = 0
146 imported = 0
143 resume_after = items.maximum(:position) || 0
147 resume_after = items.maximum(:position) || 0
144 interrupted = false
148 interrupted = false
145 started_on = Time.now
149 started_on = Time.now
146
150
147 read_items do |row, position|
151 read_items do |row, position|
148 if (max_items && imported >= max_items) || (max_time && Time.now >= started_on + max_time)
152 if (max_items && imported >= max_items) || (max_time && Time.now >= started_on + max_time)
149 interrupted = true
153 interrupted = true
150 break
154 break
151 end
155 end
152 if position > resume_after
156 if position > resume_after
153 item = items.build
157 item = items.build
154 item.position = position
158 item.position = position
155
159
156 if object = build_object(row)
160 if object = build_object(row)
157 if object.save
161 if object.save
158 item.obj_id = object.id
162 item.obj_id = object.id
159 else
163 else
160 item.message = object.errors.full_messages.join("\n")
164 item.message = object.errors.full_messages.join("\n")
161 end
165 end
162 end
166 end
163
167
164 item.save!
168 item.save!
165 imported += 1
169 imported += 1
166 end
170 end
167 current = position
171 current = position
168 end
172 end
169
173
170 if imported == 0 || interrupted == false
174 if imported == 0 || interrupted == false
171 if total_items.nil?
175 if total_items.nil?
172 update_attribute :total_items, current
176 update_attribute :total_items, current
173 end
177 end
174 update_attribute :finished, true
178 update_attribute :finished, true
175 remove_file
179 remove_file
176 end
180 end
177
181
178 current
182 current
179 end
183 end
180
184
181 def unsaved_items
185 def unsaved_items
182 items.where(:obj_id => nil)
186 items.where(:obj_id => nil)
183 end
187 end
184
188
185 def saved_items
189 def saved_items
186 items.where("obj_id IS NOT NULL")
190 items.where("obj_id IS NOT NULL")
187 end
191 end
188
192
189 private
193 private
190
194
191 def read_rows
195 def read_rows
192 return unless file_exists?
196 return unless file_exists?
193
197
194 csv_options = {:headers => false}
198 csv_options = {:headers => false}
195 csv_options[:encoding] = settings['encoding'].to_s.presence || 'UTF-8'
199 csv_options[:encoding] = settings['encoding'].to_s.presence || 'UTF-8'
196 separator = settings['separator'].to_s
200 separator = settings['separator'].to_s
197 csv_options[:col_sep] = separator if separator.size == 1
201 csv_options[:col_sep] = separator if separator.size == 1
198 wrapper = settings['wrapper'].to_s
202 wrapper = settings['wrapper'].to_s
199 csv_options[:quote_char] = wrapper if wrapper.size == 1
203 csv_options[:quote_char] = wrapper if wrapper.size == 1
200
204
201 CSV.foreach(filepath, csv_options) do |row|
205 CSV.foreach(filepath, csv_options) do |row|
202 yield row if block_given?
206 yield row if block_given?
203 end
207 end
204 end
208 end
205
209
206 def row_value(row, key)
210 def row_value(row, key)
207 if index = mapping[key].presence
211 if index = mapping[key].presence
208 row[index.to_i].presence
212 row[index.to_i].presence
209 end
213 end
210 end
214 end
211
215
212 def row_date(row, key)
216 def row_date(row, key)
213 if s = row_value(row, key)
217 if s = row_value(row, key)
214 format = settings['date_format']
218 format = settings['date_format']
215 format = DATE_FORMATS.first unless DATE_FORMATS.include?(format)
219 format = DATE_FORMATS.first unless DATE_FORMATS.include?(format)
216 Date.strptime(s, format) rescue s
220 Date.strptime(s, format) rescue s
217 end
221 end
218 end
222 end
219
223
220 # Builds a record for the given row and returns it
224 # Builds a record for the given row and returns it
221 # To be implemented by subclasses
225 # To be implemented by subclasses
222 def build_object(row)
226 def build_object(row)
223 end
227 end
224
228
225 # Generates a filename used to store the import file
229 # Generates a filename used to store the import file
226 def generate_filename
230 def generate_filename
227 Redmine::Utils.random_hex(16)
231 Redmine::Utils.random_hex(16)
228 end
232 end
229
233
230 # Deletes the import file
234 # Deletes the import file
231 def remove_file
235 def remove_file
232 if file_exists?
236 if file_exists?
233 begin
237 begin
234 File.delete filepath
238 File.delete filepath
235 rescue Exception => e
239 rescue Exception => e
236 logger.error "Unable to delete file #{filepath}: #{e.message}" if logger
240 logger.error "Unable to delete file #{filepath}: #{e.message}" if logger
237 end
241 end
238 end
242 end
239 end
243 end
240
244
241 # Returns true if value is a string that represents a true value
245 # Returns true if value is a string that represents a true value
242 def yes?(value)
246 def yes?(value)
243 value == lu(user, :general_text_yes) || value == '1'
247 value == lu(user, :general_text_yes) || value == '1'
244 end
248 end
245 end
249 end
@@ -1,103 +1,113
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19
19
20 class IssueImportTest < ActiveSupport::TestCase
20 class IssueImportTest < ActiveSupport::TestCase
21 fixtures :projects, :enabled_modules,
21 fixtures :projects, :enabled_modules,
22 :users, :email_addresses,
22 :users, :email_addresses,
23 :roles, :members, :member_roles,
23 :roles, :members, :member_roles,
24 :issues, :issue_statuses,
24 :issues, :issue_statuses,
25 :trackers, :projects_trackers,
25 :trackers, :projects_trackers,
26 :versions,
26 :versions,
27 :issue_categories,
27 :issue_categories,
28 :enumerations,
28 :enumerations,
29 :workflows,
29 :workflows,
30 :custom_fields,
30 :custom_fields,
31 :custom_values,
31 :custom_values,
32 :custom_fields_projects,
32 :custom_fields_projects,
33 :custom_fields_trackers
33 :custom_fields_trackers
34
34
35 def test_create_versions_should_create_missing_versions
35 def test_create_versions_should_create_missing_versions
36 import = generate_import_with_mapping
36 import = generate_import_with_mapping
37 import.mapping.merge!('fixed_version' => '9', 'create_versions' => '1')
37 import.mapping.merge!('fixed_version' => '9', 'create_versions' => '1')
38 import.save!
38 import.save!
39
39
40 version = new_record(Version) do
40 version = new_record(Version) do
41 assert_difference 'Issue.count', 3 do
41 assert_difference 'Issue.count', 3 do
42 import.run
42 import.run
43 end
43 end
44 end
44 end
45 assert_equal '2.1', version.name
45 assert_equal '2.1', version.name
46 end
46 end
47
47
48 def test_create_categories_should_create_missing_categories
48 def test_create_categories_should_create_missing_categories
49 import = generate_import_with_mapping
49 import = generate_import_with_mapping
50 import.mapping.merge!('category' => '10', 'create_categories' => '1')
50 import.mapping.merge!('category' => '10', 'create_categories' => '1')
51 import.save!
51 import.save!
52
52
53 category = new_record(IssueCategory) do
53 category = new_record(IssueCategory) do
54 assert_difference 'Issue.count', 3 do
54 assert_difference 'Issue.count', 3 do
55 import.run
55 import.run
56 end
56 end
57 end
57 end
58 assert_equal 'New category', category.name
58 assert_equal 'New category', category.name
59 end
59 end
60
60
61 def test_parent_should_be_set
61 def test_parent_should_be_set
62 import = generate_import_with_mapping
62 import = generate_import_with_mapping
63 import.mapping.merge!('parent_issue_id' => '5')
63 import.mapping.merge!('parent_issue_id' => '5')
64 import.save!
64 import.save!
65
65
66 issues = new_records(Issue, 3) { import.run }
66 issues = new_records(Issue, 3) { import.run }
67 assert_nil issues[0].parent
67 assert_nil issues[0].parent
68 assert_equal issues[0].id, issues[1].parent_id
68 assert_equal issues[0].id, issues[1].parent_id
69 assert_equal 2, issues[2].parent_id
69 assert_equal 2, issues[2].parent_id
70 end
70 end
71
71
72 def test_is_private_should_be_set_based_on_user_locale
72 def test_is_private_should_be_set_based_on_user_locale
73 import = generate_import_with_mapping
73 import = generate_import_with_mapping
74 import.mapping.merge!('is_private' => '6')
74 import.mapping.merge!('is_private' => '6')
75 import.save!
75 import.save!
76
76
77 issues = new_records(Issue, 3) { import.run }
77 issues = new_records(Issue, 3) { import.run }
78 assert_equal [false, true, false], issues.map(&:is_private)
78 assert_equal [false, true, false], issues.map(&:is_private)
79 end
79 end
80
80
81 def test_dates_should_be_parsed_using_date_format_setting
81 def test_dates_should_be_parsed_using_date_format_setting
82 field = IssueCustomField.generate!(:field_format => 'date', :is_for_all => true, :trackers => Tracker.all)
82 field = IssueCustomField.generate!(:field_format => 'date', :is_for_all => true, :trackers => Tracker.all)
83 import = generate_import_with_mapping('import_dates.csv')
83 import = generate_import_with_mapping('import_dates.csv')
84 import.settings.merge!('date_format' => Import::DATE_FORMATS[1])
84 import.settings.merge!('date_format' => Import::DATE_FORMATS[1])
85 import.mapping.merge!('subject' => '0', 'start_date' => '1', 'due_date' => '2', "cf_#{field.id}" => '3')
85 import.mapping.merge!('subject' => '0', 'start_date' => '1', 'due_date' => '2', "cf_#{field.id}" => '3')
86 import.save!
86 import.save!
87
87
88 issue = new_record(Issue) { import.run } # only 1 valid issue
88 issue = new_record(Issue) { import.run } # only 1 valid issue
89 assert_equal "Valid dates", issue.subject
89 assert_equal "Valid dates", issue.subject
90 assert_equal Date.parse('2015-07-10'), issue.start_date
90 assert_equal Date.parse('2015-07-10'), issue.start_date
91 assert_equal Date.parse('2015-08-12'), issue.due_date
91 assert_equal Date.parse('2015-08-12'), issue.due_date
92 assert_equal '2015-07-14', issue.custom_field_value(field)
92 assert_equal '2015-07-14', issue.custom_field_value(field)
93 end
93 end
94
94
95 def test_date_format_should_default_to_user_language
96 user = User.generate!(:language => 'fr')
97 import = Import.new
98 import.user = user
99 assert_nil import.settings['date_format']
100
101 import.set_default_settings
102 assert_equal '%d/%m/%Y', import.settings['date_format']
103 end
104
95 def test_run_should_remove_the_file
105 def test_run_should_remove_the_file
96 import = generate_import_with_mapping
106 import = generate_import_with_mapping
97 file_path = import.filepath
107 file_path = import.filepath
98 assert File.exists?(file_path)
108 assert File.exists?(file_path)
99
109
100 import.run
110 import.run
101 assert !File.exists?(file_path)
111 assert !File.exists?(file_path)
102 end
112 end
103 end
113 end
General Comments 0
You need to be logged in to leave comments. Login now