# frozen_string_literal: true # Redmine - project management software # Copyright (C) 2006-2021 Jean-Philippe Lang # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. class IssueImport < Import AUTO_MAPPABLE_FIELDS = { 'tracker' => 'field_tracker', 'subject' => 'field_subject', 'description' => 'field_description', 'status' => 'field_status', 'priority' => 'field_priority', 'category' => 'field_category', 'assigned_to' => 'field_assigned_to', 'fixed_version' => 'field_fixed_version', 'is_private' => 'field_is_private', 'parent_issue_id' => 'field_parent_issue', 'start_date' => 'field_start_date', 'due_date' => 'field_due_date', 'estimated_hours' => 'field_estimated_hours', 'done_ratio' => 'field_done_ratio', 'unique_id' => 'field_unique_id', 'relation_duplicates' => 'label_duplicates', 'relation_duplicated' => 'label_duplicated_by', 'relation_blocks' => 'label_blocks', 'relation_blocked' => 'label_blocked_by', 'relation_relates' => 'label_relates_to', 'relation_precedes' => 'label_precedes', 'relation_follows' => 'label_follows', 'relation_copied_to' => 'label_copied_to', 'relation_copied_from' => 'label_copied_from' } def self.menu_item :issues end def self.authorized?(user) user.allowed_to?(:import_issues, nil, :global => true) end # Returns the objects that were imported def saved_objects object_ids = saved_items.pluck(:obj_id) objects = Issue.where(:id => object_ids).order(:id).preload(:tracker, :priority, :status) end # Returns a scope of projects that user is allowed to # import issue to def allowed_target_projects Project.allowed_to(user, :import_issues) end def project project_id = mapping['project_id'].to_i allowed_target_projects.find_by_id(project_id) || allowed_target_projects.first end # Returns a scope of trackers that user is allowed to # import issue to def allowed_target_trackers Issue.allowed_target_trackers(project, user) end def tracker if mapping['tracker'].to_s =~ /\Avalue:(\d+)\z/ tracker_id = $1.to_i allowed_target_trackers.find_by_id(tracker_id) end end # Returns true if missing categories should be created during the import def create_categories? user.allowed_to?(:manage_categories, project) && mapping['create_categories'] == '1' end # Returns true if missing versions should be created during the import def create_versions? user.allowed_to?(:manage_versions, project) && mapping['create_versions'] == '1' end def mappable_custom_fields if tracker issue = Issue.new issue.project = project issue.tracker = tracker issue.editable_custom_field_values(user).map(&:custom_field) elsif project project.all_issue_custom_fields else [] end end private def build_object(row, item) issue = Issue.new issue.author = user issue.notify = !!ActiveRecord::Type::Boolean.new.cast(settings['notifications']) tracker_id = nil if tracker tracker_id = tracker.id elsif tracker_name = row_value(row, 'tracker') tracker_id = allowed_target_trackers.named(tracker_name).first.try(:id) end attributes = { 'project_id' => mapping['project_id'], 'tracker_id' => tracker_id, 'subject' => row_value(row, 'subject'), 'description' => row_value(row, 'description') } if status_name = row_value(row, 'status') if status_id = IssueStatus.named(status_name).first.try(:id) attributes['status_id'] = status_id end end issue.send :safe_attributes=, attributes, user attributes = {} if priority_name = row_value(row, 'priority') if priority_id = IssuePriority.active.named(priority_name).first.try(:id) attributes['priority_id'] = priority_id end end if issue.project && category_name = row_value(row, 'category') if category = issue.project.issue_categories.named(category_name).first attributes['category_id'] = category.id elsif create_categories? category = issue.project.issue_categories.build category.name = category_name if category.save attributes['category_id'] = category.id end end end if assignee_name = row_value(row, 'assigned_to') if assignee = Principal.detect_by_keyword(issue.assignable_users, assignee_name) attributes['assigned_to_id'] = assignee.id end end if issue.project && version_name = row_value(row, 'fixed_version') version = issue.project.versions.named(version_name).first || issue.project.shared_versions.named(version_name).first if version attributes['fixed_version_id'] = version.id elsif create_versions? version = issue.project.versions.build version.name = version_name if version.save attributes['fixed_version_id'] = version.id end end end if is_private = row_value(row, 'is_private') if yes?(is_private) attributes['is_private'] = '1' end end if parent_issue_id = row_value(row, 'parent_issue_id') if parent_issue_id.start_with? '#' # refers to existing issue attributes['parent_issue_id'] = parent_issue_id[1..-1] elsif use_unique_id? # refers to other row with unique id issue_id = items.where(:unique_id => parent_issue_id).first.try(:obj_id) if issue_id attributes['parent_issue_id'] = issue_id else add_callback(parent_issue_id, 'set_as_parent', item.position) end elsif /\A\d+\z/.match?(parent_issue_id) # refers to other row by position parent_issue_id = parent_issue_id.to_i if parent_issue_id > item.position add_callback(parent_issue_id, 'set_as_parent', item.position) elsif issue_id = items.where(:position => parent_issue_id).first.try(:obj_id) attributes['parent_issue_id'] = issue_id end else # Something is odd. Assign parent_issue_id to trigger validation error attributes['parent_issue_id'] = parent_issue_id end end if start_date = row_date(row, 'start_date') attributes['start_date'] = start_date end if due_date = row_date(row, 'due_date') attributes['due_date'] = due_date end if estimated_hours = row_value(row, 'estimated_hours') attributes['estimated_hours'] = estimated_hours end if done_ratio = row_value(row, 'done_ratio') attributes['done_ratio'] = done_ratio end attributes['custom_field_values'] = issue.custom_field_values.inject({}) do |h, v| value = case v.custom_field.field_format when 'date' row_date(row, "cf_#{v.custom_field.id}") else row_value(row, "cf_#{v.custom_field.id}") end if value h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(value, issue) end h end issue.send :safe_attributes=, attributes, user if issue.tracker_id != tracker_id issue.tracker_id = nil end issue end def extend_object(row, item, issue) build_relations(row, item, issue) end def build_relations(row, item, issue) IssueRelation::TYPES.each_key do |type| has_delay = [IssueRelation::TYPE_PRECEDES, IssueRelation::TYPE_FOLLOWS].include?(type) if decls = relation_values(row, "relation_#{type}") decls.each do |decl| unless decl[:matches] # Invalid relation syntax - doesn't match regexp next end if decl[:delay] && !has_delay # Invalid relation syntax - delay for relation that doesn't support delays next end relation = IssueRelation.new( "relation_type" => type, "issue_from_id" => issue.id ) if decl[:other_id] relation.issue_to_id = decl[:other_id] elsif decl[:other_pos] if use_unique_id? issue_id = items.where(:unique_id => decl[:other_pos]).first.try(:obj_id) if issue_id relation.issue_to_id = issue_id else add_callback(decl[:other_pos], 'set_relation', item.position, type, decl[:delay]) next end elsif decl[:other_pos] > item.position add_callback(decl[:other_pos], 'set_relation', item.position, type, decl[:delay]) next elsif issue_id = items.where(:position => decl[:other_pos]).first.try(:obj_id) relation.issue_to_id = issue_id end end relation.delay = decl[:delay] if decl[:delay] relation.save! end end end issue end def relation_values(row, name) content = row_value(row, name) return if content.blank? content.split(",").map do |declaration| declaration = declaration.strip # Valid expression: # # 123 => row 123 within the CSV # #123 => issue with ID 123 # # For precedes and follows # # 123 7d => row 123 within CSV with 7 day delay # #123 7d => issue with ID 123 with 7 day delay # 123 -3d => negative delay allowed # # # Invalid expression: # # No. 123 => Invalid leading letters # # 123 => Invalid space between # and issue number # 123 8h => No other time units allowed (just days) # # Please note: If unique_id mapping is present, the whole line - but the # trailing delay expression - is considered unique_id. # # See examples at Rubular http://rubular.com/r/mgXM5Rp6zK # match = declaration.match(/\A(?(?#)?(?\d+)|.+?)(?:\s+(?-?\d+)d)?\z/) result = { :matches => false, :declaration => declaration } if match result[:matches] = true result[:delay] = match[:delay] if match[:is_id] && match[:id] result[:other_id] = match[:id] elsif use_unique_id? && match[:unique_id] result[:other_pos] = match[:unique_id] elsif match[:id] result[:other_pos] = match[:id].to_i else result[:matches] = false end end result end end # Callback that sets issue as the parent of a previously imported issue def set_as_parent_callback(issue, child_position) child_id = items.where(:position => child_position).first.try(:obj_id) return unless child_id child = Issue.find_by_id(child_id) return unless child child.parent_issue_id = issue.id child.save! issue.reload end def set_relation_callback(to_issue, from_position, type, delay) return if to_issue.new_record? from_id = items.where(:position => from_position).first.try(:obj_id) return unless from_id IssueRelation.create!( 'relation_type' => type, 'issue_from_id' => from_id, 'issue_to_id' => to_issue.id, 'delay' => delay ) to_issue.reload end end