Project

General

Profile

Feature #28198 » 0002-Support-issue-relations-when-importing-issues.patch

Patch on top of #28213 - Gregor Schmidt, 2018-02-19 14:35

View differences:

app/models/import.rb
187 187
        item.save!
188 188
        imported += 1
189 189

  
190
        extend_object(row, item, object) if object.persisted?
190 191
        do_callbacks(use_unique_id? ? item.unique_id : item.position, object)
191 192
      end
192 193
      current = position
......
244 245

  
245 246
  # Builds a record for the given row and returns it
246 247
  # To be implemented by subclasses
247
  def build_object(row)
248
  def build_object(row, item)
249
  end
250

  
251
  # Extends object with properties, that may only be handled after it's been
252
  # persisted.
253
  def extend_object(row, item, object)
248 254
  end
249 255

  
250 256
  # Generates a filename used to store the import file
app/models/issue_import.rb
200 200
    issue
201 201
  end
202 202

  
203
  def extend_object(row, item, issue)
204
    build_relations(row, item, issue)
205
  end
206

  
207
  def build_relations(row, item, issue)
208
    IssueRelation::TYPES.keys.each do |type|
209
      has_delay = type == IssueRelation::TYPE_PRECEDES || type == IssueRelation::TYPE_FOLLOWS
210

  
211
      if decls = relation_values(row, "relation_#{type}")
212
        decls.each do |decl|
213
          unless decl[:matches]
214
            # Invalid relation syntax - doesn't match regexp
215
            next
216
          end
217

  
218
          if decl[:delay] && !has_delay
219
            # Invalid relation syntax - delay for relation that doesn't support delays
220
            next
221
          end
222

  
223
          relation = IssueRelation.new(
224
            "relation_type" => type,
225
            "issue_from_id" => issue.id
226
          )
227

  
228
          if decl[:other_id]
229
            relation.issue_to_id = decl[:other_id]
230
          elsif decl[:other_pos]
231
            if use_unique_id?
232
              issue_id = items.where(:unique_id => decl[:other_pos]).first.try(:obj_id)
233
              if issue_id
234
                relation.issue_to_id = issue_id
235
              else
236
                add_callback(decl[:other_pos], 'set_relation', item.position, type, decl[:delay])
237
                next
238
              end
239
            else
240
              if decl[:other_pos] > item.position
241
                add_callback(decl[:other_pos], 'set_relation', item.position, type, decl[:delay])
242
                next
243
              elsif issue_id = items.where(:position => decl[:other_pos]).first.try(:obj_id)
244
                relation.issue_to_id = issue_id
245
              end
246
            end
247
          end
248

  
249
          relation.delay = decl[:delay] if decl[:delay]
250

  
251
          relation.save!
252
        end
253
      end
254
    end
255

  
256
    issue
257
  end
258

  
259
  def relation_values(row, name)
260
    content = row_value(row, name)
261

  
262
    return if content.blank?
263

  
264
    content.split(",").map do |declaration|
265
      declaration = declaration.strip
266

  
267
      # Valid expression:
268
      #
269
      # 123  => row 123 within the CSV
270
      # #123 => issue with ID 123
271
      #
272
      # For precedes and follows
273
      #
274
      # 123 7d    => row 123 within CSV with 7 day delay
275
      # #123  7d  => issue with ID 123 with 7 day delay
276
      # 123 -3d   => negative delay allowed
277
      #
278
      #
279
      # Invalid expression:
280
      #
281
      # No. 123 => Invalid leading letters
282
      # # 123   => Invalid space between # and issue number
283
      # 123 8h  => No other time units allowed (just days)
284
      #
285
      # Please note: If unique_id mapping is present, the whole line - but the
286
      # trailing delay expression - is considered unique_id.
287
      #
288
      # See examples at Rubular http://rubular.com/r/mgXM5Rp6zK
289
      #
