├── .circleci
└── config.yml
├── .gitignore
├── Gemfile.local
├── README.rdoc
├── app
├── controllers
│ └── importer_controller.rb
├── helpers
│ └── importer_helper.rb
├── models
│ └── import_in_progress.rb
└── views
│ └── importer
│ ├── index.html.erb
│ ├── match.html.erb
│ └── result.html.erb
├── assets
└── stylesheets
│ └── importer.css
├── config
├── locales
│ ├── de.yml
│ ├── en.yml
│ ├── ja.yml
│ ├── pt-BR.yml
│ ├── ru.yml
│ └── zh.yml
└── routes.rb
├── db
└── migrate
│ └── 001_create_import_in_progresses.rb
├── init.rb
├── lib
└── redmine_importer
│ └── concerns
│ └── validate_status.rb
└── test
├── fixtures
└── import_in_progresses.yml
├── functional
└── importer_controller_test.rb
├── samples
├── AllStandardBlankFields.csv
├── AllStandardFields.csv
├── AllStandardFields.de.csv
├── CustomField.csv
├── CustomFieldMultiValues.csv
├── CustomFieldUpdate.csv
├── ErroneousStandardFields.csv
├── IssueRelationsCustomField.csv
├── KeyValueList.csv
├── KeyValueListMultiple.csv
├── NumberAsStringFields.csv
├── ParentTaskByCustomField.csv
├── ParentTaskBySubject.csv
├── TypedCustomFields.csv
└── TypedErroneousCustomFields.csv
├── test_helper.rb
└── unit
└── import_in_progress_test.rb
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | orbs:
4 | redmine-plugin: agileware-jp/redmine-plugin@3.8.0
5 |
6 | jobs:
7 | run-tests-git-url:
8 | parameters:
9 | redmine_git_url:
10 | type: string
11 | redmine_version:
12 | type: string
13 | ruby_version:
14 | type: string
15 | db:
16 | type: enum
17 | enum: ['mysql', 'pg']
18 | db_version:
19 | type: string
20 | executor:
21 | name: redmine-plugin/ruby-<< parameters.db >>
22 | ruby_version: << parameters.ruby_version >>
23 | db_version: << parameters.db_version >>
24 | steps:
25 | - checkout
26 | - redmine-plugin/download-redmine-git-url:
27 | git_url: << parameters.redmine_git_url >>
28 | version: << parameters.redmine_version >>
29 | - redmine-plugin/install-self
30 | - redmine-plugin/generate-database_yml
31 | - redmine-plugin/bundle-install
32 | - redmine-plugin/migrate-without-plugins
33 | - redmine-plugin/test
34 | run-tests:
35 | executor:
36 | name: redmine-plugin/ruby-<< parameters.db >>
37 | ruby_version: << parameters.ruby_version >>
38 | db_version: << parameters.db_version >>
39 | parameters:
40 | redmine_version:
41 | type: string
42 | ruby_version:
43 | type: string
44 | db:
45 | type: enum
46 | enum: ['mysql', 'pg']
47 | db_version:
48 | type: string
49 | steps:
50 | - checkout
51 | - redmine-plugin/download-redmine:
52 | version: << parameters.redmine_version >>
53 | - redmine-plugin/install-self
54 | - redmine-plugin/generate-database_yml
55 | - redmine-plugin/bundle-install
56 | - redmine-plugin/migrate-without-plugins
57 | - redmine-plugin/test
58 |
59 | default_context: &default_context
60 | context:
61 | - lychee-ci-environment
62 |
63 | workflows:
64 | run-tests-workflow:
65 | jobs:
66 | - run-tests-git-url:
67 | !!merge <<: *default_context
68 | name: test on Redmine git with PostgreSQL
69 | redmine_git_url: $REDMINE_GIT_URL
70 | redmine_version: $REDMINE_GIT_REVISION
71 | ruby_version: $REDMINE_GIT_RUBY_VERSION
72 | db: pg
73 | db_version: $POSTGRES_VERSION
74 | - run-tests:
75 | <<: *default_context
76 | name: Test on supported maximum versions with PostgreSQL
77 | redmine_version: $REDMINE_MAX_VERSION
78 | ruby_version: $RUBY_MAX_VERSION
79 | db: pg
80 | db_version: $POSTGRES_VERSION
81 | - run-tests:
82 | <<: *default_context
83 | name: Test on supported minimum versions with MySQL
84 | redmine_version: $REDMINE_MIN_VERSION
85 | ruby_version: $RUBY_MIN_VERSION
86 | db: mysql
87 | db_version: $MYSQL_VERSION
88 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .~lock*
2 | *~
3 | #*#
4 |
--------------------------------------------------------------------------------
/Gemfile.local:
--------------------------------------------------------------------------------
1 | group :test do
2 | dependencies.reject! { |i| i.name == 'nokogiri' } # Ensure Nokogiri have new version
3 | end
--------------------------------------------------------------------------------
/README.rdoc:
--------------------------------------------------------------------------------
1 | = Redmine Issue Importer
2 |
3 | User documentation for this plugin is
4 | at https://github.com/leovitch/redmine_importer/wiki.
5 |
6 | This plugin is functional now, including in multiprocess environments.
7 | The plugin has been tested on Redmine 4.1 or higher
8 | The database is used for intermediate storage.
9 |
10 | To install:
11 | - Prerequisites: Unless you are using ruby-1.9, you'll need the fastercsv gem (gem install fastercsv
as root).
12 | Versions 1.4 through 1.5.3 are tested.
13 | - Download the plugin to your 'plugins/' directory. Be sure to maintain the correct folder name, 'redmine_importer'.
14 | - Run rake redmine:plugins:migrate RAILS_ENV=production
15 | - Restart your redmine as appropriate (e.g., ruby script/rails server -e production
)
16 | - Go to the Admin/Projects/../Modules
17 | - Enable "Importer"
18 |
19 | en, de, zh, pt-BR, ru and ja localizations included.
20 | The other localizations are up to date, but the zh is a little bit behind.
21 | If anyone could update it, it would be appreciated.
22 |
23 | User documentation at https://github.com/leovitch/redmine_importer/wiki.
24 |
--------------------------------------------------------------------------------
/app/controllers/importer_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'csv'
4 | require 'tempfile'
5 |
6 | MultipleIssuesForUniqueValue = Class.new(RuntimeError)
7 | NoIssueForUniqueValue = Class.new(RuntimeError)
8 |
9 | class ImporterController < ApplicationController
10 | before_action :find_project
11 |
12 | ISSUE_ATTRS = %i[id subject assigned_to fixed_version
13 | author description category priority tracker status
14 | start_date due_date done_ratio estimated_hours
15 | parent_issue watchers is_private].freeze
16 |
17 | def index; end
18 |
19 | def match
20 | if params[:file].blank?
21 | flash[:error] = I18n.t(:flash_csv_file_is_blank)
22 | redirect_to action: :index
23 | return
24 | end
25 |
26 | # Delete existing iip to ensure there can't be two iips for a user
27 | ImportInProgress.where('user_id = ?', User.current.id).delete_all
28 | # save import-in-progress data
29 | iip = ImportInProgress.find_or_create_by(user_id: User.current.id)
30 | iip.quote_char = params[:wrapper]
31 | iip.col_sep = params[:splitter]
32 | iip.encoding = params[:encoding]
33 | iip.created = Time.new
34 | iip.csv_data = params[:file].read unless params[:file].blank?
35 | iip.save
36 |
37 | # Put the timestamp in the params to detect
38 | # users with two imports in progress
39 | @import_timestamp = iip.created.strftime('%Y-%m-%d %H:%M:%S')
40 | @original_filename = params[:file].original_filename
41 |
42 | flash.delete(:error)
43 | validate_csv_data(iip.csv_data)
44 | return if flash[:error].present?
45 |
46 | sample_data(iip)
47 | return if flash[:error].present?
48 |
49 | set_csv_headers(iip)
50 | return if flash[:error].present?
51 |
52 | # fields
53 | @attrs = []
54 | ISSUE_ATTRS.each do |attr|
55 | # @attrs.push([l_has_string?("field_#{attr}".to_sym) ? l("field_#{attr}".to_sym) : attr.to_s.humanize, attr])
56 | @attrs.push([l_or_humanize(attr, prefix: 'field_'), "standard_field-#{attr}"])
57 | end
58 | @project.all_issue_custom_fields.each do |cfield|
59 | @attrs.push([cfield.name, "custom_field-#{cfield.name}"])
60 | end
61 | IssueRelation::TYPES.each_pair do |rtype, rinfo|
62 | @attrs.push([l_or_humanize(rinfo[:name]), "issue_relation-#{rtype}"])
63 | end
64 | @attrs.sort!
65 | end
66 |
67 | def result
68 | # used for bookkeeping
69 | flash.delete(:error)
70 |
71 | init_globals
72 | # Used to optimize some work that has to happen inside the loop
73 | unique_attr_checked = false
74 |
75 | # Retrieve saved import data
76 | iip = ImportInProgress.find_by_user_id(User.current.id)
77 | if iip.nil?
78 | flash[:error] = 'No import is currently in progress'
79 | return
80 | end
81 | if iip.created.strftime('%Y-%m-%d %H:%M:%S') != params[:import_timestamp]
82 | flash[:error] = 'You seem to have started another import ' \
83 | 'since starting this one. ' \
84 | 'This import cannot be completed'
85 | return
86 | end
87 | # which options were turned on?
88 | update_issue = params[:update_issue]
89 | update_other_project = params[:update_other_project]
90 | send_emails = params[:send_emails]
91 | add_categories = params[:add_categories]
92 | add_versions = params[:add_versions]
93 | use_issue_id = params[:use_issue_id].present? ? true : false
94 | ignore_non_exist = params[:ignore_non_exist]
95 |
96 | # which fields should we use? what maps to what?
97 | unique_field = params[:unique_field].empty? ? nil : params[:unique_field]
98 |
99 | fields_map = {}
100 | params[:fields_map].each { |k, v| fields_map[k.unpack('U*').pack('U*')] = v }
101 | unique_attr = fields_map[unique_field]
102 |
103 | default_tracker = params[:default_tracker]
104 | journal_field = params[:journal_field]
105 |
106 | # attrs_map is fields_map's invert
107 | @attrs_map = fields_map.invert
108 |
109 | # validation!
110 | # if the unique_attr is blank but any of the following opts is turned on,
111 | if unique_attr.blank?
112 | if update_issue
113 | flash[:error] = l(:text_rmi_specify_unique_field_for_update)
114 | elsif @attrs_map['standard_field-parent_issue'].present?
115 | flash[:error] = l(:text_rmi_specify_unique_field_for_column,
116 | column: l(:field_parent_issue))
117 | else IssueRelation::TYPES.each_key.any? { |t| @attrs_map["issue_relation-#{t}"].present? }
118 | IssueRelation::TYPES.each_key do |t|
119 | if @attrs_map["issue_relation-#{t}"].present?
120 | flash[:error] = l(:text_rmi_specify_unique_field_for_column,
121 | column: l("label_#{t}".to_sym))
122 | end
123 | end
124 | end
125 | end
126 |
127 | # validate that the id attribute has been selected
128 | if use_issue_id
129 | if @attrs_map['standard_field-id'].blank?
130 | flash[:error] = 'You must specify a column mapping for id' \
131 | ' when importing using provided issue ids.'
132 | end
133 | end
134 |
135 | # if error is full, NOP
136 | return if flash[:error].present?
137 |
138 | csv_opt = { headers: true,
139 | encoding: 'UTF-8',
140 | quote_char: iip.quote_char,
141 | col_sep: iip.col_sep }
142 | CSV.new(iip.csv_data, **csv_opt).each do |row|
143 | project = Project.find_by_name(fetch('standard_field-project', row))
144 | project ||= @project
145 |
146 | begin
147 | row.each do |k, v|
148 | k = k.unpack('U*').pack('U*') if k.is_a?(String)
149 | v = v.unpack('U*').pack('U*') if v.is_a?(String)
150 |
151 | row[k] = v
152 | end
153 |
154 | issue = Issue.new
155 | issue.notify = false
156 |
157 | issue.id = fetch('standard_field-id', row) if use_issue_id
158 |
159 | tracker = Tracker.find_by_name(fetch('standard_field-tracker', row))
160 | status = IssueStatus.find_by_name(fetch('standard_field-status', row))
161 | author = if @attrs_map.key?('standard_field-author') && @attrs_map['standard_field-author']
162 | user_for_login!(fetch('standard_field-author', row))
163 | else
164 | User.current
165 | end
166 | priority = Enumeration.find_by_name(fetch('standard_field-priority', row))
167 | category_name = fetch('standard_field-category', row)
168 | category = IssueCategory.find_by_project_id_and_name(project.id,
169 | category_name)
170 |
171 | if !category \
172 | && category_name && !category_name.empty? \
173 | && add_categories
174 |
175 | category = project.issue_categories.build(name: category_name)
176 | category.save
177 | end
178 |
179 | if category.blank? && fetch('standard_field-category', row).present?
180 | @unfound_class = 'Category'
181 | @unfound_key = fetch('standard_field-category', row)
182 | raise ActiveRecord::RecordNotFound
183 | end
184 |
185 | if fetch('standard_field-assigned_to', row).present?
186 | assigned_to = user_for_login!(fetch('standard_field-assigned_to', row))
187 | assigned_to = nil if assigned_to == User.anonymous
188 | else
189 | assigned_to = nil
190 | end
191 |
192 | if fetch('standard_field-fixed_version', row).present?
193 | fixed_version_name = fetch('standard_field-fixed_version', row)
194 | fixed_version_id = version_id_for_name!(project,
195 | fixed_version_name,
196 | add_versions)
197 | else
198 | fixed_version_name = nil
199 | fixed_version_id = nil
200 | end
201 |
202 | watchers = fetch('standard_field-watchers', row)
203 |
204 | issue.project_id = !project.nil? ? project.id : @project.id
205 | issue.tracker_id = !tracker.nil? ? tracker.id : default_tracker
206 | issue.author_id = !author.nil? ? author.id : User.current.id
207 | rescue ActiveRecord::RecordNotFound
208 | log_failure(row, "Warning: When adding issue #{@failed_count + 1} below," \
209 | " the #{@unfound_class} #{@unfound_key} was not found")
210 | next
211 | end
212 |
213 | begin
214 | unique_attr = translate_unique_attr(issue, unique_field, unique_attr, unique_attr_checked)
215 |
216 | issue, journal = handle_issue_update(issue, row, author, status, update_other_project, journal_field,
217 | unique_attr, unique_field, ignore_non_exist, update_issue)
218 |
219 | project ||= Project.find_by_id(issue.project_id)
220 |
221 | update_project_issues_stat(project)
222 | assign_issue_attrs(issue, category, fixed_version_id, assigned_to, status, row, priority, tracker)
223 | handle_parent_issues(issue, row, ignore_non_exist, unique_attr)
224 | handle_custom_fields(add_versions, issue, project, row)
225 | handle_watchers(issue, row, watchers)
226 | rescue RowFailed
227 | next
228 | rescue ActiveRecord::RecordNotFound
229 | log_failure(row, "Warning: When adding issue #{@failed_count + 1} below," \
230 | " the #{@unfound_class} #{@unfound_key} was not found")
231 | next
232 | rescue ArgumentError
233 | log_failure(row, "Warning: When adding issue #{@failed_count + 1} below," \
234 | " #{@error_value} is not valid value.")
235 | next
236 | end
237 |
238 | issue.singleton_class.include RedmineImporter::Concerns::ValidateStatus
239 |
240 | begin
241 | issue_saved = issue.save
242 | rescue ActiveRecord::RecordNotUnique
243 | issue_saved = false
244 | @messages << 'This issue id has already been taken.'
245 | end
246 |
247 | if issue_saved
248 | @issue_by_unique_attr[row[unique_field]] = issue if unique_field
249 |
250 | if send_emails
251 | if update_issue
252 | if Setting.notified_events.include?('issue_updated') \
253 | && !(issue.current_journal.details.empty? && issue.current_journal.notes.blank?)
254 |
255 | Mailer.deliver_issue_edit(issue.current_journal)
256 | end
257 | else
258 | if Setting.notified_events.include?('issue_added')
259 | Mailer.deliver_issue_add(issue)
260 | end
261 | end
262 | end
263 |
264 | # Issue relations
265 | begin
266 | IssueRelation::TYPES.each_pair do |rtype, _rinfo|
267 | next unless row[@attrs_map["issue_relation-#{rtype}"]]
268 |
269 | other_issue = issue_for_unique_attr(unique_attr,
270 | row[@attrs_map["issue_relation-#{rtype}"]],
271 | row)
272 | relations = issue.relations.select do |r|
273 | (r.other_issue(issue).id == other_issue.id) \
274 | && (r.relation_type_for(issue) == rtype)
275 | end
276 | next unless relations.empty?
277 |
278 | relation = IssueRelation.new(issue_from: issue,
279 | issue_to: other_issue,
280 | relation_type: rtype)
281 | relation.save
282 | end
283 | rescue NoIssueForUniqueValue
284 | if ignore_non_exist
285 | @skip_count += 1
286 | next
287 | end
288 | rescue MultipleIssuesForUniqueValue
289 | break
290 | end
291 |
292 | journal
293 |
294 | @handle_count += 1
295 |
296 | else
297 | @failed_count += 1
298 | @failed_issues[@failed_count] = row
299 | @messages << 'Warning: The following data-validation errors occurred' \
300 | " on issue #{@failed_count} in the list below"
301 | issue.errors.each do |attr, error_message|
302 | @messages << "Error: #{attr} #{error_message}"
303 | end
304 | end
305 | end # do
306 |
307 | unless @failed_issues.empty?
308 | @failed_issues = @failed_issues.sort
309 | @headers = @failed_issues[0][1].headers
310 | end
311 |
312 | # Clean up after ourselves
313 | iip.delete
314 |
315 | # Garbage prevention: clean up iips older than 3 days
316 | ImportInProgress.where('created < ?', Time.new - 3 * 24 * 60 * 60).delete_all
317 |
318 | if use_issue_id && ActiveRecord::Base.connection.respond_to?(:reset_pk_sequence!)
319 | ActiveRecord::Base.connection.reset_pk_sequence!(Issue.table_name)
320 | end
321 | end
322 |
323 | def translate_unique_attr(issue, unique_field, unique_attr, unique_attr_checked)
324 | # translate unique_attr if it's a custom field -- only on the first issue
325 | unless unique_attr_checked
326 | if unique_field && !ISSUE_ATTRS.include?(unique_attr.to_sym)
327 | issue.available_custom_fields.each do |cf|
328 | if cf.name == unique_attr
329 | unique_attr = "cf_#{cf.id}"
330 | break
331 | end
332 | end
333 | end
334 | unique_attr_checked = true
335 | end
336 | unique_attr
337 | end
338 |
339 | def handle_issue_update(issue, row, author, status, update_other_project, journal_field, unique_attr, unique_field, ignore_non_exist, update_issue)
340 | if update_issue
341 | begin
342 | issue = issue_for_unique_attr(unique_attr, row[unique_field], row)
343 |
344 | # ignore other project's issue or not
345 | if issue.project_id != @project.id && !update_other_project
346 | @skip_count += 1
347 | raise RowFailed
348 | end
349 |
350 | # ignore closed issue except reopen
351 | if issue.status.is_closed?
352 | if status.nil? || status.is_closed?
353 | @skip_count += 1
354 | raise RowFailed
355 | end
356 | end
357 |
358 | # init journal
359 | note = row[journal_field] || ''
360 | journal = issue.init_journal(author || User.current,
361 | note || '')
362 | journal.notify = false # disable journal's notification to use custom one down below
363 | @update_count += 1
364 | rescue NoIssueForUniqueValue
365 | if ignore_non_exist
366 | @skip_count += 1
367 | raise RowFailed
368 | else
369 | log_failure(row,
370 | "Warning: Could not update issue #{@failed_count + 1} below," \
371 | " no match for the value #{row[unique_field]} were found")
372 | raise RowFailed
373 | end
374 | rescue MultipleIssuesForUniqueValue
375 | log_failure(row,
376 | "Warning: Could not update issue #{@failed_count + 1} below," \
377 | " multiple matches for the value #{row[unique_field]} were found")
378 | raise RowFailed
379 | end
380 | end
381 | [issue, journal]
382 | end
383 |
384 | def update_project_issues_stat(project)
385 | if @affect_projects_issues.key?(project.name)
386 | @affect_projects_issues[project.name] += 1
387 | else
388 | @affect_projects_issues[project.name] = 1
389 | end
390 | end
391 |
392 | def assign_issue_attrs(issue, category, fixed_version_id, assigned_to, status, row, priority, tracker)
393 | # required attributes
394 | if assignable?(:status)
395 | issue.status_id = !status.nil? ? status.id : issue.status_id
396 | end
397 | if assignable?(:priority)
398 | issue.priority_id = !priority.nil? ? priority.id : issue.priority_id
399 | end
400 | if assignable?(:subject)
401 | issue.subject = fetch('standard_field-subject', row) || issue.subject
402 | end
403 | if assignable?(:tracker)
404 | issue.tracker_id = tracker.present? ? tracker.id : issue.tracker_id
405 | end
406 |
407 | # optional attributes
408 | issue.description = fetch('standard_field-description', row) if assignable?(:description)
409 | issue.category_id = category.try(:id) if assignable?(:category)
410 |
411 | %w[start_date due_date].each do |date_field_name|
412 | next unless assignable?(date_field_name)
413 |
414 | date_field_value = fetch("standard_field-#{date_field_name}", row)
415 |
416 | if date_field_value.present?
417 | begin
418 | issue.send("#{date_field_name}=", Date.parse(date_field_value))
419 | rescue ArgumentError
420 | @error_value = date_field_value
421 | raise ArgumentError
422 | end
423 | else
424 | issue.send("#{date_field_name}=", nil)
425 | end
426 | end
427 |
428 | if assignable?(:assigned_to)
429 | issue.assigned_to_id = assigned_to.try(:id)
430 | unless issue.assigned_to.in?(issue.assignable_users)
431 | issue.assigned_to = nil
432 | end
433 | end
434 | issue.fixed_version_id = fixed_version_id if assignable?(:fixed_version)
435 | issue.done_ratio = fetch('standard_field-done_ratio', row) if assignable?(:done_ratio)
436 | if assignable?(:estimated_hours)
437 | issue.estimated_hours = fetch('standard_field-estimated_hours', row)
438 | end
439 | if assignable?(:is_private)
440 | issue.is_private = (convert_to_boolean(fetch('standard_field-is_private', row)) || false)
441 | end
442 | end
443 |
444 | def assignable?(field)
445 | raise unless ISSUE_ATTRS.include?(field.to_sym)
446 |
447 | @attrs_map.key?("standard_field-#{field}")
448 | end
449 |
450 | def handle_parent_issues(issue, row, ignore_non_exist, unique_attr)
451 | return unless assignable?(:parent_issue)
452 |
453 | parent_value = fetch('standard_field-parent_issue', row)
454 | issue.parent_issue_id = if parent_value.present?
455 | issue_for_unique_attr(unique_attr, parent_value, row).id
456 | end
457 | rescue NoIssueForUniqueValue
458 | if ignore_non_exist
459 | @skip_count += 1
460 | else
461 | @failed_count += 1
462 | @failed_issues[@failed_count] = row
463 | @messages << "Warning: When setting the parent for issue #{@failed_count} below,"\
464 | " no matches for the value #{parent_value} were found"
465 | raise RowFailed
466 | end
467 | rescue MultipleIssuesForUniqueValue
468 | @failed_count += 1
469 | @failed_issues[@failed_count] = row
470 | @messages << "Warning: When setting the parent for issue #{@failed_count} below," \
471 | " multiple matches for the value #{parent_value} were found"
472 | raise RowFailed
473 | end
474 |
475 | def init_globals
476 | @handle_count = 0
477 | @update_count = 0
478 | @skip_count = 0
479 | @failed_count = 0
480 | @failed_issues = {}
481 | @messages = []
482 | @affect_projects_issues = {}
483 | # This is a cache of previously inserted issues indexed by the value
484 | # the user provided in the unique column
485 | @issue_by_unique_attr = {}
486 | # Cache of user id by login
487 | @user_by_login = {}
488 | # Cache of Version by name
489 | @version_id_by_name = {}
490 | # Cache of CustomFieldEnumeration by name
491 | @enumeration_id_by_name = {}
492 | end
493 |
494 | def handle_watchers(issue, row, watchers)
495 | return unless assignable?(:watchers)
496 |
497 | watcher_failed_count = 0
498 | if watchers
499 | addable_watcher_users = issue.addable_watcher_users
500 | watchers.split(',').each do |watcher|
501 | begin
502 | watcher_user = user_for_login!(watcher)
503 | next if issue.watcher_users.include?(watcher_user)
504 |
505 | if addable_watcher_users.include?(watcher_user)
506 | issue.add_watcher(watcher_user)
507 | end
508 | rescue ActiveRecord::RecordNotFound
509 | if watcher_failed_count == 0
510 | @failed_count += 1
511 | @failed_issues[@failed_count] = row
512 | end
513 | watcher_failed_count += 1
514 | @messages << 'Warning: When trying to add watchers on issue' \
515 | " #{@failed_count} below, User #{watcher} was not found"
516 | end
517 | end
518 | end
519 | raise RowFailed if watcher_failed_count > 0
520 | end
521 |
522 | def handle_custom_fields(add_versions, issue, project, row)
523 | custom_failed_count = 0
524 | issue.custom_field_values = issue.available_custom_fields.each_with_object({}) do |cf, h|
525 | next h unless @attrs_map.key?("custom_field-#{cf.name}") # this cf is absent or ignored.
526 |
527 | value = row[@attrs_map["custom_field-#{cf.name}"]]
528 | if cf.multiple
529 | h[cf.id] = process_multivalue_custom_field(project, add_versions, issue, cf, value)
530 | else
531 | begin
532 | if value.present?
533 | value = case cf.field_format
534 | when 'user'
535 | user = user_id_for_login!(value)
536 | if user.in?(cf.format.possible_values_records(cf, issue).map(&:id))
537 | user == User.anonymous.id ? nil : user.to_s
538 | end
539 | when 'version'
540 | version_id_for_name!(project, value, add_versions).to_s
541 | when 'date'
542 | value.to_date.to_s(:db)
543 | when 'bool'
544 | convert_to_0_or_1(value)
545 | when 'enumeration'
546 | enumeration_id_for_name!(cf, value).to_s
547 | else
548 | value
549 | end
550 | else
551 | value = nil
552 | end
553 |
554 | h[cf.id] = value
555 | rescue StandardError
556 | if custom_failed_count == 0
557 | custom_failed_count += 1
558 | @failed_count += 1
559 | @failed_issues[@failed_count] = row
560 | end
561 | @messages << "Warning: When trying to set custom field #{cf.name}" \
562 | " on issue #{@failed_count} below, value #{value} was invalid"
563 | end
564 | end
565 | end
566 | raise RowFailed if custom_failed_count > 0
567 | end
568 |
569 | private
570 |
571 | def fetch(key, row)
572 | row[@attrs_map[key]]
573 | end
574 |
575 | def log_failure(row, msg)
576 | @failed_count += 1
577 | @failed_issues[@failed_count] = row
578 | @messages << msg
579 | end
580 |
581 | def find_project
582 | @project = Project.find(params[:project_id])
583 | end
584 |
585 | def flash_message(type, text)
586 | flash[type] ||= ''
587 | flash[type] += "#{text} "
588 | end
589 |
590 | def validate_csv_data(csv_data)
591 | if csv_data.lines.to_a.size <= 1
592 | flash[:error] = 'No data line in your CSV, check the encoding of the file'\
593 | ' Header : '.html_safe + csv_data
594 |
595 | redirect_to project_importer_path(project_id: @project)
596 |
597 | nil
598 | end
599 | end
600 |
601 | def sample_data(iip)
602 | # display sample
603 | sample_count = 5
604 | @samples = []
605 |
606 | begin
607 | CSV.new(iip.csv_data, headers: true,
608 | encoding: 'UTF-8',
609 | quote_char: iip.quote_char,
610 | col_sep: iip.col_sep).each_with_index do |row, i|
611 | @samples[i] = row
612 | break if i >= sample_count
613 | end # do
614 | rescue Exception => e
615 | csv_data_lines = iip.csv_data.lines.to_a
616 |
617 | error_message = e.message +
618 | ' Header : '.html_safe +
619 | csv_data_lines[0]
620 |
621 | # if there was an exception, probably happened on line after the last sampled.
622 | unless csv_data_lines.empty?
623 | error_message += ' Error on header or line : '.html_safe +
624 | csv_data_lines[@samples.size + 1]
625 | end
626 |
627 | flash[:error] = error_message
628 |
629 | redirect_to project_importer_path(project_id: @project)
630 |
631 | nil
632 | end
633 | end
634 |
635 | def set_csv_headers(iip)
636 | @headers = @samples[0].headers unless @samples.empty?
637 |
638 | missing_header_columns = ''
639 | @headers.each_with_index do |h, i|
640 | missing_header_columns += " #{i + 1}" if h.nil?
641 | end
642 |
643 | if missing_header_columns.present?
644 | flash[:error] = "Column header missing : #{missing_header_columns}" \
645 | " / #{@headers.size} #{' Header : '.html_safe}" \
646 | " #{iip.csv_data.lines.to_a[0]}"
647 |
648 | redirect_to project_importer_path(project_id: @project)
649 |
650 | nil
651 | end
652 | end
653 |
654 | # Returns the issue object associated with the given value of the given attribute.
655 | # Raises NoIssueForUniqueValue if not found or MultipleIssuesForUniqueValue
656 | def issue_for_unique_attr(unique_attr, attr_value, row_data)
657 | if @issue_by_unique_attr.key?(attr_value)
658 | return @issue_by_unique_attr[attr_value]
659 | end
660 |
661 | if unique_attr == 'standard_field-id'
662 | issues = [Issue.find_by_id(attr_value)].compact
663 | else
664 | # Use IssueQuery class Redmine >= 2.3.0
665 | begin
666 | if Module.const_get('IssueQuery') && IssueQuery.is_a?(Class)
667 | query_class = IssueQuery
668 | end
669 | rescue NameError
670 | query_class = Query
671 | end
672 |
673 | query = query_class.new(name: '_importer', project: @project)
674 | query.add_filter('status_id', '*', [1])
675 | query.add_filter(unique_attr, '=', [attr_value])
676 |
677 | issues = Issue.joins([:project])
678 | .includes(%i[assigned_to status tracker project priority
679 | category fixed_version])
680 | .limit(2)
681 | .where(query.statement)
682 | end
683 |
684 | if issues.size > 1
685 | @failed_count += 1
686 | @failed_issues[@failed_count] = row_data
687 | @messages << "Warning: Unique field #{unique_attr} with value " \
688 | "'#{attr_value}' in issue #{@failed_count} has duplicate record"
689 | raise MultipleIssuesForUniqueValue, "Unique field #{unique_attr} with" \
690 | " value '#{attr_value}' has duplicate record"
691 | elsif issues.empty? || issues[0].nil?
692 | raise NoIssueForUniqueValue, "No issue with #{unique_attr} of '#{attr_value}' found"
693 | else
694 | issues.first
695 | end
696 | end
697 |
698 | # Returns the id for the given user or raises RecordNotFound
699 | # Implements a cache of users based on login name
700 | def user_for_login!(login)
701 | begin
702 | unless @user_by_login.key?(login)
703 | @user_by_login[login] = User.find_by_login!(login)
704 | end
705 | rescue ActiveRecord::RecordNotFound
706 | if params[:use_anonymous]
707 | @user_by_login[login] = User.anonymous
708 | else
709 | @unfound_class = 'User'
710 | @unfound_key = login
711 | raise
712 | end
713 | end
714 | @user_by_login[login]
715 | end
716 |
717 | def user_id_for_login!(login)
718 | user = user_for_login!(login)
719 | user ? user.id : nil
720 | end
721 |
722 | # Returns the id for the given version or raises RecordNotFound.
723 | # Implements a cache of version ids based on version name
724 | # If add_versions is true and a valid name is given,
725 | # will create a new version and save it when it doesn't exist yet.
726 | def version_id_for_name!(project, name, add_versions)
727 | unless @version_id_by_name.key?(name)
728 | version = project.shared_versions.find_by_name(name)
729 | unless version
730 | if name && !name.empty? && add_versions
731 | version = project.versions.build(name: name)
732 | version.save
733 | else
734 | @unfound_class = 'Version'
735 | @unfound_key = name
736 | raise ActiveRecord::RecordNotFound, "No version named #{name}"
737 | end
738 | end
739 | @version_id_by_name[name] = version.id
740 | end
741 | @version_id_by_name[name]
742 | end
743 |
744 | def enumeration_id_for_name!(custom_field, name)
745 | unless @enumeration_id_by_name.key?(name)
746 | enumeration = custom_field.enumerations.find_by(name: name).try!(:id)
747 | if enumeration.nil?
748 | @unfound_class = 'CustomFieldEnumeration'
749 | @unfound_key = name
750 | raise ActiveRecord::RecordNotFound, "No enumeration named #{name}"
751 | end
752 | @enumeration_id_by_name[name] = enumeration
753 | end
754 | @enumeration_id_by_name[name]
755 | end
756 |
757 | def process_multivalue_custom_field(project, add_versions, issue, custom_field, csv_val)
758 | return [] if csv_val.blank?
759 |
760 | csv_val.split(',').map(&:strip).map do |val|
761 | if custom_field.field_format == 'version'
762 | version = version_id_for_name!(project, val, add_versions)
763 | version
764 | elsif custom_field.field_format == 'enumeration'
765 | enumeration_id_for_name!(custom_field, val)
766 | elsif custom_field.field_format == 'user'
767 | user = user_id_for_login!(val)
768 | if user.in?(custom_field.format.possible_values_records(custom_field, issue).map(&:id))
769 | user == User.anonymous.id ? nil : user.to_s
770 | end
771 | else
772 | val
773 | end
774 | end
775 | end
776 |
777 | def convert_to_boolean(raw_value)
778 | return_value_by raw_value, true, false
779 | end
780 |
781 | def convert_to_0_or_1(raw_value)
782 | return_value_by raw_value, '1', '0'
783 | end
784 |
785 | def return_value_by(raw_value, value_yes, value_no)
786 | case raw_value
787 | when I18n.t('general_text_yes')
788 | value_yes
789 | when I18n.t('general_text_no')
790 | value_no
791 | end
792 | end
793 |
794 | class RowFailed < RuntimeError
795 | end
796 | end
797 |
--------------------------------------------------------------------------------
/app/helpers/importer_helper.rb:
--------------------------------------------------------------------------------
1 | module ImporterHelper
2 | def matched_attrs(column)
3 | matched = ''
4 | @attrs.each do |k,v|
5 | if v.to_s[/(?<=-).*/].casecmp(column.to_s.sub(" ") {|sp| "_" }) == 0 \
6 | || k.to_s.casecmp(column.to_s) == 0
7 |
8 | matched = v
9 | end
10 | end
11 | matched
12 | end
13 |
14 | def force_utf8(str)
15 | str.unpack("U*").pack('U*')
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/app/models/import_in_progress.rb:
--------------------------------------------------------------------------------
1 | require 'nkf'
2 | class ImportInProgress < ActiveRecord::Base
3 | belongs_to :user
4 | belongs_to :project
5 |
6 | before_save :encode_csv_data
7 |
8 | private
9 | def encode_csv_data
10 | return if self.csv_data.blank?
11 |
12 | self.csv_data = self.csv_data
13 | # 入力文字コード
14 | encode = case self.encoding
15 | when "U"
16 | "-W"
17 | when "EUC"
18 | "-E"
19 | when "S"
20 | "-S"
21 | when "N"
22 | ""
23 | else
24 | ""
25 | end
26 |
27 | self.csv_data = NKF.nkf("#{encode} -w", self.csv_data)
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/app/views/importer/index.html.erb:
--------------------------------------------------------------------------------
1 |
<%=l(:label_issue_importer)%>
2 |
3 | <%= form_tag({:action => 'match'}, {:multipart => true}) do %>
4 | <%= hidden_field_tag 'project_id', @project.id %>
5 |
6 | <%=l(:label_upload_notice)%>
7 | <%= file_field_tag 'file', :size => 60%>
8 |
9 | <%= l(:label_upload_format) %>
10 | <%=l(:label_upload_encoding)%>
11 | <%= select_tag "encoding",
12 | "UTF8 " \
13 | "EUC " \
14 | "SJIS " \
15 | "None ".html_safe %>
16 |
17 | <%=l(:label_upload_splitter)%>
18 | <%= text_field_tag "splitter", ',', {:size => 3, :maxlength => 1}%>
19 |
20 | <%=l(:label_upload_wrapper)%>
21 | <%= text_field_tag "wrapper", '"', {:size => 3, :maxlength => 1}%>
22 |
23 |
24 | <%= submit_tag l(:button_upload) %>
25 | <% end %>
26 |
27 |
--------------------------------------------------------------------------------
/app/views/importer/match.html.erb:
--------------------------------------------------------------------------------
1 | <% content_for :header_tags do %>
2 | <%= stylesheet_link_tag 'importer', :plugin => 'redmine_importer' %>
3 | <% end %>
4 |
5 | <% content_for :update_issue_javascript do %>
6 |
15 | <% end %>
16 |
17 | <%= l(:label_match_columns) %>
18 |
19 | <%= form_tag({:action => 'result'}, {:multipart => true}) do %>
20 | <%= hidden_field_tag 'project_id', @project.id %>
21 | <%= hidden_field_tag 'import_timestamp', @import_timestamp %>
22 |
23 |
24 | <%= l(:label_match_select) %>
25 | <% @headers.each do |column| %>
26 | <% col = force_utf8(column) %>
27 |
28 | <%= col %>:
29 | <%= select_tag "fields_map[#{col}]",
30 | raw("#{l(:option_ignore)} ") +
31 | options_for_select( @attrs, matched_attrs(column) ) %>
32 |
33 | <% end %>
34 |
35 |
36 |
37 | <%= l(:label_import_rule) %>
38 |
39 | <%= l(:label_default_tracker) %>
40 | <%= select_tag "default_tracker",
41 | options_from_collection_for_select(@project.trackers, 'id', 'name') %>
42 |
43 |
44 |
45 | <%= l(:label_unique_field) %>
46 | <%= select_tag "unique_field",
47 | raw("#{l(:option_ignore)} ") +
48 | options_for_select(@headers.map{|header| force_utf8(header)}) %>
49 |
50 |
51 |
52 | <%= check_box_tag "send_emails", true, false %>
53 | <%= l(:label_importer_send_emails) %>
54 |
55 |
56 |
57 | <%= check_box_tag "add_categories", true, true %>
58 | <%= l(:label_importer_add_categories) %>
59 |
60 |
61 |
62 | <%= check_box_tag "add_versions", true, true %>
63 | <%= l(:label_importer_add_versions) %>
64 |
65 |
66 |
67 | <%= check_box_tag "use_anonymous", true, true %>
68 | <%= l(:label_importer_use_anonymous) %>
69 |
70 |
71 |
72 | <%= check_box_tag "use_issue_id", true, false %>
73 | Import using issue ids
74 |
75 |
76 |
77 | <%= check_box_tag "update_issue", true, false %>
78 | <%= l(:label_update_issue) %>
79 |
80 |
81 |
82 | <%= yield :update_issue_javascript %>
83 |
84 |
85 |
86 | <%= l(:label_journal_field) %>
87 | <%= select_tag "journal_field",
88 | raw("#{l(:option_ignore)} ") +
89 | options_for_select(@headers.map{|header| force_utf8(header)}),
90 | {:disabled => true} %>
91 |
92 |
93 |
94 |
95 |
96 | <%= check_box_tag "update_other_project", true, false, {:disabled => true} %>
97 | <%= l(:label_update_other_project) %>
98 |
99 |
100 |
101 |
102 |
103 | <%= check_box_tag "ignore_non_exist", true, false, {:disabled => true} %>
104 | <%= l(:label_ignore_non_exist) %>
105 |
106 |
107 |
108 |
109 | <%= submit_tag l(:button_submit) %>
110 |
111 | <% end %>
112 |
113 |
114 |
115 | <%= l(:label_toplines, @original_filename) %>
116 |
117 |
118 |
119 | <% @headers.each do |column| %>
120 | <%= force_utf8(column) %>
121 | <% end %>
122 |
123 |
124 |
125 | <% @samples.each do |issue| -%>
126 | ">
127 | <% issue.each do |column| %>
128 | <% column[1] = force_utf8(column[1]) if column[1].kind_of?(String) %>
129 | <%= content_tag 'td', column[1] %>
130 | <% end %>
131 |
132 | <% end %>
133 | ">
134 | <% @headers.each do |column| %>... <% end %>
135 |
136 |
137 |
138 |
--------------------------------------------------------------------------------
/app/views/importer/result.html.erb:
--------------------------------------------------------------------------------
1 | <% content_for :header_tags do %>
2 | <%= stylesheet_link_tag 'importer', :plugin => 'redmine_importer' %>
3 | <% end %>
4 |
5 | <%= l(:label_import_result) %>
6 |
7 | <%= l(:label_result_notice,
8 | :handle_count => @handle_count,
9 | :success_count => @handle_count) %>
10 |
11 |
12 | <%= l(:label_result_projects) %>
13 |
14 | <% @affect_projects_issues.each do |project, count|%>
15 | <%= project %>:
16 | <%= l(:label_result_issues, count) %>
17 |
18 | <% end %>
19 |
20 |
21 | <% if not @messages.empty? %>
22 |
23 | <%= l(:label_result_messages) %>
24 |
25 | <% @messages.each do |m| %>
26 | <%= m %>
27 | <% end %>
28 |
29 | <% end %>
30 |
31 |
32 | <% if @failed_count > 0 %>
33 | <%= l(:label_result_failed, @failed_count) %>
34 |
35 |
36 |
37 | #
38 | <% @headers.each do |column| %>
39 | <%= column.unpack('U*').pack('U*') %>
40 | <% end %>
41 |
42 |
43 |
44 | <% @failed_issues.each do |id, issue| -%>
45 | ">
46 | <%= id %>
47 | <% issue.each do |column| %>
48 | <%- data = column[1] -%>
49 | <%- data = data.unpack('U*').pack('U*') if data.is_a?(String) -%>
50 | <%= content_tag 'td', data %>
51 | <% end %>
52 |
53 | <% end %>
54 |
55 |
56 | <% end %>
57 |
58 |
59 |
--------------------------------------------------------------------------------
/assets/stylesheets/importer.css:
--------------------------------------------------------------------------------
1 | label.tabular{
2 | text-align: right;
3 | width: 270px;
4 | display: inline-block;
5 | }
6 |
7 | label.tabular2{
8 | text-align: right;
9 | width: 100px;
10 | display: inline-block;
11 | }
12 |
--------------------------------------------------------------------------------
/config/locales/de.yml:
--------------------------------------------------------------------------------
1 | de:
2 | label_import: "Ticket Import"
3 | label_issue_importer: "Ticket Import"
4 | label_upload_notice: "Bitte wählen Sie die zu importierende CSV-Datei aus. Die Datei muss eine Überschriftenzeile haben. Die maximale Grösse der Datei ist 4MB."
5 | label_upload_format: "Dateiformateinstellungen"
6 | label_upload_encoding: "Encoding:"
7 | label_upload_splitter: "Trennzeichen:"
8 | label_upload_wrapper: "Felder eingschlossen von:"
9 |
10 | label_load_rules: "Lade gespeicherte Regeln"
11 | label_toplines: "Bezug auf den Spaltenkopf von %{value}~Z"
12 | label_match_columns: "Zuordnung der Spalten"
13 | label_match_select: "Auswahl passender Felder"
14 | label_import_rule: "Import-Regeln"
15 | label_default_tracker: "Standard Tracker:"
16 | label_importer_send_emails: "Schicke Benachrichtiungs-E-Mails"
17 | label_importer_add_categories: "Kategorien automatisch hinzufügen"
18 | label_importer_add_versions: "Ziel-Versionen automatisch hinzufügen"
19 | label_update_issue: "Aktualisiere vorhandene Tickets"
20 | label_journal_field: "Spalte als Notiz auswählen:"
21 | label_unique_field: "Wähle ID-Spalte aus (wird fü das Updaten von bestehenden Tickets verwendet bzw. für Relationen):"
22 | label_update_other_project: "Erlaube Aktualisierung von Tickets von anderen Projekten"
23 | label_ignore_non_exist: "Ignoriere nicht existierende Tickets"
24 | label_rule_name: "Name der Input-Regel"
25 |
26 | label_import_result: "Import Ergebnis"
27 | label_result_notice: "%{handle_count} Tickets verarbeitet. {{success_count}} Tickets erfolgreich importiert."
28 | label_result_projects: "Betroffene Projekte:"
29 | label_result_issues: "%{count} Tickets"
30 | label_result_failed: "%{count} fehlgeschlagene Reihen:"
31 |
32 | option_ignore: "Ignorieren"
33 |
34 | button_upload: "Datei hochladen"
35 | button_submit: "Abschicken"
36 | button_save_rules_and_submit: "Abschicken und Abgleich-Regel speichern"
37 |
38 | text_rmi_specify_unique_field_for_update: "Ein eindeutiges Feld muss definiert sein, da die Einstellung für das Update von bestehenden Tickets gesetzt ist."
39 | text_rmi_specify_unique_field_for_column: "Ein eindeutiges Feld muss definiert sein, da die Spalte %{column} auf andere Tasks zeigen muss."
40 |
--------------------------------------------------------------------------------
/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | en:
2 | label_import: "Import"
3 | label_issue_importer: "Issue Importer"
4 | label_upload_notice: "Select a CSV file to import. Import file must have a header row. Maximum size 4MB."
5 | label_upload_format: "File format settings"
6 | label_upload_encoding: "Encoding:"
7 | label_upload_splitter: "Field separator character:"
8 | label_upload_wrapper: "Field quoting character:"
9 |
10 | label_load_rules: "Load saved rules"
11 | label_toplines: "Refer to top lines of %{value}:"
12 | label_match_columns: "Matching Columns"
13 | label_match_select: "Select field to set from each column:"
14 | label_import_rule: "Import rules"
15 | label_default_tracker: "Default tracker:"
16 | label_importer_send_emails: "Send notification emails"
17 | label_importer_add_categories: "Auto-add categories"
18 | label_importer_add_versions: "Auto-add target versions"
19 | label_importer_use_anonymous: "Substitute unknown users with the Anonymous user"
20 | label_update_issue: "Update existing issues"
21 | label_journal_field: "Select column to use as note:"
22 | label_unique_field: "Select unique-valued column (required for updating existing issues or importing relations):"
23 | label_update_other_project: "Allow updating issues from other projects"
24 | label_ignore_non_exist: "Ignore non-existant issues"
25 | label_rule_name: "Input rule name"
26 |
27 | label_import_result: "Import Result"
28 | label_result_notice: "%{handle_count} issues processed. %{success_count} issues successfully imported."
29 | label_result_projects: "Affected projects:"
30 | label_result_issues: "%{count} issues"
31 | label_result_failed: "Failed rows (%{count})"
32 | label_result_messages: "Messages"
33 |
34 | option_ignore: "Ignore"
35 |
36 | button_upload: "Upload File"
37 | button_submit: "Submit"
38 | button_save_rules_and_submit: "Save match rules and submit"
39 |
40 | text_rmi_specify_unique_field_for_update: "Unique field must be specified because Update existing issues is on"
41 | text_rmi_specify_unique_field_for_column: "Unique field must be specified because the column %{column} needs to refer to other tasks"
42 | flash_csv_file_is_blank: "CSV file is blank."
43 |
44 |
--------------------------------------------------------------------------------
/config/locales/ja.yml:
--------------------------------------------------------------------------------
1 | ja:
2 | label_import: "インポート"
3 | label_issue_importer: "チケットのインポート"
4 | label_upload_notice: "CSV ファイルを選択してください。ヘッダー行が必要です。最大サイズは 4MB です。"
5 | label_upload_format: "ファイルフォーマットのオプション"
6 | label_upload_encoding: "エンコーディング:"
7 | label_upload_splitter: "フィールドの区切り文字:"
8 | label_upload_wrapper: "引用文字:"
9 |
10 | label_load_rules: "保存されたルールを読み込む"
11 | label_toplines: "%{value} の先頭行:"
12 | label_match_columns: "各列のフィールドとの対応"
13 | label_match_select: "各列が対応するフィールドを選択:"
14 | label_import_rule: "インポートのルール"
15 | label_default_tracker: "デフォルトのトラッカー:"
16 | label_update_issue: "存在するチケットを更新:"
17 | label_journal_field: "メモとして入るフィールドを選択:"
18 | label_importer_send_emails: "メールを送信する"
19 | label_importer_add_categories: "新しいカテゴリを自動的に追加"
20 | label_importer_add_versions: "新しい対象バージョンを自動的に追加"
21 | label_importer_use_anonymous: "未登録のユーザーは匿名ユーザーに置き換える"
22 | label_unique_field: "ユニークフィールドを選択(存在するチケットを更新する場合や、関連するチケットをインポートする場合に必須):"
23 | label_update_other_project: "他のプロジェクトのチケットでも更新"
24 | label_ignore_non_exist: "チケットが存在しない場合は無視"
25 | label_rule_name: "ルール名を入力:"
26 |
27 | label_import_result: "インポート結果"
28 | label_result_notice: "%{handle_count} 行が処理されました。%{success_count} 行が正しく追加されました。"
29 | label_result_projects: "対象となったプロジェクト:"
30 | label_result_issues: "%{count} 行"
31 | label_result_failed: "失敗した行数: %{count}"
32 | label_result_messages: "メッセージ"
33 |
34 | option_ignore: "無視"
35 |
36 | button_upload: "ファイルをアップロード"
37 | button_submit: "確認"
38 | button_save_rules_and_submit: "対応ルールを保存して確認"
39 |
40 | text_rmi_specify_unique_field_for_update: "存在するチケットを更新する場合、ユニークフィールドを選択する必要があります。"
41 | text_rmi_specify_unique_field_for_column: "「%{column}」という他のチケットと関連する列があるため、ユニークフィールドを選択する必要があります。"
42 |
43 | flash_csv_file_is_blank: "CSVファイルが指定されていません。"
44 |
--------------------------------------------------------------------------------
/config/locales/pt-BR.yml:
--------------------------------------------------------------------------------
1 | pt-BR:
2 | label_import: "Importar"
3 | label_issue_importer: "Importador de Tarefas"
4 | label_upload_notice: "Selecione um arquivo CSV para importar. Arquivo de importação deve ter uma linha de cabeçalho. Tamanho máximo 4MB."
5 | label_upload_format: "Configurações do formato do arquivo"
6 | label_upload_encoding: "Encoding:"
7 | label_upload_splitter: "Caracter de separação entre campos:"
8 | label_upload_wrapper: "Caracter em volta do nome:"
9 |
10 | label_load_rules: "Carregar regras salvas"
11 | label_toplines: "Refere-se as linhas superiores de {{value}}:"
12 | label_match_columns: "Mapeamento de Colunas"
13 | label_match_select: "Selecione o campo para cada coluna:"
14 | label_import_rule: "Regras de Importação"
15 | label_default_tracker: "Tipo de Tarefa padrão:"
16 | label_importer_send_emails: "Enviar emails de notificação"
17 | label_importer_add_categories: "Adicionar categorias automaticamente"
18 | label_importer_add_versions: "Adicionar versões automaticamente"
19 | label_update_issue: "Atualizar tarefas existentes"
20 | label_journal_field: "Selecione coluna para usar como nota:"
21 | label_unique_field: "Selecione coluna de valor único (obrigatório para atualiza tarefas existentes ou importar relacionamentos):"
22 | label_update_other_project: "Permitir atualizar tarefas de outros projetos"
23 | label_ignore_non_exist: "Ignorar tarefas não existentes"
24 | label_rule_name: "Entre com o nome da regra"
25 |
26 | label_import_result: "Resultado da Importação"
27 | label_result_notice: "{{handle_count}} tarefas processadas. {{success_count}} tarefas importadas com sucesso."
28 | label_result_projects: "Projetos afetados:"
29 | label_result_issues: "{{count}} tarefas"
30 | label_result_failed: "{{count}} linhas falharam:"
31 |
32 | option_ignore: "Ignorar"
33 |
34 | button_upload: "Enviar arquivo"
35 | button_submit: "Enviar"
36 | button_save_rules_and_submit: "Salvar regra de mapeamento e enviar"
37 |
--------------------------------------------------------------------------------
/config/locales/ru.yml:
--------------------------------------------------------------------------------
1 | ru:
2 | label_import: "Импорт"
3 | label_issue_importer: "Импорт задач"
4 | label_upload_notice: "Выберите CSV файл для импорта. Импортируемый файл должен иметь заголовки колонок. Максимальный размер - 4MB."
5 | label_upload_format: "Настройки формата файла"
6 | label_upload_encoding: "Кодировка:"
7 | label_upload_splitter: "Разделитель:"
8 | label_upload_wrapper: "Формат кавычек:"
9 |
10 | label_load_rules: "Загрузить сохраненные правила"
11 | label_toplines: "Refer to top lines of %{value}:"
12 | label_match_columns: "Привязка колонок"
13 | label_match_select: "Задайте соответствие полей:"
14 | label_import_rule: "Правила импортра"
15 | label_default_tracker: "Трекер по умолчанию:"
16 | label_importer_send_emails: "Уведомить участников по e-mail"
17 | label_importer_add_categories: "Создать несуществующие категории"
18 | label_importer_add_versions: "Создать несуществующие версии"
19 | label_update_issue: "Обновить существующие задачи при совпадении"
20 | label_journal_field: "Выберите колонку используемую как описание:"
21 | label_unique_field: "Выберите колонку с уникальными значениями (необходимо для обновления существующих задач или импорта их отношений):"
22 | label_update_other_project: "Разрешить обновление задач других проектов"
23 | label_ignore_non_exist: "Игнорировать несуществующие задачи"
24 | label_rule_name: "Input rule name"
25 |
26 | label_import_result: "Результат импорта"
27 | label_result_notice: "%{handle_count} задач(а) обработано. %{success_count} задач(а) была успешно импортирована."
28 | label_result_projects: "Затронуто проектов:"
29 | label_result_issues: "%{count} задач(а)"
30 | label_result_failed: "Пропущено %{count} строк(а):"
31 |
32 | option_ignore: "Игнорировать"
33 |
34 | button_upload: "Загрузить файл"
35 | button_submit: "Импортировать"
36 | button_save_rules_and_submit: "Save match rules and submit"
37 |
38 | text_rmi_specify_unique_field_for_update: "Колонка с уникальными значениями должна быть определена, так как включен режим обновления существующих задач включен"
39 | text_rmi_specify_unique_field_for_column: "Unique field must be specified because the column %{column} needs to refer to other tasks"
--------------------------------------------------------------------------------
/config/locales/zh.yml:
--------------------------------------------------------------------------------
1 | zh:
2 | label_import: "导入"
3 | label_issue_importer: "问题列表导入工具"
4 | label_upload_notice: "请选择需要上传的问题列表CSV文件。文件必须具有标题行。"
5 | label_upload_format: "文件格式设置"
6 | label_upload_encoding: "编码:"
7 | label_upload_splitter: "字段分隔符:"
8 | label_upload_wrapper: "字段包裹符:"
9 |
10 | label_load_rules: "载入已保存的匹配规则"
11 | label_toplines: "参考文件 {{value}} 的头几行:"
12 | label_match_columns: "字段配对"
13 | label_match_select: "选择对应的字段"
14 | label_import_rule: "导入规则"
15 | label_default_tracker: "默认跟踪:"
16 | label_importer_send_emails: "发送提醒邮件"
17 | label_importer_add_categories: "自动新增问题类别"
18 | label_importer_add_versions: "自动新增目标版本"
19 | label_importer_add_enumerations: "Auto-add Key/value"
20 | label_update_issue: "更新已存在的问题"
21 | label_journal_field: "选择用于日志的字段:"
22 | label_unique_field: "选择用于标识问题的唯一字段:"
23 | label_update_other_project: "允许更新其他项目的问题"
24 | label_ignore_non_exist: "忽略不存在的问题"
25 | label_rule_name: "输入规则名称"
26 |
27 | label_import_result: "导入结果"
28 | label_result_notice: "处理了{{handle_count}}个问题。{{success_count}}个问题被成功导入。"
29 | label_result_projects: "受影响的项目:"
30 | label_result_issues: "{{count}} 个问题"
31 | label_result_failed: "{{count}} 条失败的行:"
32 |
33 | option_ignore: "忽略"
34 |
35 | button_upload: "上传文件"
36 | button_submit: "提交"
37 | button_save_rules_and_submit: "存储匹配规则后提交"
38 |
39 | text_rmi_specify_unique_field_for_update: "更新现有问题时,必须填写必填字段内容!"
40 | text_rmi_specify_unique_field_for_column: "【{{column}}】列必须填写内容,或在导入时,将【{{column}}】列设置为忽略!"
41 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | resources :projects do
2 | get '/importer/', to: 'importer#index'
3 | post '/importer/', to: 'importer#index'
4 | get '/importer/match', to: 'importer#match'
5 | post '/importer/match', to: 'importer#match'
6 | get '/importer/result', to: 'importer#result'
7 | post '/importer/result', to: 'importer#result'
8 | end
9 |
--------------------------------------------------------------------------------
/db/migrate/001_create_import_in_progresses.rb:
--------------------------------------------------------------------------------
1 | class CreateImportInProgresses < ActiveRecord::Migration[4.2]
2 | def self.up
3 | create_table :import_in_progresses do |t|
4 | t.column :user_id, :integer, :null => false
5 | t.string :quote_char, :limit => 8
6 | t.string :col_sep, :limit => 8
7 | t.string :encoding, :limit => 64
8 | t.column :created, :datetime
9 | t.column :csv_data, :binary, :limit => 4096*1024
10 | end
11 | end
12 |
13 | def self.down
14 | drop_table :import_in_progresses
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/init.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'redmine'
4 |
5 | Redmine::Plugin.register :redmine_importer do
6 | name 'Issue Importer'
7 | author 'Martin Liu / Leo Hourvitz / Stoyan Zhekov / Jérôme Bataille / Agileware Inc.'
8 | description 'Issue import plugin for Redmine.'
9 | version '2.0'
10 |
11 | project_module :importer do
12 | permission :import, importer: :index
13 | end
14 | menu :project_menu,
15 | :importer,
16 | { controller: 'importer', action: 'index' },
17 | caption: :label_import,
18 | before: :settings,
19 | param: :project_id
20 | end
21 |
--------------------------------------------------------------------------------
/lib/redmine_importer/concerns/validate_status.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module RedmineImporter
4 | module Concerns
5 | module ValidateStatus
6 | extend ActiveSupport::Concern
7 |
8 | included do
9 | validate do
10 | if status.is_closed? && descendants.open.exists?
11 | # NOTE: prefer an appropriate error
12 | errors.add(:status_id, :inclusion)
13 | end
14 |
15 | if !status.is_closed? && ancestors.joins(:status).merge(IssueStatus.where(is_closed: true)).exists?
16 | # NOTE: prefer an appropriate error
17 | errors.add(:status_id, :inclusion)
18 | end
19 | end
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/test/fixtures/import_in_progresses.yml:
--------------------------------------------------------------------------------
1 | # Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html
2 | one:
3 | id: 1
4 | csv_data:
5 | user:
6 | created: 2010-10-09 20:40:03
7 | two:
8 | id: 2
9 | csv_data:
10 | user:
11 | created: 2010-10-09 20:40:03
12 |
--------------------------------------------------------------------------------
/test/functional/importer_controller_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require File.expand_path('../test_helper', __dir__)
4 |
5 | class ImporterControllerTest < ActionController::TestCase
6 | include ActiveJob::TestHelper
7 |
8 | def setup
9 | ActionController::Base.allow_forgery_protection = false
10 | @project = Project.create! name: 'foo', identifier: 'importer_controller_test'
11 | @tracker = Tracker.new(name: 'Defect')
12 | @tracker.default_status = IssueStatus.find_or_create_by!(name: 'New')
13 | @tracker.save!
14 | @project.trackers << @tracker
15 | @project.save!
16 | @role = Role.create! name: 'ADMIN', permissions: %i[import view_issues]
17 | @user = create_user!(@role, @project)
18 | @iip = create_iip_for_multivalues!(@user, @project)
19 | @issue = create_issue!(@project, @user, { id: 70_385, tracker: @tracker })
20 | create_custom_fields!(@issue)
21 | create_versions!(@project)
22 | User.stubs(:current).returns(@user)
23 | end
24 |
25 | test 'should handle multiple values for versions' do
26 | assert issue_has_none_of_these_multival_versions?(@issue,
27 | %w[Admin 2013-09-25])
28 | post :result, params: build_params(update_issue: 'true')
29 | assert_response :success
30 | @issue.reload
31 | assert issue_has_all_these_multival_versions?(@issue, %w[Admin 2013-09-25])
32 | end
33 |
34 | test 'should handle multiple values' do
35 | assert issue_has_none_of_these_multifield_vals?(@issue, %w[tag1 tag2])
36 | post :result, params: build_params(update_issue: 'true')
37 | assert_response :success
38 | @issue.reload
39 | assert issue_has_all_these_multifield_vals?(@issue, %w[tag1 tag2])
40 | end
41 |
42 | test 'should handle single-value fields' do
43 | assert_equal 'foobar', @issue.subject
44 | post :result, params: build_params(update_issue: 'true')
45 | assert_response :success
46 | @issue.reload
47 | assert_equal 'barfooz', @issue.subject
48 | assert_equal @user.today, @issue.start_date
49 | end
50 |
51 | test 'should create issue if none exists' do
52 | Mailer.expects(:deliver_issue_add).never
53 | Issue.delete_all
54 | assert_equal 0, Issue.count
55 | post :result, params: build_params
56 | assert_response :success
57 | assert_equal 1, Issue.count
58 | issue = Issue.first
59 | assert_equal 'barfooz', issue.subject
60 | end
61 |
62 | test 'should send email when Send email notifications checkbox is checked and issue updated' do
63 | assert_equal 'foobar', @issue.subject
64 | Mailer.expects(:deliver_issue_edit)
65 |
66 | post :result, params: build_params(update_issue: 'true', send_emails: 'true')
67 | assert_response :success
68 | @issue.reload
69 | assert_equal 'barfooz', @issue.subject
70 | end
71 |
72 | test 'should send email when Send email notifications checkbox is checked and issue added' do
73 | assert_equal 'foobar', @issue.subject
74 | Mailer.expects(:deliver_issue_add)
75 |
76 | assert_equal 0, Issue.where(subject: 'barfooz').count
77 | post :result, params: build_params(send_emails: 'true')
78 | assert_response :success
79 | assert_equal 1, Issue.where(subject: 'barfooz').count
80 | end
81 |
82 | test 'should NOT send email when Send email notifications checkbox is unchecked' do
83 | assert_equal 'foobar', @issue.subject
84 | Mailer.expects(:deliver_issue_edit).never
85 |
86 | post :result, params: build_params(update_issue: 'true')
87 | assert_response :success
88 | @issue.reload
89 | assert_equal 'barfooz', @issue.subject
90 | end
91 |
92 | test 'should add watchers' do
93 | assert issue_has_none_of_these_watchers?(@issue, [@user])
94 | post :result, params: build_params(update_issue: 'true')
95 | assert_response :success
96 | @issue.reload
97 | assert issue_has_all_of_these_watchers?(@issue, [@user])
98 | end
99 |
100 | test 'should handle key value list value' do
101 | Mailer.expects(:deliver_issue_add).never
102 | IssueCustomField.where(name: 'Area').each { |icf| icf.update(multiple: false) }
103 | @iip.destroy
104 | @iip = create_iip!('KeyValueList', @user, @project)
105 | post :result, params: build_params
106 | assert_response :success
107 | assert keyval_vals_for(Issue.find_by!(subject: 'パンケーキ')) == ['Tokyo']
108 | assert keyval_vals_for(Issue.find_by!(subject: 'たこ焼き')) == ['Osaka']
109 | assert Issue.find_by(subject: 'サーターアンダギー').nil?
110 | end
111 |
112 | test 'should handle multiple key value list values' do
113 | Mailer.expects(:deliver_issue_add).never
114 | @iip.destroy
115 | @iip = create_iip!('KeyValueListMultiple', @user, @project)
116 | post :result, params: build_params
117 | assert_response :success
118 | assert keyval_vals_for(Issue.find_by!(subject: 'パンケーキ')) == ['Tokyo']
119 | assert keyval_vals_for(Issue.find_by!(subject: 'たこ焼き')) == ['Osaka']
120 | issue = Issue.find_by!(subject: 'タピオカ')
121 | assert(%w[Tokyo Osaka].all? { |area| area.in?(keyval_vals_for(Issue.find_by!(subject: 'タピオカ'))) })
122 | assert Issue.find_by(subject: 'サーターアンダギー').nil?
123 | end
124 |
125 | test 'should handle issue relation' do
126 | other_issue = create_issue!(@project, @user, { subject: 'other_issue' })
127 | @iip.update!(csv_data: "#,Subject,Duplicated issue ID\n#{@issue.id},set other issue relation,#{other_issue.id}\n")
128 | post :result, params: build_params(update_issue: 'true').tap { |params|
129 | params[:fields_map]['Duplicated issue ID'] = "issue_relation-#{IssueRelation::TYPE_DUPLICATED}"
130 | }
131 | assert_response :success
132 | @issue.reload
133 | assert_equal 'set other issue relation', @issue.subject
134 | issue_relation = @issue.relations_to.first!
135 | assert_equal other_issue, issue_relation.issue_from
136 | assert_equal IssueRelation::TYPE_DUPLICATES, issue_relation.relation_type
137 | assert_equal 1, @issue.relations_to.count
138 | end
139 |
140 | test 'should error when assigned_to is missing' do
141 | @iip.update!(csv_data: "#,Subject,assigned_to\n#{@issue.id},barfooz,JohnDoe\n")
142 | @issue.reload.update!(assigned_to: @user)
143 | post :result, params: build_params(update_issue: 'true').tap { |params|
144 | params[:fields_map]['assigned_to'] = 'standard_field-assigned_to'
145 | }
146 | assert_response :success
147 | assert response.body.include?('Warning')
148 | @issue.reload
149 | assert_equal 'foobar', @issue.subject
150 | assert_equal @user, @issue.assigned_to
151 | end
152 |
153 | test 'should unset assigned_to when assigned_to user is not assignable' do
154 | User.create!(login: 'john', firstname: 'John', lastname: 'Doe', mail: 'john.doe@example.com')
155 | @iip.update!(csv_data: "#,Subject,assigned_to\n#{@issue.id},barfooz,john\n")
156 | post :result, params: build_params(update_issue: 'true').tap { |params|
157 | params[:fields_map]['assigned_to'] = 'standard_field-assigned_to'
158 | }
159 | assert_response :success
160 | assert !response.body.include?('Warning')
161 | @issue.reload
162 | assert_equal 'barfooz', @issue.subject
163 | assert_nil @issue.assigned_to
164 | end
165 |
166 | test 'should error when user type CF value is missing' do
167 | assigned_by_field = create_multivalue_field!('assigned_by', 'user', @issue.project)
168 | @tracker.custom_fields << assigned_by_field
169 | @issue.reload
170 | @issue.custom_field_values.detect { |cfv| cfv.custom_field == assigned_by_field }.value = @user
171 | @iip.update!(csv_data: "#,Subject,assigned_by\n#{@issue.id},barfooz,JeanDoe\n")
172 | @issue.update!(assigned_to: @user)
173 | post :result, params: build_params(update_issue: 'true').tap { |params|
174 | params[:fields_map]['assigned_by'] = 'standard_field-assigned_by'
175 | }
176 | assert_response :success
177 | assert response.body.include?('Warning')
178 | @issue.reload
179 | assert_equal 'foobar', @issue.subject
180 | assert_equal @user.name, @issue.custom_value_for(assigned_by_field).value
181 | end
182 |
183 | test 'should not error when assigned_to is missing but use_anonymous is true' do
184 | @iip.update!(csv_data: "#,Subject,assigned_to\n#{@issue.id},barfooz,JohnDoe\n")
185 | @issue.reload.update!(assigned_to: @user)
186 | post :result, params: build_params(update_issue: 'true', use_anonymous: 'true').tap { |params|
187 | params[:fields_map]['assigned_to'] = 'standard_field-assigned_to'
188 | }
189 | assert_response :success
190 | assert !response.body.include?('Warning')
191 | @issue.reload
192 | assert_equal 'barfooz', @issue.subject
193 | assert_nil @issue.assigned_to
194 | end
195 |
196 | test 'should not error when user type CF value is missing but use_anonymous is true' do
197 | assigned_by_field = create_multivalue_field!('assigned_by', 'user', @issue.project)
198 | @tracker.custom_fields << assigned_by_field
199 | @issue.reload
200 | @issue.custom_field_values.detect { |cfv| cfv.custom_field == assigned_by_field }.value = @user
201 | @iip.update!(csv_data: "#,Subject,assigned_by\n#{@issue.id},barfooz,JeanDoe\n")
202 | @issue.update!(assigned_to: @user)
203 | post :result, params: build_params(update_issue: 'true', use_anonymous: 'true').tap { |params|
204 | params[:fields_map]['assigned_by'] = 'custom_field-assigned_by'
205 | }
206 | assert_response :success
207 | assert !response.body.include?('Warning')
208 | @issue.reload
209 | assert_equal 'barfooz', @issue.subject
210 | assert_equal '', @issue.custom_value_for(assigned_by_field).value
211 | end
212 |
213 | test 'should not error when user type CF value is not listed in possible values' do
214 | User.create!(login: 'john', firstname: 'John', lastname: 'Doe', mail: 'john.doe@example.com')
215 | assigned_by_field = create_multivalue_field!('assigned_by', 'user', @issue.project)
216 | @tracker.custom_fields << assigned_by_field
217 | @issue.reload
218 | @issue.custom_field_values.detect { |cfv| cfv.custom_field == assigned_by_field }.value = @user
219 | @iip.update!(csv_data: "#,Subject,assigned_by\n#{@issue.id},barfooz,john\n")
220 | @issue.update!(assigned_to: @user)
221 | post :result, params: build_params(update_issue: 'true', use_anonymous: 'true').tap { |params|
222 | params[:fields_map]['assigned_by'] = 'custom_field-assigned_by'
223 | }
224 | assert_response :success
225 | assert !response.body.include?('Warning')
226 | @issue.reload
227 | assert_equal 'barfooz', @issue.subject
228 | assert_equal '', @issue.custom_value_for(assigned_by_field).value
229 | end
230 |
231 | test 'should reset pk sequence' do
232 | return unless ActiveRecord::Base.connection.respond_to?(:set_pk_sequence!)
233 | return unless ActiveRecord::Base.connection.respond_to?(:reset_pk_sequence!)
234 |
235 | ActiveRecord::Base.connection.set_pk_sequence!('issues', 4422)
236 |
237 | @iip.update!(csv_data: "#,Subject,Tracker,Priority\n4423,test,Defect,Critical\n")
238 | post :result, params: build_params(use_issue_id: '1')
239 | assert_response :success
240 | assert !response.body.include?('Warning')
241 |
242 | issue = Issue.new
243 | issue.project = @project
244 | issue.subject = 'foobar'
245 | issue.priority = IssuePriority.find_by!(name: 'Critical')
246 | issue.tracker = @project.trackers.first
247 | issue.author = @user
248 | issue.status = IssueStatus.find_by!(name: 'New')
249 | issue.save!
250 | end
251 |
252 | test "should NOT change an open issue's parent to an closed issue" do
253 | closed_status = IssueStatus.find_or_create_by!(name: 'Closed', is_closed: true)
254 | parent = create_issue!(@project, @user, status: closed_status)
255 | @iip.update!(csv_data: "#,Parent\n#{@issue.id},#{parent.id}\n")
256 | post :result, params: build_params(update_issue: 'true')
257 | assert_response :success
258 | assert response.body.include?('Error')
259 | assert_nil @issue.reload.parent
260 | end
261 |
262 | test 'should NOT close an issue having open children' do
263 | @child = create_issue!(@project, @user, parent_id: @issue.id)
264 | assert @issue.children.include?(@child)
265 | assert !@issue.status.is_closed?
266 | assert !@child.status.is_closed?
267 | IssueStatus.find_or_create_by!(name: 'Closed', is_closed: true)
268 | @iip.update!(csv_data: "#,Status\n#{@issue.id},Closed\n")
269 | post :result, params: build_params(update_issue: 'true')
270 | assert_response :success
271 | assert response.body.include?('Error')
272 | assert !@issue.reload.status.is_closed?
273 | end
274 |
275 | test 'should NOT reopen an issue having closed parent' do
276 | closed_status = IssueStatus.find_or_create_by!(name: 'Closed', is_closed: true)
277 | new_issue = create_issue!(@project, @user, status: closed_status)
278 | @issue.reload.update!(status: closed_status, parent_id: new_issue.id)
279 | @iip.update!(csv_data: "#,Status\n#{@issue.id},New\n")
280 | post :result, params: build_params(update_issue: 'true')
281 | assert_response :success
282 | assert response.body.include?('Error')
283 | assert @issue.reload.status.is_closed?
284 | end
285 |
286 | protected
287 |
288 | def build_params(opts = {})
289 | @iip.reload
290 | opts.reverse_merge(
291 | import_timestamp: @iip.created.strftime('%Y-%m-%d %H:%M:%S'),
292 | unique_field: '#',
293 | project_id: @project.id,
294 | fields_map: {
295 | '#' => 'standard_field-id',
296 | 'Subject' => 'standard_field-subject',
297 | 'Tags' => 'custom_field-Tags',
298 | 'Affected versions' => 'custom_field-Affected versions',
299 | 'Priority' => 'standard_field-priority',
300 | 'Tracker' => 'standard_field-tracker',
301 | 'Status' => 'standard_field-status',
302 | 'Watchers' => 'standard_field-watchers',
303 | 'Parent' => 'standard_field-parent_issue',
304 | 'Area' => 'custom_field-Area'
305 | }
306 | )
307 | end
308 |
309 | def issue_has_all_these_multival_versions?(issue, version_names)
310 | find_version_ids(version_names).all? do |version_to_find|
311 | versions_for(issue).include?(version_to_find)
312 | end
313 | end
314 |
315 | def issue_has_none_of_these_multival_versions?(issue, version_names)
316 | find_version_ids(version_names).none? do |version_to_find|
317 | versions_for(issue).include?(version_to_find)
318 | end
319 | end
320 |
321 | def issue_has_none_of_these_watchers?(issue, watchers)
322 | watchers.none? do |watcher|
323 | issue.watcher_users.include?(watcher)
324 | end
325 | end
326 |
327 | def issue_has_all_of_these_watchers?(issue, watchers)
328 | watchers.all? do |watcher|
329 | issue.watcher_users.include?(watcher)
330 | end
331 | end
332 |
333 | def find_version_ids(version_names)
334 | version_names.map do |name|
335 | Version.find_by_name!(name).id.to_s
336 | end
337 | end
338 |
339 | def versions_for(issue)
340 | versions_field = CustomField.find_by_name! 'Affected versions'
341 | value_objs = issue.custom_values.where(custom_field_id: versions_field.id)
342 | values = value_objs.map(&:value)
343 | end
344 |
345 | def issue_has_all_these_multifield_vals?(issue, vals_to_find)
346 | vals_to_find.all? do |val_to_find|
347 | multifield_vals_for(issue).include?(val_to_find)
348 | end
349 | end
350 |
351 | def issue_has_none_of_these_multifield_vals?(issue, vals_to_find)
352 | vals_to_find.none? do |val_to_find|
353 | multifield_vals_for(issue).include?(val_to_find)
354 | end
355 | end
356 |
357 | def multifield_vals_for(issue)
358 | multival_field = CustomField.find_by_name! 'Tags'
359 | value_objs = issue.custom_values.where(custom_field_id: multival_field.id)
360 | values = value_objs.map(&:value)
361 | end
362 |
363 | def keyval_vals_for(issue)
364 | keyval_field = CustomField.find_by_name! 'Area'
365 | value_objs = issue.custom_values.where(custom_field_id: keyval_field.id)
366 | value_objs.map { |value_obj| keyval_field.enumerations.find(value_obj.value).name }
367 | end
368 |
369 | def create_user!(role, project)
370 | user = User.new admin: true,
371 | login: 'bob',
372 | firstname: 'Bob',
373 | lastname: 'Loblaw',
374 | mail: 'bob.loblaw@example.com'
375 | user.login = 'bob'
376 | sponsor = User.new admin: true,
377 | firstname: 'A',
378 | lastname: 'H',
379 | mail: 'a@example.com'
380 | sponsor.login = 'alice'
381 |
382 | membership = user.memberships.build(project: project)
383 | membership.roles << role
384 | membership.principal = user
385 |
386 | membership = sponsor.memberships.build(project: project)
387 | membership.roles << role
388 | membership.principal = sponsor
389 | sponsor.save!
390 | user.pref.auto_watch_on = nil if Redmine::VERSION.to_s.to_f >= 5.1
391 | user.save!
392 | user
393 | end
394 |
395 | def create_iip_for_multivalues!(user, project)
396 | create_iip!('CustomFieldMultiValues', user, project)
397 | end
398 |
399 | def create_iip!(filename, user, _project)
400 | iip = ImportInProgress.new
401 | iip.user = user
402 | iip.csv_data = get_csv(filename)
403 | # iip.created = DateTime.new(2001,2,3,4,5,6,'+7')
404 | iip.created = DateTime.now
405 | iip.encoding = 'UTF-8'
406 | iip.col_sep = ','
407 | iip.quote_char = '"'
408 | iip.save!
409 | iip
410 | end
411 |
412 | def create_issue!(project, author, options = {})
413 | issue = Issue.new
414 | issue.id = options[:id]
415 | issue.parent_id = options[:parent_id]
416 | issue.project = project
417 | issue.subject = options[:subject] || 'foobar'
418 | issue.priority = IssuePriority.find_or_create_by!(name: 'Critical')
419 | issue.tracker = options[:tracker] || project.trackers.first
420 | issue.author = author
421 | issue.status = options[:status] || IssueStatus.find_or_create_by!(name: 'New')
422 | issue.start_date = author.today
423 | issue.save!
424 | issue
425 | end
426 |
427 | def create_custom_fields!(issue)
428 | versions_field = create_multivalue_field!('Affected versions',
429 | 'version',
430 | issue.project)
431 | multival_field = create_multivalue_field!('Tags',
432 | 'list',
433 | issue.project,
434 | %w[tag1 tag2])
435 | keyval_field = create_enumeration_field!('Area',
436 | issue.project,
437 | %w[Tokyo Osaka])
438 | issue.tracker.custom_fields << versions_field
439 | issue.tracker.custom_fields << multival_field
440 | issue.tracker.custom_fields << keyval_field
441 | issue.tracker.save!
442 | end
443 |
444 | def create_multivalue_field!(name, format, project, possible_vals = [])
445 | field = IssueCustomField.new name: name, multiple: true
446 | field.field_format = format
447 | field.projects << project
448 | field.possible_values = possible_vals if possible_vals
449 | field.save!
450 | field
451 | end
452 |
453 | def create_enumeration_field!(name, project, enumerations)
454 | field = IssueCustomField.new name: name, multiple: true, field_format: 'enumeration'
455 | field.projects << project
456 | field.save!
457 | enumerations.each.with_index(1) do |name, position|
458 | CustomFieldEnumeration.create!(name: name, custom_field_id: field.id, active: true, position: position)
459 | end
460 | field
461 | end
462 |
463 | def create_versions!(project)
464 | project.versions.create! name: 'Admin', status: 'open'
465 | project.versions.create! name: '2013-09-25', status: 'open'
466 | end
467 |
468 | def get_csv(filename)
469 | File.read(File.expand_path("../../samples/#{filename}.csv", __FILE__))
470 | end
471 | end
472 |
--------------------------------------------------------------------------------
/test/samples/AllStandardBlankFields.csv:
--------------------------------------------------------------------------------
1 | "Subject","Description","Assigned To","Fixed version","Author","Category","Priority","Tracker","Status","Start date","Due date","Done Ratio","Estimated hours","Watchers"
2 | "A blank task","","","","","","","","",,,,,""
3 | "A blank task",,,,"",,,,,,,,,
4 |
--------------------------------------------------------------------------------
/test/samples/AllStandardFields.csv:
--------------------------------------------------------------------------------
1 | "Subject","Description","Assigned To","Fixed version","Author","Category","Priority","Tracker","Status","Start date","Due date","Done Ratio","Estimated hours","Watchers"
2 | "A full task","A lengthily described set of activities.","admin","The Target Version","admin","Default","High","Bug","In Progress",2011/05/01,2011/08/28,25,200,"test1,test2"
3 |
--------------------------------------------------------------------------------
/test/samples/AllStandardFields.de.csv:
--------------------------------------------------------------------------------
1 | "Thema","Beschreibung","Zugewiesen an","Zielversion","Autor","Kategorie","Priorität","Tracker","Status","Beginn","Abgabedatum","% erledigt","Geschätzter Aufwand","Watchers"
2 | "A full task","A lengthily described set of activities.","admin","The Target Version","admin","Default","High","Bug","In Progress",05/01/11,08/28/11,25,200,"test1,test2"
3 |
--------------------------------------------------------------------------------
/test/samples/CustomField.csv:
--------------------------------------------------------------------------------
1 | "Subject","Description","External id"
2 | "Important Task","A truly critical deed.","5643-4"
3 | "Less Important Task","Something that would be useful.","5644-6"
4 |
--------------------------------------------------------------------------------
/test/samples/CustomFieldMultiValues.csv:
--------------------------------------------------------------------------------
1 | #,Project,Tracker,Parent item,Status,Priority,Subject,Author,Visibility,Updated,Category,Target version,Start date,Due date,Created,Closed,Related items,Priority.,Priority:,Priority*,Found in version,Platform.fv,Cycle.fv,Affected versions,Platform,Cycle,Severity,Occurrence,Responsible,Found In-Market,Ext. Ref,Type,Tags,Private,Watchers
2 | 70385,RDQ,Defect,"",New,Critical,barfooz,Bob Loblaw,"",2015-02-20 15:25,"","",2015-01-30,"",2015-01-30 11:08,"","","","",Critical,2013-09-25,"","","Admin, 2013-09-25","","",Critical,Always,Anonymous,"","","","tag1, tag2",Yes,"alice,bob"
3 |
--------------------------------------------------------------------------------
/test/samples/CustomFieldUpdate.csv:
--------------------------------------------------------------------------------
1 | "Subject","Description","External id"
2 | "Important Task","A truly critical deed.","5643-4"
3 | "Less Important Task","Altering this task to make it even more useful.","5644-6"
4 |
--------------------------------------------------------------------------------
/test/samples/ErroneousStandardFields.csv:
--------------------------------------------------------------------------------
1 | "Subject","Description","Assigned To","Fixed version","Author","Category","Priority","Tracker","Status","Start date","Due date","Done Ratio","Estimated hours","Watchers","Parent task"
2 | "a task with bad assigned-to","A lengthily described set of activities.","nobody","The Target Version","admin","Default","High","Bug","In Progress",2011/05/01,2011/08/28,25,200,"test1,test2",""
3 | "A task with bad target version","A lengthily described set of activities.","admin","No Version We Know Of","admin","Default","High","Bug","In Progress",2011/05/01,2011/08/28,25,200,"test1,test2",""
4 | "A task with bad Author","A lengthily described set of activities.","admin","The Target Version","Nobody","Default","High","Bug","In Progress",2011/05/01,2011/08/28,25,200,"test1,test2",""
5 | "A task with bad watchers","A lengthily described set of activities.","admin","The Target Version","admin","Default","High","Bug","In Progress",2011/05/01,2011/08/28,25,200,"testy1,zest2",""
6 | "A task with bad parent","A lengthily described set of activities.","admin","The Target Version","admin","Default","High","Bug","In Progress",2011/05/01,2011/08/28,25,200,"","Superkalifragilisticexpialidocious"
7 |
--------------------------------------------------------------------------------
/test/samples/IssueRelationsCustomField.csv:
--------------------------------------------------------------------------------
1 | "Subject","Description","External id","Parent issue","follows"
2 | "Run the company","What we actually have to do","T55",,
3 | "Major new project","The top-level new task","T56","T55",
4 | "Market Research","Find out what people what to buy","T57","T56",
5 | "Research and Development","Design that thing","T58","T56","T57"
6 | "Manufacturing Engineering","Figure out how to make that thing","T59","T56","T58"
7 | "Manufacturing","Make that thing","T60","T56","T59"
8 | "Marketing","Convince people they really want that thing","T61","T56","T58"
9 | "Sales","Sell people that thing","T62","T56","T60"
10 | "Investor Relations","Keep the investors happy in the meantime","T63","T55",
11 |
--------------------------------------------------------------------------------
/test/samples/KeyValueList.csv:
--------------------------------------------------------------------------------
1 | "Subject","Tracker","Priority","Area"
2 | "パンケーキ","Defect","Critical","Tokyo"
3 | "たこ焼き","Defect","Critical","Osaka"
4 | "サーターアンダギー","Defect","Critical","Okinawa"
5 |
--------------------------------------------------------------------------------
/test/samples/KeyValueListMultiple.csv:
--------------------------------------------------------------------------------
1 | "Subject","Tracker","Priority","Area"
2 | "パンケーキ","Defect","Critical","Tokyo"
3 | "たこ焼き","Defect","Critical","Osaka"
4 | "タピオカ","Defect","Critical","Tokyo,Osaka"
5 | "サーターアンダギー","Defect","Critical","Tokyo,Okinawa"
6 |
--------------------------------------------------------------------------------
/test/samples/NumberAsStringFields.csv:
--------------------------------------------------------------------------------
1 | "Subject","Description","Assigned To","Fixed version","Author","Category","Priority","Tracker","Status","Start date","Due date","Done Ratio","Estimated hours","Watchers"
2 | "A number task","","100_001","9.99","100_001","","","","",,,,,""
3 |
--------------------------------------------------------------------------------
/test/samples/ParentTaskByCustomField.csv:
--------------------------------------------------------------------------------
1 | "Subject","Description","External id","Parent issue"
2 | "The overall task","A truly critical deed.","7643-4",
3 | "The first sub-task","Something that would be useful.","7644-6","7643-4"
4 | "The second sub-task","A really important component","7644-7","7643-4"
5 |
--------------------------------------------------------------------------------
/test/samples/ParentTaskBySubject.csv:
--------------------------------------------------------------------------------
1 | "Subject","Description","Parent issue"
2 | "Task #1","This is a parent task.",
3 | "Task #1-1","This is a child task.","Task #1"
4 |
--------------------------------------------------------------------------------
/test/samples/TypedCustomFields.csv:
--------------------------------------------------------------------------------
1 | "Subject","Description","UserField","DateField","IntegerField","FloatField","BooleanField","VersionField"
2 | "A task of many fields","A task that needs many kinds of custom fields.","test1",2013/12/06 00:00:00,5,3.14,1,"The Target Version"
3 | "Another task of many fields","A task that needs many kinds of custom fields.","test2",2015/06/01 00:00:00,10,4,0,"Another Version"
4 |
--------------------------------------------------------------------------------
/test/samples/TypedErroneousCustomFields.csv:
--------------------------------------------------------------------------------
1 | "Subject","Description","UserField","DateField","IntegerField","FloatField","BooleanField","VersionField"
2 | "A task of many fields","A task that needs many kinds of custom fields.","test1",12/06 00:00:00,5,3.14,1,"The Target Version"
3 | "Another task of many fields","A task that needs many kinds of custom fields.","test2",2015/06/01 00:00:00,1y0,4,0,"Another Version"
4 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'test_helper'
4 |
--------------------------------------------------------------------------------
/test/unit/import_in_progress_test.rb:
--------------------------------------------------------------------------------
1 | require File.dirname(__FILE__) + '/../test_helper'
2 |
3 | class ImportInProgressTest < ActiveSupport::TestCase
4 | end
5 |
--------------------------------------------------------------------------------