Feature #28198 » 0001-Rebased-patch-from-28198.patch
app/models/import.rb | ||
---|---|---|
212 | 212 |
item.save! |
213 | 213 |
imported += 1 |
214 | 214 | |
215 |
extend_object(row, item, object) if object.persisted? |
|
215 | 216 |
do_callbacks(use_unique_id? ? item.unique_id : item.position, object) |
216 | 217 |
end |
217 | 218 |
current = position |
... | ... | |
270 | 271 | |
271 | 272 |
# Builds a record for the given row and returns it |
272 | 273 |
# To be implemented by subclasses |
273 |
def build_object(row) |
|
274 |
def build_object(row, item) |
|
275 |
end |
|
276 | ||
277 |
# Extends object with properties, that may only be handled after it's been |
|
278 |
# persisted. |
|
279 |
def extend_object(row, item, object) |
|
274 | 280 |
end |
275 | 281 | |
276 | 282 |
# Generates a filename used to store the import file |
app/models/issue_import.rb | ||
---|---|---|
230 | 230 |
issue |
231 | 231 |
end |
232 | 232 | |
233 |
def extend_object(row, item, issue) |
|
234 |
build_relations(row, item, issue) |
|
235 |
end |
|
236 | ||
237 |
def build_relations(row, item, issue) |
|
238 |
IssueRelation::TYPES.each_key do |type| |
|
239 |
has_delay = [IssueRelation::TYPE_PRECEDES, IssueRelation::TYPE_FOLLOWS].include?(type) |
|
240 | ||
241 |
if decls = relation_values(row, "relation_#{type}") |
|
242 |
decls.each do |decl| |
|
243 |
unless decl[:matches] |
|
244 |
# Invalid relation syntax - doesn't match regexp |
|
245 |
next |
|
246 |
end |
|
247 | ||
248 |
if decl[:delay] && !has_delay |
|
249 |
# Invalid relation syntax - delay for relation that doesn't support delays |
|
250 |
next |
|
251 |
end |
|
252 | ||
253 |
relation = IssueRelation.new( |
|
254 |
"relation_type" => type, |
|
255 |
"issue_from_id" => issue.id |
|
256 |
) |
|
257 | ||
258 |
if decl[:other_id] |
|
259 |
relation.issue_to_id = decl[:other_id] |
|
260 |
elsif decl[:other_pos] |
|
261 |
if use_unique_id? |
|
262 |
issue_id = items.where(:unique_id => decl[:other_pos]).first.try(:obj_id) |
|
263 |
if issue_id |
|
264 |
relation.issue_to_id = issue_id |
|
265 |
else |
|
266 |
add_callback(decl[:other_pos], 'set_relation', item.position, type, decl[:delay]) |
|
267 |
next |
|
268 |
end |
|
269 |
elsif decl[:other_pos] > item.position |
|
270 |
add_callback(decl[:other_pos], 'set_relation', item.position, type, decl[:delay]) |
|
271 |
next |
|
272 |
elsif issue_id = items.where(:position => decl[:other_pos]).first.try(:obj_id) |
|
273 |
relation.issue_to_id = issue_id |
|
274 |
end |
|
275 |
end |
|
276 | ||
277 |
relation.delay = decl[:delay] if decl[:delay] |
|
278 | ||
279 |
relation.save! |
|
280 |
end |
|
281 |
end |
|
282 |
end |
|
283 | ||
284 |
issue |
|
285 |
end |
|
286 | ||
287 |
def relation_values(row, name) |
|
288 |
content = row_value(row, name) |
|
289 | ||
290 |
return if content.blank? |
|
291 | ||
292 |
content.split(",").map do |declaration| |
|
293 |
declaration = declaration.strip |
|
294 | ||
295 |
# Valid expression: |
|
296 |
# |
|
297 |
# 123 => row 123 within the CSV |
|
298 |
# #123 => issue with ID 123 |
|
299 |
# |
|
300 |
# For precedes and follows |
|
301 |
# |
|
302 |
# 123 7d => row 123 within CSV with 7 day delay |
|
303 |
# #123 7d => issue with ID 123 with 7 day delay |
|
304 |
# 123 -3d => negative delay allowed |
|
305 |
# |
|
306 |
# |
|
307 |
# Invalid expression: |
|
308 |
# |
|
309 |
# No. 123 => Invalid leading letters |
|
310 |
# # 123 => Invalid space between # and issue number |
|
311 |
# 123 8h => No other time units allowed (just days) |
|
312 |
# |
|
313 |
# Please note: If unique_id mapping is present, the whole line - but the |
|
314 |
# trailing delay expression - is considered unique_id. |
|
315 |
# |
|
316 |
# See examples at Rubular http://rubular.com/r/mgXM5Rp6zK |
|
317 |
# |
|
318 |
match = declaration.match(/\A(?<unique_id>(?<is_id>#)?(?<id>\d+)|.+?)(?:\s+(?<delay>-?\d+)d)?\z/) |
|
319 | ||
320 |
result = { |
|
321 |
:matches => false, |
|
322 |
:declaration => declaration |
|
323 |
} |
|
324 | ||
325 |
if match |
|
326 |
result[:matches] = true |
|
327 |
result[:delay] = match[:delay] |
|
328 | ||
329 |
if match[:is_id] && match[:id] |
|
330 |
result[:other_id] = match[:id] |
|
331 |
elsif use_unique_id? && match[:unique_id] |
|
332 |
result[:other_pos] = match[:unique_id] |
|
333 |
elsif match[:id] |
|
334 |
result[:other_pos] = match[:id].to_i |
|
335 |
else |
|
336 |
result[:matches] = false |
|
337 |
end |
|
338 |
end |
|
339 | ||
340 |
result |
|
341 |
end |
|
342 |
end |
|
343 | ||
233 | 344 |
# Callback that sets issue as the parent of a previously imported issue |
234 | 345 |
def set_as_parent_callback(issue, child_position) |
235 | 346 |
child_id = items.where(:position => child_position).first.try(:obj_id) |
... | ... | |
242 | 353 |
child.save! |
243 | 354 |
issue.reload |
244 | 355 |
end |
356 | ||
357 |
def set_relation_callback(to_issue, from_position, type, delay) |
|
358 |
return if to_issue.new_record? |
|
359 | ||
360 |
from_id = items.where(:position => from_position).first.try(:obj_id) |
|
361 |
return unless from_id |
|
362 | ||
363 |
IssueRelation.create!( |
|
364 |
'relation_type' => type, |
|
365 |
'issue_from_id' => from_id, |
|
366 |
'issue_to_id' => to_issue.id, |
|
367 |
'delay' => delay |
|
368 |
) |
|
369 |
to_issue.reload |
|
370 |
end |
|
245 | 371 |
end |
app/views/imports/_issues_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> |
app/views/imports/_issues_mapping.html.erb | ||
---|---|---|
5 | 5 |
</div> |
6 | 6 |
</fieldset> |
7 | 7 | |
8 |
<fieldset class="box tabular collapsible collapsed"> |
|
9 |
<legend onclick="toggleFieldset(this);" class="icon icon-collapsed"><%= l(:label_relations_mapping) %></legend> |
|
10 |
<div id="relations-mapping" style="display: none;"> |
|
11 |
<%= render :partial => 'issues_relations_mapping' %> |
|
12 |
</div> |
|
13 |
</fieldset> |
|
14 | ||
8 | 15 |
<%= javascript_tag do %> |
9 | 16 |
$('#fields-mapping').on('change', '#import_mapping_project_id, #import_mapping_tracker', function(){ |
10 | 17 |
$.ajax({ |
app/views/imports/_issues_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> |
config/locales/de.yml | ||
---|---|---|
1190 | 1190 |
label_quote_char: Anführungszeichen |
1191 | 1191 |
label_double_quote_char: Doppelte Anführungszeichen |
1192 | 1192 |
label_fields_mapping: Zuordnung der Felder |
1193 |
label_relations_mapping: Zuordnung von Beziehungen |
|
1193 | 1194 |
label_file_content_preview: Inhaltsvorschau |
1194 | 1195 |
label_create_missing_values: Ergänze fehlende Werte |
1195 | 1196 |
button_import: Importieren |
config/locales/en.yml | ||
---|---|---|
1053 | 1053 |
label_quote_char: Quote |
1054 | 1054 |
label_double_quote_char: Double quote |
1055 | 1055 |
label_fields_mapping: Fields mapping |
1056 |
label_relations_mapping: Relations mapping |
|
1056 | 1057 |
label_file_content_preview: File content preview |
1057 | 1058 |
label_create_missing_values: Create missing values |
1058 | 1059 |
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 | ||
---|---|---|
146 | 146 |
assert_equal child2, grandchild.parent |
147 | 147 |
end |
148 | 148 | |
149 |
def test_backward_and_forward_reference_with_unique_id
|
|
149 |
def test_references_with_unique_id
|
|
150 | 150 |
import = generate_import_with_mapping('import_subtasks_with_unique_id.csv') |
151 |
import.settings['mapping'] = {'project_id' => '1', 'unique_id' => '0', 'tracker' => '1', 'subject' => '2', 'parent_issue_id' => '3'} |
|
151 |
import.settings['mapping'] = {'project_id' => '1', 'unique_id' => '0', 'tracker' => '1', 'subject' => '2', 'parent_issue_id' => '3', 'relation_follows' => '4'}
|
|
152 | 152 |
import.save! |
153 | 153 | |
154 |
root, child1, grandchild, child2 = new_records(Issue, 4) { import.run } |
|
155 |
assert_equal root, child1.parent |
|
156 |
assert_equal child2, grandchild.parent |
|
154 |
red4, red3, red2, red1, blue1, blue2, blue3, blue4, green = new_records(Issue, 9) { import.run } |
|
155 | ||
156 |
# future references |
|
157 |
assert_equal red1, red2.parent |
|
158 |
assert_equal red3, red4.parent |
|
159 | ||
160 |
assert IssueRelation.where('issue_from_id' => red2.id, 'issue_to_id' => red3.id, 'delay' => 1, 'relation_type' => 'precedes').present? |
|
161 | ||
162 |
# past references |
|
163 |
assert_equal blue1, blue2.parent |
|
164 |
assert_equal blue3, blue4.parent |
|
165 | ||
166 |
assert IssueRelation.where('issue_from_id' => blue2.id, 'issue_to_id' => blue3.id, 'delay' => 1, 'relation_type' => 'precedes').present? |
|
167 | ||
168 |
assert_equal issues(:issues_001), green.parent |
|
169 |
assert IssueRelation.where('issue_from_id' => issues(:issues_002).id, 'issue_to_id' => green.id, 'delay' => 3, 'relation_type' => 'precedes').present? |
|
170 |
end |
|
171 | ||
172 |
def test_follow_relation |
|
173 |
import = generate_import_with_mapping('import_subtasks.csv') |
|
174 |
import.settings['mapping'] = {'project_id' => '1', 'tracker' => '1', 'subject' => '2', 'relation_relates' => '4'} |
|
175 |
import.save! |
|
176 | ||
177 |
one, one_one, one_two_one, one_two = new_records(Issue, 4) { import.run } |
|
178 |
assert_equal 2, one.relations.count |
|
179 |
assert one.relations.all? { |r| r.relation_type == 'relates' } |
|
180 |
assert one.relations.any? { |r| r.other_issue(one) == one_one } |
|
181 |
assert one.relations.any? { |r| r.other_issue(one) == one_two } |
|
182 | ||
183 |
assert_equal 2, one_one.relations.count |
|
184 |
assert one_one.relations.all? { |r| r.relation_type == 'relates' } |
|
185 |
assert one_one.relations.any? { |r| r.other_issue(one_one) == one } |
|
186 |
assert one_one.relations.any? { |r| r.other_issue(one_one) == one_two } |
|
187 | ||
188 |
assert_equal 3, one_two.relations.count |
|
189 |
assert one_two.relations.all? { |r| r.relation_type == 'relates' } |
|
190 |
assert one_two.relations.any? { |r| r.other_issue(one_two) == one } |
|
191 |
assert one_two.relations.any? { |r| r.other_issue(one_two) == one_one } |
|
192 |
assert one_two.relations.any? { |r| r.other_issue(one_two) == one_two_one } |
|
193 | ||
194 |
assert_equal 1, one_two_one.relations.count |
|
195 |
assert one_two_one.relations.all? { |r| r.relation_type == 'relates' } |
|
196 |
assert one_two_one.relations.any? { |r| r.other_issue(one_two_one) == one_two } |
|
197 |
end |
|
198 | ||
199 |
def test_delayed_relation |
|
200 |
import = generate_import_with_mapping('import_subtasks.csv') |
|
201 |
import.settings['mapping'] = {'project_id' => '1', 'tracker' => '1', 'subject' => '2', 'relation_precedes' => '5'} |
|
202 |
import.save! |
|
203 | ||
204 |
one, one_one, one_two_one, one_two = new_records(Issue, 4) { import.run } |
|
205 | ||
206 |
assert_equal 2, one.relations_to.count |
|
207 |
assert one.relations_to.all? { |r| r.relation_type == 'precedes' } |
|
208 |
assert one.relations_to.any? { |r| r.issue_from == one_one && r.delay == 2 } |
|
209 |
assert one.relations_to.any? { |r| r.issue_from == one_two && r.delay == 1 } |
|
210 | ||
211 | ||
212 |
assert_equal 1, one_one.relations_from.count |
|
213 |
assert one_one.relations_from.all? { |r| r.relation_type == 'precedes' } |
|
214 |
assert one_one.relations_from.any? { |r| r.issue_to == one && r.delay == 2 } |
|
215 | ||
216 | ||
217 |
assert_equal 1, one_two.relations_to.count |
|
218 |
assert one_two.relations_to.all? { |r| r.relation_type == 'precedes' } |
|
219 |
assert one_two.relations_to.any? { |r| r.issue_from == one_two_one && r.delay == -1 } |
|
220 | ||
221 |
assert_equal 1, one_two.relations_from.count |
|
222 |
assert one_two.relations_from.all? { |r| r.relation_type == 'precedes' } |
|
223 |
assert one_two.relations_from.any? { |r| r.issue_to == one && r.delay == 1 } |
|
224 | ||
225 | ||
226 |
assert_equal 1, one_two_one.relations_from.count |
|
227 |
assert one_two_one.relations_from.all? { |r| r.relation_type == 'precedes' } |
|
228 |
assert one_two_one.relations_from.any? { |r| r.issue_to == one_two && r.delay == -1 } |
|
229 |
end |
|
230 | ||
231 |
def test_parent_and_follows_relation |
|
232 |
import = generate_import_with_mapping('import_subtasks_with_relations.csv') |
|
233 |
import.settings['mapping'] = { |
|
234 |
'project_id' => '1', |
|
235 |
'tracker' => '1', |
|
236 | ||
237 |
'subject' => '2', |
|
238 |
'start_date' => '3', |
|
239 |
'due_date' => '4', |
|
240 |
'parent_issue_id' => '5', |
|
241 |
'relation_follows' => '6' |
|
242 |
} |
|
243 |
import.save! |
|
244 | ||
245 |
second, first, parent, third = assert_difference('IssueRelation.count', 2) { new_records(Issue, 4) { import.run } } |
|
246 | ||
247 |
# Parent relations |
|
248 |
assert_equal parent, first.parent |
|
249 |
assert_equal parent, second.parent |
|
250 |
assert_equal parent, third.parent |
|
251 | ||
252 |
# Issue relations |
|
253 |
assert IssueRelation.where( |
|
254 |
:issue_from_id => first.id, |
|
255 |
:issue_to_id => second.id, |
|
256 |
:relation_type => 'precedes', |
|
257 |
:delay => 1).present? |
|
258 | ||
259 |
assert IssueRelation.where( |
|
260 |
:issue_from_id => second.id, |
|
261 |
:issue_to_id => third.id, |
|
262 |
:relation_type => 'precedes', |
|
263 |
:delay => 1).present? |
|
264 | ||
265 | ||
266 |
# Checking dates, because they might act weird, when relations are added |
|
267 |
assert_equal Date.new(2020, 1, 1), parent.start_date |
|
268 |
assert_equal Date.new(2020, 1, 31), parent.due_date |
|
269 | ||
270 |
assert_equal Date.new(2020, 1, 1), first.start_date |
|
271 |
assert_equal Date.new(2020, 1, 10), first.due_date |
|
272 | ||
273 |
assert_equal Date.new(2020, 1, 12), second.start_date |
|
274 |
assert_equal Date.new(2020, 1, 20), second.due_date |
|
275 | ||
276 |
assert_equal Date.new(2020, 1, 22), third.start_date |
|
277 |
assert_equal Date.new(2020, 1, 31), third.due_date |
|
157 | 278 |
end |
158 | 279 | |
159 | 280 |
def test_assignee_should_be_set |
- « Previous
- 1
- …
- 3
- 4
- 5
- Next »