290
      match = declaration.match(/\A(?<unique_id>(?<is_id>#)?(?<id>\d+)|.+?)(?:\s+(?<delay>-?\d+)d)?\z/)
291

  
292
      result = {
293
        :matches     => false,
294
        :declaration => declaration
295
      }
296

  
297
      if match
298
        result[:matches] = true
299
        result[:delay]   = match[:delay]
300

  
301
        if match[:is_id] and match[:id]
302
          result[:other_id] = match[:id]
303
        elsif use_unique_id? and match[:unique_id]
304
          result[:other_pos] = match[:unique_id]
305
        elsif match[:id]
306
          result[:other_pos] = match[:id].to_i
307
        else
308
          result[:matches] = false
309
        end
310
      end
311

  
312
      result
313
    end
314
  end
315

  
203 316
  # Callback that sets issue as the parent of a previously imported issue
204 317
  def set_as_parent_callback(issue, child_position)
205 318
    child_id = items.where(:position => child_position).first.try(:obj_id)
......
212 325
    child.save!
213 326
    issue.reload
214 327
  end
328

  
329
  def set_relation_callback(to_issue, from_position, type, delay)
330
    return if to_issue.new_record?
331

  
332
    from_id = items.where(:position => from_position).first.try(:obj_id)
333
    return unless from_id
334

  
335
    IssueRelation.create!(
336
      'relation_type' => type,
337
      'issue_from_id' => from_id,
338
      'issue_to_id'   => to_issue.id,
339
      'delay'         => delay
340
    )
341
    to_issue.reload
342
  end
215 343
end
app/views/imports/_fields_mapping.html.erb
1
<div class="splitcontent">
2
<div class="splitcontentleft">
3 1
<p>
4 2
  <label for="import_mapping_project_id"><%= l(:label_project) %></label>
5 3
  <%= select_tag 'import_settings[mapping][project_id]',
......
15 13
  <label for="import_mapping_status"><%= l(:field_status) %></label>
16 14
  <%= mapping_select_tag @import, 'status' %>
17 15
</p>
18
</div>
19

  
20
<div class="splitcontentright">
21
<p></p>
22
<p>
23
  <label for="import_mapping_unique_id"><%= l(:field_unique_id) %></label>
24
  <%= mapping_select_tag @import, 'unique_id' %>
25
</p>
26
</div>
27
</div>
28 16

  
29 17
<div class="splitcontent">
30 18
<div class="splitcontentleft">
......
64 52
    </label>
65 53
  <% end %>
66 54
</p>
67
<% @custom_fields.each do |field| %>
68
  <p>
69
    <label for="import_mapping_cf_<% field.id %>"><%= field.name %></label>
70
    <%= mapping_select_tag @import, "cf_#{field.id}" %>
71
  </p>
72
<% end %>
73 55
</div>
74 56

  
75 57
<div class="splitcontentright">
......
77 59
  <label for="import_mapping_is_private"><%= l(:field_is_private) %></label>
78 60
  <%= mapping_select_tag @import, 'is_private' %>
79 61
</p>
80
<p>
81
  <label for="import_mapping_parent_issue_id"><%= l(:field_parent_issue) %></label>
82
  <%= mapping_select_tag @import, 'parent_issue_id' %>
83
</p>
84 62
<p>
85 63
  <label for="import_mapping_start_date"><%= l(:field_start_date) %></label>
86 64
  <%= mapping_select_tag @import, 'start_date' %>
......
97 75
  <label for="import_mapping_done_ratio"><%= l(:field_done_ratio) %></label>
98 76
  <%= mapping_select_tag @import, 'done_ratio' %>
99 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 %>
100 84
</div>
101 85
</div>
102 86

  
app/views/imports/_relations_mapping.html.erb
1
<div class="splitcontent">
2
  <div class="splitcontentleft">
3
    <p>
4
      <label for="import_mapping_unique_id"><%= l(:field_unique_id) %></label>
5
      <%= mapping_select_tag @import, 'unique_id' %>
6
    </p>
7
    <p>
8
      <label for="import_settings_mapping_parent_issue_id"><%= l(:field_parent_issue) %></label>
9
      <%= mapping_select_tag @import, 'parent_issue_id' %>
10
    </p>
11

  
12
    <p>
13
      <label for="import_settings_mapping_relation_duplicates"><%= l(:label_duplicates) %></label>
14
      <%= mapping_select_tag @import, 'relation_duplicates' %>
15
    </p>
16

  
17
    <p>
18
      <label for="import_settings_mapping_relation_duplicated"><%= l(:label_duplicated_by) %></label>
19
      <%= mapping_select_tag @import, 'relation_duplicated' %>
20
    </p>
21

  
22
    <p>
23
      <label for="import_settings_mapping_relation_blocks"><%= l(:label_blocks) %></label>
24
      <%= mapping_select_tag @import, 'relation_blocks' %>
25
    </p>
26

  
27
    <p>
28
      <label for="import_settings_mapping_relation_blocked"><%= l(:label_blocked_by) %></label>
29
      <%= mapping_select_tag @import, 'relation_blocked' %>
30
    </p>
31
  </div>
32

  
33
  <div class="splitcontentright">
34
    <p></p>
35
    <p>
36
      <label for="import_settings_mapping_relation_relates"><%= l(:label_relates_to) %></label>
37
      <%= mapping_select_tag @import, 'relation_relates' %>
38
    </p>
39

  
40
    <p>
41
      <label for="import_settings_mapping_relation_precedes"><%= l(:label_precedes) %></label>
42
      <%= mapping_select_tag @import, 'relation_precedes' %>
43
    </p>
44

  
45
    <p>
46
      <label for="import_settings_mapping_relation_follows"><%= l(:label_follows) %></label>
47
      <%= mapping_select_tag @import, 'relation_follows' %>
48
    </p>
49

  
50
    <p>
51
      <label for="import_settings_mapping_relation_copied_to"><%= l(:label_copied_to) %></label>
52
      <%= mapping_select_tag @import, 'relation_copied_to' %>
53
    </p>
54

  
55
    <p>
56
      <label for="import_settings_mapping_relation_copied_from"><%= l(:label_copied_from) %></label>
57
      <%= mapping_select_tag @import, 'relation_copied_from' %>
58
    </p>
59
  </div>
60
</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
1183 1183
  label_quote_char: Anführungszeichen
1184 1184
  label_double_quote_char: Doppelte Anführungszeichen
1185 1185
  label_fields_mapping: Zuordnung der Felder
1186
  label_relations_mapping: Zuordnung von Beziehungen
1186 1187
  label_file_content_preview: Inhaltsvorschau
1187 1188
  label_create_missing_values: Ergänze fehlende Werte
1188 1189
  button_import: Importieren
config/locales/en.yml
1013 1013
  label_quote_char: Quote
1014 1014
  label_double_quote_char: Double quote
1015 1015
  label_fields_mapping: Fields mapping
1016
  label_relations_mapping: Relations mapping
1016 1017
  label_file_content_preview: File content preview
1017 1018
  label_create_missing_values: Create missing values
1018 1019
  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/fixtures/files/import_subtasks_with_unique_id.csv
1
id;tracker;subject;parent
2
RED-I;bug;Root;
3
RED-II;bug;Child 1;RED-I
4
RED-III;bug;Grand-child;RED-IV
5
RED-IV;bug;Child 2;RED-I
1
id;tracker;subject;parent;follows
2
RED-IV;bug;Grand-child;RED-III;
3
RED-III;bug;Child 2;RED-I;RED-II 1d
4
RED-II;bug;Child 1;RED-I;
5
RED-I;bug;Root;;
6
BLUE-I;bug;Root;;
7
BLUE-II;bug;Child 1;BLUE-I;
8
BLUE-III;bug;Child 2;BLUE-I;BLUE-II 1d
9
BLUE-IV;bug;Grand-child;BLUE-III;
10
GREEN-II;bug;Thing;#1;#2 3d;
test/unit/issue_import_test.rb
128 128
    assert_equal child2, grandchild.parent
129 129
  end
130 130

  
131
  def test_backward_and_forward_reference_with_unique_id
131
  def test_references_with_unique_id
132 132
    import = generate_import_with_mapping('import_subtasks_with_unique_id.csv')
133
    import.settings['mapping'] = {'project_id' => '1', 'unique_id' => '0', 'tracker' => '1', 'subject' => '2', 'parent_issue_id' => '3'}
133
    import.settings['mapping'] = {'project_id' => '1', 'unique_id' => '0', 'tracker' => '1', 'subject' => '2', 'parent_issue_id' => '3', 'relation_follows' => '4'}
134 134
    import.save!
135 135

  
136
    root, child1, grandchild, child2 = new_records(Issue, 4) { import.run }
137
    assert_equal root, child1.parent
138
    assert_equal child2, grandchild.parent
136
    red4, red3, red2, red1, blue1, blue2, blue3, blue4, green = new_records(Issue, 9) { import.run }
137

  
138
    # future references
139
    assert_equal red1, red2.parent
140
    assert_equal red3, red4.parent
141

  
142
    assert IssueRelation.where('issue_from_id' => red2.id, 'issue_to_id' => red3.id, 'delay' => 1, 'relation_type' => 'precedes').present?
143

  
144
    # past references
145
    assert_equal blue1, blue2.parent
146
    assert_equal blue3, blue4.parent
147

  
148
    assert IssueRelation.where('issue_from_id' => blue2.id, 'issue_to_id' => blue3.id, 'delay' => 1, 'relation_type' => 'precedes').present?
149

  
150
    assert_equal issues(:issues_001), green.parent
151
    assert IssueRelation.where('issue_from_id' => issues(:issues_002).id, 'issue_to_id' => green.id, 'delay' => 3, 'relation_type' => 'precedes').present?
152
  end
153

  
154
  def test_follow_relation
155
    import = generate_import_with_mapping('import_subtasks.csv')
156
    import.settings['mapping'] = {'project_id' => '1', 'tracker' => '1', 'subject' => '2', 'relation_relates' => '4'}
157
    import.save!
158

  
159
    one, one_one, one_two_one, one_two = new_records(Issue, 4) { import.run }
160
    assert_equal 2, one.relations.count
161
    assert one.relations.all? { |r| r.relation_type == 'relates' }
162
    assert one.relations.any? { |r| r.other_issue(one) == one_one }
163
    assert one.relations.any? { |r| r.other_issue(one) == one_two }
164

  
165
    assert_equal 2, one_one.relations.count
166
    assert one_one.relations.all? { |r| r.relation_type == 'relates' }
167
    assert one_one.relations.any? { |r| r.other_issue(one_one) == one }
168
    assert one_one.relations.any? { |r| r.other_issue(one_one) == one_two }
169

  
170
    assert_equal 3, one_two.relations.count
171
    assert one_two.relations.all? { |r| r.relation_type == 'relates' }
172
    assert one_two.relations.any? { |r| r.other_issue(one_two) == one }
173
    assert one_two.relations.any? { |r| r.other_issue(one_two) == one_one }
174
    assert one_two.relations.any? { |r| r.other_issue(one_two) == one_two_one }
175

  
176
    assert_equal 1, one_two_one.relations.count
177
    assert one_two_one.relations.all? { |r| r.relation_type == 'relates' }
178
    assert one_two_one.relations.any? { |r| r.other_issue(one_two_one) == one_two }
179
  end
180

  
181
  def test_delayed_relation
182
    import = generate_import_with_mapping('import_subtasks.csv')
183
    import.settings['mapping'] = {'project_id' => '1', 'tracker' => '1', 'subject' => '2', 'relation_precedes' => '5'}
184
    import.save!
185

  
186
    one, one_one, one_two_one, one_two = new_records(Issue, 4) { import.run }
187

  
188
    assert_equal 2, one.relations_to.count
189
    assert one.relations_to.all? { |r| r.relation_type == 'precedes' }
190
    assert one.relations_to.any? { |r| r.issue_from == one_one && r.delay == 2 }
191
    assert one.relations_to.any? { |r| r.issue_from == one_two && r.delay == 1 }
192

  
193

  
194
    assert_equal 1, one_one.relations_from.count
195
    assert one_one.relations_from.all? { |r| r.relation_type == 'precedes' }
196
    assert one_one.relations_from.any? { |r| r.issue_to == one && r.delay == 2 }
197

  
198

  
199
    assert_equal 1, one_two.relations_to.count
200
    assert one_two.relations_to.all? { |r| r.relation_type == 'precedes' }
201
    assert one_two.relations_to.any? { |r| r.issue_from == one_two_one && r.delay == -1 }
202

  
203
    assert_equal 1, one_two.relations_from.count
204
    assert one_two.relations_from.all? { |r| r.relation_type == 'precedes' }
205
    assert one_two.relations_from.any? { |r| r.issue_to == one && r.delay == 1 }
206

  
207

  
208
    assert_equal 1, one_two_one.relations_from.count
209
    assert one_two_one.relations_from.all? { |r| r.relation_type == 'precedes' }
210
    assert one_two_one.relations_from.any? { |r| r.issue_to == one_two && r.delay == -1 }
211
  end
212

  
213
  def test_parent_and_follows_relation
214
    import = generate_import_with_mapping('import_subtasks_with_relations.csv')
215
    import.settings['mapping'] = {
216
      'project_id'       => '1',
217
      'tracker'          => '1',
218

  
219
      'subject'          => '2',
220
      'start_date'       => '3',
221
      'due_date'         => '4',
222
      'parent_issue_id'  => '5',
223
      'relation_follows' => '6'
224
    }
225
    import.save!
226

  
227
    second, first, parent, third = assert_difference('IssueRelation.count', 2) { new_records(Issue, 4) { import.run } }
228

  
229
    # Parent relations
230
    assert_equal parent, first.parent
231
    assert_equal parent, second.parent
232
    assert_equal parent, third.parent
233

  
234
    # Issue relations
235
    assert IssueRelation.where(
236
      :issue_from_id => first.id,
237
      :issue_to_id   => second.id,
238
      :relation_type => 'precedes',
239
      :delay         => 1).present?
240

  
241
    assert IssueRelation.where(
242
      :issue_from_id => second.id,
243
      :issue_to_id   => third.id,
244
      :relation_type => 'precedes',
245
      :delay         => 1).present?
246

  
247

  
248
    # Checking dates, because they might act weird, when relations are added
249
    assert_equal Date.new(2020, 1,  1), parent.start_date
250
    assert_equal Date.new(2020, 1, 31), parent.due_date
251

  
252
    assert_equal Date.new(2020, 1,  1), first.start_date
253
    assert_equal Date.new(2020, 1, 10), first.due_date
254

  
255
    assert_equal Date.new(2020, 1, 12), second.start_date
256
    assert_equal Date.new(2020, 1, 20), second.due_date
257

  
258
    assert_equal Date.new(2020, 1, 22), third.start_date
259
    assert_equal Date.new(2020, 1, 31), third.due_date
139 260
  end
140 261

  
141 262
  def test_assignee_should_be_set
(2-2/5)