├── .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 |


7 | <%= file_field_tag 'file', :size => 60%>

8 | 9 |
<%= l(:label_upload_format) %> 10 |

11 | <%= select_tag "encoding", 12 | "" \ 13 | "" \ 14 | "" \ 15 | "".html_safe %>

16 | 17 |

18 | <%= text_field_tag "splitter", ',', {:size => 3, :maxlength => 1}%>

19 | 20 |

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 | 33 | <% end %> 34 |
35 | 36 |
37 | <%= l(:label_import_rule) %> 38 | 43 |
44 | 50 |
51 | 55 |
56 | 60 |
61 | 65 |
66 | 70 |
71 | 75 |
76 | 80 |
81 | 82 | <%= yield :update_issue_javascript %> 83 | 84 |      85 | 92 |
93 | 94 |      95 | 99 |
100 | 101 |      102 | 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 | 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 |
<%= force_utf8(column) %>
...
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 | 16 | 17 |
18 | <% end %> 19 |

20 | 21 | <% if not @messages.empty? %> 22 |
23 |

<%= l(:label_result_messages) %>

24 | 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 | 40 | <% end %> 41 | 42 | 43 | 44 | <% @failed_issues.each do |id, issue| -%> 45 | "> 46 | 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 |
#<%= column.unpack('U*').pack('U*') %>
<%= id %>
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 | --------------------------------------------------------------------------------