Feature #28198 » 0001-Support-issue-relations-when-importing-issues.patch
| app/models/import.rb | ||
|---|---|---|
| 186 | 186 |
item.save! |
| 187 | 187 |
imported += 1 |
| 188 | 188 | |
| 189 |
extend_object(row, item, object) if object.persisted? |
|
| 189 | 190 |
do_callbacks(item.position, object) |
| 190 | 191 |
end |
| 191 | 192 |
current = position |
| ... | ... | |
| 243 | 244 | |
| 244 | 245 |
# Builds a record for the given row and returns it |
| 245 | 246 |
# To be implemented by subclasses |
| 246 |
def build_object(row) |
|
| 247 |
def build_object(row, item) |
|
| 248 |
end |
|
| 249 | ||
| 250 |
# Extends object with properties, that may only be handled after it's been |
|
| 251 |
# persisted. |
|
| 252 |
def extend_object(row, item, object) |
|
| 247 | 253 |
end |
| 248 | 254 | |
| 249 | 255 |
# Generates a filename used to store the import file |
| app/models/issue_import.rb | ||
|---|---|---|
| 188 | 188 |
issue |
| 189 | 189 |
end |
| 190 | 190 | |
| 191 |
def extend_object(row, item, issue) |
|
| 192 |
build_relations(row, item, issue) |
|
| 193 |
end |
|
| 194 | ||
| 195 |
def build_relations(row, item, issue) |
|
| 196 |
IssueRelation::TYPES.keys.each do |type| |
|
| 197 |
has_delay = type == IssueRelation::TYPE_PRECEDES || type == IssueRelation::TYPE_FOLLOWS |
|
| 198 | ||
| 199 |
if decls = relation_values(row, "relation_#{type}")
|
|
| 200 |
decls.each do |decl| |
|
| 201 |
unless decl[:matches] |
|
| 202 |
# Invalid relation syntax - doesn't match regexp |
|
| 203 |
next |
|
| 204 |
end |
|
| 205 | ||
| 206 |
if decl[:delay] && !has_delay |
|
| 207 |
# Invalid relation syntax - delay for relation that doesn't support delays |
|
| 208 |
next |
|
| 209 |
end |
|
| 210 | ||
| 211 |
relation = IssueRelation.new( |
|
| 212 |
"relation_type" => type, |
|
| 213 |
"issue_from_id" => issue.id |
|
| 214 |
) |
|
| 215 | ||
| 216 |
if decl[:other_id] |
|
| 217 |
relation.issue_to_id = decl[:other_id] |
|
| 218 |
elsif decl[:other_pos] |
|
| 219 |
if decl[:other_pos] > item.position |
|
| 220 |
add_callback(decl[:other_pos], 'set_relation', item.position, type, decl[:delay]) |
|
| 221 |
next |
|
| 222 |
elsif issue_id = items.where(:position => decl[:other_pos]).first.try(:obj_id) |
|
| 223 |
relation.issue_to_id = issue_id |
|
| 224 |
end |
|
| 225 |
end |
|
| 226 | ||
| 227 |
relation.delay = decl[:delay] if decl[:delay] |
|
| 228 | ||
| 229 |
relation.save! |
|
| 230 |
end |
|
| 231 |
end |
|
| 232 |
end |
|
| 233 | ||
| 234 |
issue |
|
| 235 |
end |
|
| 236 | ||
| 237 |
def relation_values(row, name) |
|
| 238 |
content = row_value(row, name) |
|
| 239 | ||
| 240 |
return if content.blank? |
|
| 241 | ||
| 242 |
content.split(",").map do |declaration|
|
|
| 243 |
declaration = declaration.strip |
|
| 244 | ||
| 245 |
# Valid expression: |
|
| 246 |
# |
|
| 247 |
# 123 => row 123 within the CSV |
|
| 248 |
# #123 => issue with ID 123 |
|
| 249 |
# |
|
| 250 |
# For precedes and follows |
|
| 251 |
# |
|
| 252 |
# 123 7d => row 123 within CSV with 7 day delay |
|
| 253 |
# #123 7d => issue with ID 123 with 7 day delay |
|
| 254 |
# 123 -3d => negative delay allowed |
|
| 255 |
# |
|
| 256 |
# |
|
| 257 |
# Invalid expression: |
|
| 258 |
# |
|
| 259 |
# No. 123 => Invalid leading letters |
|
| 260 |
# # 123 => Invalid space between # and issue number |
|
| 261 |
# 123 8h => No other time units allowed (just days) |
|
| 262 |
# |
|
| 263 |
# See examples at Rubular http://rubular.com/r/mgXM5Rp6zK |
|
| 264 |
# |
|
| 265 |
match = declaration.match(/\A(?<is_id>#)?(?<id>\d+)(?:\s+(?<delay>-?\d+)d)?\z/) |
|
| 266 | ||
| 267 |
result = {
|
|
| 268 |
:matches => false, |
|
| 269 |
:declaration => declaration |
|
| 270 |
} |
|
| 271 | ||
| 272 |
if match |
|
| 273 |
result[:matches] = true |
|
| 274 |
result[:delay] = match[:delay] |
|
| 275 | ||
| 276 |
if match[:is_id] and match[:id] |
|
| 277 |
result[:other_id] = match[:id] |
|
| 278 |
elsif match[:id] |
|
| 279 |
result[:other_pos] = match[:id].to_i |
|
| 280 |
else |
|
| 281 |
result[:matches] = false |
|
| 282 |
end |
|
| 283 |
end |
|
| 284 | ||
| 285 |
result |
|
| 286 |
end |
|
| 287 |
end |
|
| 288 | ||
| 191 | 289 |
# Callback that sets issue as the parent of a previously imported issue |
| 192 | 290 |
def set_as_parent_callback(issue, child_position) |
| 193 | 291 |
child_id = items.where(:position => child_position).first.try(:obj_id) |
| ... | ... | |
| 200 | 298 |
child.save! |
| 201 | 299 |
issue.reload |
| 202 | 300 |
end |
| 301 | ||
| 302 |
def set_relation_callback(to_issue, from_position, type, delay) |
|
| 303 |
return if to_issue.new_record? |
|
| 304 | ||
| 305 |
from_id = items.where(:position => from_position).first.try(:obj_id) |
|
| 306 |
return unless from_id |
|
| 307 | ||
| 308 |
IssueRelation.create!( |
|
| 309 |
'relation_type' => type, |
|
| 310 |
'issue_from_id' => from_id, |
|
| 311 |
'issue_to_id' => to_issue.id, |
|
| 312 |
'delay' => delay |
|
| 313 |
) |
|
| 314 |
to_issue.reload |
|
| 315 |
end |
|
| 203 | 316 |
end |
| app/views/imports/_fields_mapping.html.erb | ||
|---|---|---|
| 52 | 52 |
</label> |
| 53 | 53 |
<% end %> |
| 54 | 54 |
</p> |
| 55 |
<% @custom_fields.each do |field| %> |
|
| 56 |
<p> |
|
| 57 |
<label for="import_mapping_cf_<% field.id %>"><%= field.name %></label> |
|
| 58 |
<%= mapping_select_tag @import, "cf_#{field.id}" %>
|
|
| 59 |
</p> |
|
| 60 |
<% end %> |
|
| 61 | 55 |
</div> |
| 62 | 56 | |
| 63 | 57 |
<div class="splitcontentright"> |
| ... | ... | |
| 65 | 59 |
<label for="import_mapping_is_private"><%= l(:field_is_private) %></label> |
| 66 | 60 |
<%= mapping_select_tag @import, 'is_private' %> |
| 67 | 61 |
</p> |
| 68 |
<p> |
|
| 69 |
<label for="import_mapping_parent_issue_id"><%= l(:field_parent_issue) %></label> |
|
| 70 |
<%= mapping_select_tag @import, 'parent_issue_id' %> |
|
| 71 |
</p> |
|
| 72 | 62 |
<p> |
| 73 | 63 |
<label for="import_mapping_start_date"><%= l(:field_start_date) %></label> |
| 74 | 64 |
<%= mapping_select_tag @import, 'start_date' %> |
| ... | ... | |
| 85 | 75 |
<label for="import_mapping_done_ratio"><%= l(:field_done_ratio) %></label> |
| 86 | 76 |
<%= mapping_select_tag @import, 'done_ratio' %> |
| 87 | 77 |
</p> |
| 78 |
<% @custom_fields.each do |field| %> |
|
| 79 |
<p> |
|
| 80 |
<label for="import_mapping_cf_<%= field.id %>"><%= field.name %></label> |
|
| 81 |
<%= mapping_select_tag @import, "cf_#{field.id}" %>
|
|
| 82 |
</p> |
|
| 83 |
<% end %> |
|
| 88 | 84 |
</div> |
| 89 | 85 |
</div> |
| 90 | 86 | |
| app/views/imports/_relations_mapping.html.erb | ||
|---|---|---|
| 1 |
<div class="splitcontent"> |
|
| 2 |
<div class="splitcontentleft"> |
|
| 3 |
<p> |
|
| 4 |
<label for="import_settings_mapping_parent_issue_id"><%= l(:field_parent_issue) %></label> |
|
| 5 |
<%= mapping_select_tag @import, 'parent_issue_id' %> |
|
| 6 |
</p> |
|
| 7 | ||
| 8 |
<p> |
|
| 9 |
<label for="import_settings_mapping_relation_duplicates"><%= l(:label_duplicates) %></label> |
|
| 10 |
<%= mapping_select_tag @import, 'relation_duplicates' %> |
|
| 11 |
</p> |
|
| 12 | ||
| 13 |
<p> |
|
| 14 |
<label for="import_settings_mapping_relation_duplicated"><%= l(:label_duplicated_by) %></label> |
|
| 15 |
<%= mapping_select_tag @import, 'relation_duplicated' %> |
|
| 16 |
</p> |
|
| 17 | ||
| 18 |
<p> |
|
| 19 |
<label for="import_settings_mapping_relation_blocks"><%= l(:label_blocks) %></label> |
|
| 20 |
<%= mapping_select_tag @import, 'relation_blocks' %> |
|
| 21 |
</p> |
|
| 22 | ||
| 23 |
<p> |
|
| 24 |
<label for="import_settings_mapping_relation_blocked"><%= l(:label_blocked_by) %></label> |
|
| 25 |
<%= mapping_select_tag @import, 'relation_blocked' %> |
|
| 26 |
</p> |
|
| 27 |
</div> |
|
| 28 | ||
| 29 |
<div class="splitcontentright"> |
|
| 30 |
<p> |
|
| 31 |
<label for="import_settings_mapping_relation_relates"><%= l(:label_relates_to) %></label> |
|
| 32 |
<%= mapping_select_tag @import, 'relation_relates' %> |
|
| 33 |
</p> |
|
| 34 | ||
| 35 |
<p> |
|
| 36 |
<label for="import_settings_mapping_relation_precedes"><%= l(:label_precedes) %></label> |
|
| 37 |
<%= mapping_select_tag @import, 'relation_precedes' %> |
|
| 38 |
</p> |
|
| 39 | ||
| 40 |
<p> |
|
| 41 |
<label for="import_settings_mapping_relation_follows"><%= l(:label_follows) %></label> |
|
| 42 |
<%= mapping_select_tag @import, 'relation_follows' %> |
|
| 43 |
</p> |
|
| 44 | ||
| 45 |
<p> |
|
| 46 |
<label for="import_settings_mapping_relation_copied_to"><%= l(:label_copied_to) %></label> |
|
| 47 |
<%= mapping_select_tag @import, 'relation_copied_to' %> |
|
| 48 |
</p> |
|
| 49 | ||
| 50 |
<p> |
|
| 51 |
<label for="import_settings_mapping_relation_copied_from"><%= l(:label_copied_from) %></label> |
|
| 52 |
<%= mapping_select_tag @import, 'relation_copied_from' %> |
|
| 53 |
</p> |
|
| 54 |
</div> |
|
| 55 |
</div> |
|
| app/views/imports/mapping.html.erb | ||
|---|---|---|
| 8 | 8 |
</div> |
| 9 | 9 |
</fieldset> |
| 10 | 10 | |
| 11 |
<fieldset class="box tabular collapsible collapsed"> |
|
| 12 |
<legend onclick="toggleFieldset(this);"><%= l(:label_relations_mapping) %></legend> |
|
| 13 |
<div id="relations-mapping" style="display: none;"> |
|
| 14 |
<%= render :partial => 'relations_mapping' %> |
|
| 15 |
</div> |
|
| 16 |
</fieldset> |
|
| 17 | ||
| 11 | 18 |
<div class="autoscroll"> |
| 12 | 19 |
<fieldset class="box"> |
| 13 | 20 |
<legend><%= l(:label_file_content_preview) %></legend> |
| config/locales/de.yml | ||
|---|---|---|
| 1182 | 1182 |
label_quote_char: Anführungszeichen |
| 1183 | 1183 |
label_double_quote_char: Doppelte Anführungszeichen |
| 1184 | 1184 |
label_fields_mapping: Zuordnung der Felder |
| 1185 |
label_relations_mapping: Zuordnung von Beziehungen |
|
| 1185 | 1186 |
label_file_content_preview: Inhaltsvorschau |
| 1186 | 1187 |
label_create_missing_values: Ergänze fehlende Werte |
| 1187 | 1188 |
button_import: Importieren |
| config/locales/en.yml | ||
|---|---|---|
| 1012 | 1012 |
label_quote_char: Quote |
| 1013 | 1013 |
label_double_quote_char: Double quote |
| 1014 | 1014 |
label_fields_mapping: Fields mapping |
| 1015 |
label_relations_mapping: Relations mapping |
|
| 1015 | 1016 |
label_file_content_preview: File content preview |
| 1016 | 1017 |
label_create_missing_values: Create missing values |
| 1017 | 1018 |
label_api: API |
| test/fixtures/files/import_subtasks.csv | ||
|---|---|---|
| 1 |
row;tracker;subject;parent |
|
| 2 |
1;bug;Root; |
|
| 3 |
2;bug;Child 1;1 |
|
| 4 |
3;bug;Grand-child;4 |
|
| 5 |
4;bug;Child 2;1 |
|
| 1 |
row;tracker;subject;parent;simple relation;delayed relation |
|
| 2 |
1;bug;Root;;; |
|
| 3 |
2;bug;Child 1;1;1,4;1 2d |
|
| 4 |
3;bug;Grand-child;4;4;4 -1d |
|
| 5 |
4;bug;Child 2;1;1;1 1d |
|
| test/fixtures/files/import_subtasks_with_relations.csv | ||
|---|---|---|
| 1 |
row;tracker;subject;start;due;parent;follows |
|
| 2 |
1;bug;2nd Child;2020-01-12;2020-01-20;3;2 1d |
|
| 3 |
2;bug;1st Child;2020-01-01;2020-01-10;3; |
|
| 4 |
3;bug;Parent;2020-01-01;2020-01-31;; |
|
| 5 |
1;bug;3rd Child;2020-01-22;2020-01-31;3;1 1d |
|
| test/unit/issue_import_test.rb | ||
|---|---|---|
| 128 | 128 |
assert_equal child2, grandchild.parent |
| 129 | 129 |
end |
| 130 | 130 | |
| 131 |
def test_follow_relation |
|
| 132 |
import = generate_import_with_mapping('import_subtasks.csv')
|
|
| 133 |
import.settings['mapping'] = {'project_id' => '1', 'tracker' => '1', 'subject' => '2', 'relation_relates' => '4'}
|
|
| 134 |
import.save! |
|
| 135 | ||
| 136 |
one, one_one, one_two_one, one_two = new_records(Issue, 4) { import.run }
|
|
| 137 |
assert_equal 2, one.relations.count |
|
| 138 |
assert one.relations.all? { |r| r.relation_type == 'relates' }
|
|
| 139 |
assert one.relations.any? { |r| r.other_issue(one) == one_one }
|
|
| 140 |
assert one.relations.any? { |r| r.other_issue(one) == one_two }
|
|
| 141 | ||
| 142 |
assert_equal 2, one_one.relations.count |
|
| 143 |
assert one_one.relations.all? { |r| r.relation_type == 'relates' }
|
|
| 144 |
assert one_one.relations.any? { |r| r.other_issue(one_one) == one }
|
|
| 145 |
assert one_one.relations.any? { |r| r.other_issue(one_one) == one_two }
|
|
| 146 | ||
| 147 |
assert_equal 3, one_two.relations.count |
|
| 148 |
assert one_two.relations.all? { |r| r.relation_type == 'relates' }
|
|
| 149 |
assert one_two.relations.any? { |r| r.other_issue(one_two) == one }
|
|
| 150 |
assert one_two.relations.any? { |r| r.other_issue(one_two) == one_one }
|
|
| 151 |
assert one_two.relations.any? { |r| r.other_issue(one_two) == one_two_one }
|
|
| 152 | ||
| 153 |
assert_equal 1, one_two_one.relations.count |
|
| 154 |
assert one_two_one.relations.all? { |r| r.relation_type == 'relates' }
|
|
| 155 |
assert one_two_one.relations.any? { |r| r.other_issue(one_two_one) == one_two }
|
|
| 156 |
end |
|
| 157 | ||
| 158 |
def test_delayed_relation |
|
| 159 |
import = generate_import_with_mapping('import_subtasks.csv')
|
|
| 160 |
import.settings['mapping'] = {'project_id' => '1', 'tracker' => '1', 'subject' => '2', 'relation_precedes' => '5'}
|
|
| 161 |
import.save! |
|
| 162 | ||
| 163 |
one, one_one, one_two_one, one_two = new_records(Issue, 4) { import.run }
|
|
| 164 | ||
| 165 |
assert_equal 2, one.relations_to.count |
|
| 166 |
assert one.relations_to.all? { |r| r.relation_type == 'precedes' }
|
|
| 167 |
assert one.relations_to.any? { |r| r.issue_from == one_one && r.delay == 2 }
|
|
| 168 |
assert one.relations_to.any? { |r| r.issue_from == one_two && r.delay == 1 }
|
|
| 169 | ||
| 170 | ||
| 171 |
assert_equal 1, one_one.relations_from.count |
|
| 172 |
assert one_one.relations_from.all? { |r| r.relation_type == 'precedes' }
|
|
| 173 |
assert one_one.relations_from.any? { |r| r.issue_to == one && r.delay == 2 }
|
|
| 174 | ||
| 175 | ||
| 176 |
assert_equal 1, one_two.relations_to.count |
|
| 177 |
assert one_two.relations_to.all? { |r| r.relation_type == 'precedes' }
|
|
| 178 |
assert one_two.relations_to.any? { |r| r.issue_from == one_two_one && r.delay == -1 }
|
|
| 179 | ||
| 180 |
assert_equal 1, one_two.relations_from.count |
|
| 181 |
assert one_two.relations_from.all? { |r| r.relation_type == 'precedes' }
|
|
| 182 |
assert one_two.relations_from.any? { |r| r.issue_to == one && r.delay == 1 }
|
|
| 183 | ||
| 184 | ||
| 185 |
assert_equal 1, one_two_one.relations_from.count |
|
| 186 |
assert one_two_one.relations_from.all? { |r| r.relation_type == 'precedes' }
|
|
| 187 |
assert one_two_one.relations_from.any? { |r| r.issue_to == one_two && r.delay == -1 }
|
|
| 188 |
end |
|
| 189 | ||
| 190 |
def test_parent_and_follows_relation |
|
| 191 |
import = generate_import_with_mapping('import_subtasks_with_relations.csv')
|
|
| 192 |
import.settings['mapping'] = {
|
|
| 193 |
'project_id' => '1', |
|
| 194 |
'tracker' => '1', |
|
| 195 | ||
| 196 |
'subject' => '2', |
|
| 197 |
'start_date' => '3', |
|
| 198 |
'due_date' => '4', |
|
| 199 |
'parent_issue_id' => '5', |
|
| 200 |
'relation_follows' => '6' |
|
| 201 |
} |
|
| 202 |
import.save! |
|
| 203 | ||
| 204 |
second, first, parent, third = assert_difference('IssueRelation.count', 2) { new_records(Issue, 4) { import.run } }
|
|
| 205 | ||
| 206 |
# Parent relations |
|
| 207 |
assert_equal parent, first.parent |
|
| 208 |
assert_equal parent, second.parent |
|
| 209 |
assert_equal parent, third.parent |
|
| 210 | ||
| 211 |
# Issue relations |
|
| 212 |
assert IssueRelation.where( |
|
| 213 |
:issue_from_id => first.id, |
|
| 214 |
:issue_to_id => second.id, |
|
| 215 |
:relation_type => 'precedes', |
|
| 216 |
:delay => 1).present? |
|
| 217 | ||
| 218 |
assert IssueRelation.where( |
|
| 219 |
:issue_from_id => second.id, |
|
| 220 |
:issue_to_id => third.id, |
|
| 221 |
:relation_type => 'precedes', |
|
| 222 |
:delay => 1).present? |
|
| 223 | ||
| 224 | ||
| 225 |
# Checking dates, because they might act weird, when relations are added |
|
| 226 |
assert_equal Date.new(2020, 1, 1), parent.start_date |
|
| 227 |
assert_equal Date.new(2020, 1, 31), parent.due_date |
|
| 228 | ||
| 229 |
assert_equal Date.new(2020, 1, 1), first.start_date |
|
| 230 |
assert_equal Date.new(2020, 1, 10), first.due_date |
|
| 231 | ||
| 232 |
assert_equal Date.new(2020, 1, 12), second.start_date |
|
| 233 |
assert_equal Date.new(2020, 1, 20), second.due_date |
|
| 234 | ||
| 235 |
assert_equal Date.new(2020, 1, 22), third.start_date |
|
| 236 |
assert_equal Date.new(2020, 1, 31), third.due_date |
|
| 237 |
end |
|
| 238 | ||
| 131 | 239 |
def test_assignee_should_be_set |
| 132 | 240 |
import = generate_import_with_mapping |
| 133 | 241 |
import.mapping.merge!('assigned_to' => '11')
|