0001-Support-issue-relations-when-importing-issues.patch

Gregor Schmidt, 2018-02-16 13:02

Download (16.2 KB)

View differences:

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')
134
-