blob: f902a86077d43e377b6ad021c89f7439ae6391c7 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2016 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
Copybara854996b2021-09-07 19:36:02 +00004
5"""Classes that implement the issue bulk edit page and related forms.
6
7Summary of classes:
8 IssueBulkEdit: Show a form for editing multiple issues and allow the
9 user to update them all at once.
10"""
11from __future__ import print_function
12from __future__ import division
13from __future__ import absolute_import
14
15import collections
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020016from six.moves import http_client
Copybara854996b2021-09-07 19:36:02 +000017import itertools
18import logging
19import time
20
21import ezt
22
23from features import filterrules_helpers
24from features import send_notifications
25from framework import exceptions
26from framework import framework_constants
27from framework import framework_views
28from framework import permissions
29from framework import servlet
30from framework import template_helpers
31from services import tracker_fulltext
32from tracker import field_helpers
33from tracker import tracker_bizobj
34from tracker import tracker_constants
35from tracker import tracker_helpers
36from tracker import tracker_views
37
38
39class IssueBulkEdit(servlet.Servlet):
40 """IssueBulkEdit lists multiple issues and allows an edit to all of them."""
41
42 _PAGE_TEMPLATE = 'tracker/issue-bulk-edit-page.ezt'
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010043 _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
Copybara854996b2021-09-07 19:36:02 +000044 _SECONDS_OVERHEAD = 4
45 _SECONDS_PER_UPDATE = 0.12
46 _SLOWNESS_THRESHOLD = 10
47
48 def AssertBasePermission(self, mr):
49 """Check whether the user has any permission to visit this page.
50
51 Args:
52 mr: commonly used info parsed from the request.
53
54 Raises:
55 PermissionException: if the user is not allowed to enter an issue.
56 """
57 super(IssueBulkEdit, self).AssertBasePermission(mr)
58 can_edit = self.CheckPerm(mr, permissions.EDIT_ISSUE)
59 can_comment = self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT)
60 if not (can_edit and can_comment):
61 raise permissions.PermissionException('bulk edit forbidden')
62
63 def GatherPageData(self, mr):
64 """Build up a dictionary of data values to use when rendering the page.
65
66 Args:
67 mr: commonly used info parsed from the request.
68
69 Returns:
70 Dict of values used by EZT for rendering the page.
71 """
72 with mr.profiler.Phase('getting issues'):
73 if not mr.local_id_list:
74 raise exceptions.InputException()
75 requested_issues = self.services.issue.GetIssuesByLocalIDs(
76 mr.cnxn, mr.project_id, sorted(mr.local_id_list))
77
78 with mr.profiler.Phase('filtering issues'):
79 # TODO(jrobbins): filter out issues that the user cannot edit and
80 # provide that as feedback rather than just siliently ignoring them.
81 open_issues, closed_issues = (
82 tracker_helpers.GetAllowedOpenedAndClosedIssues(
83 mr, [issue.issue_id for issue in requested_issues],
84 self.services))
85 issues = open_issues + closed_issues
86
87 if not issues:
88 self.abort(404, 'no issues found')
89
90 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
91 type_label_set = {
92 lab.lower() for lab in issues[0].labels
93 if lab.lower().startswith('type-')}
94 for issue in issues[1:]:
95 new_type_set = {
96 lab.lower() for lab in issue.labels
97 if lab.lower().startswith('type-')}
98 type_label_set &= new_type_set
99
100 issue_phases = list(
101 itertools.chain.from_iterable(issue.phases for issue in issues))
102
103 field_views = tracker_views.MakeAllFieldValueViews(
104 config, type_label_set, [], [], {}, phases=issue_phases)
105 for fv in field_views:
106 # Explicitly set all field views to not required. We do not want to force
107 # users to have to set it for issues missing required fields.
108 # See https://bugs.chromium.org/p/monorail/issues/detail?id=500 for more
109 # details.
110 fv.field_def.is_required_bool = None
111
112 if permissions.CanEditValueForFieldDef(
113 mr.auth.effective_ids, mr.perms, mr.project, fv.field_def.field_def):
114 fv.is_editable = ezt.boolean(True)
115 else:
116 fv.is_editable = ezt.boolean(False)
117
118 with mr.profiler.Phase('making issue proxies'):
119 issue_views = [
120 template_helpers.EZTItem(
121 local_id=issue.local_id, summary=issue.summary,
122 closed=ezt.boolean(issue in closed_issues))
123 for issue in issues]
124
125 num_seconds = (int(len(issue_views) * self._SECONDS_PER_UPDATE) +
126 self._SECONDS_OVERHEAD)
127
128 page_perms = self.MakePagePerms(
129 mr, None,
130 permissions.CREATE_ISSUE,
131 permissions.DELETE_ISSUE)
132
133 return {
134 'issue_tab_mode': 'issueBulkEdit',
135 'issues': issue_views,
136 'local_ids_str': ','.join([str(issue.local_id) for issue in issues]),
137 'num_issues': len(issue_views),
138 'show_progress': ezt.boolean(num_seconds > self._SLOWNESS_THRESHOLD),
139 'num_seconds': num_seconds,
140
141 'initial_blocked_on': '',
142 'initial_blocking': '',
143 'initial_comment': '',
144 'initial_status': '',
145 'initial_owner': '',
146 'initial_merge_into': '',
147 'initial_cc': '',
148 'initial_components': '',
149 'labels': [],
150 'fields': field_views,
151
152 'restrict_to_known': ezt.boolean(config.restrict_to_known),
153 'page_perms': page_perms,
154 'statuses_offer_merge': config.statuses_offer_merge,
155 'issue_phase_names': list(
156 {phase.name.lower() for phase in issue_phases}),
157 }
158
159 def ProcessFormData(self, mr, post_data):
160 # (...) -> str
161 """Process the posted issue update form.
162
163 Args:
164 mr: commonly used info parsed from the request.
165 post_data: HTML form data from the request.
166
167 Returns:
168 String URL to redirect the user to after processing.
169 """
170 if not mr.local_id_list:
171 logging.info('missing issue local IDs, probably tampered')
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100172 self.response.status_code = http_client.BAD_REQUEST
Copybara854996b2021-09-07 19:36:02 +0000173 return
174
175 # Check that the user is logged in; anon users cannot update issues.
176 if not mr.auth.user_id:
177 logging.info('user was not logged in, cannot update issue')
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100178 self.response.status_code = http_client.BAD_REQUEST
Copybara854996b2021-09-07 19:36:02 +0000179 return
180
181 # Check that the user has permission to add a comment, and to enter
182 # metadata if they are trying to do that.
183 if not self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT):
184 logging.info('user has no permission to add issue comment')
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100185 self.response.status_code = http_client.BAD_REQUEST
Copybara854996b2021-09-07 19:36:02 +0000186 return
187
188 if not self.CheckPerm(mr, permissions.EDIT_ISSUE):
189 logging.info('user has no permission to edit issue metadata')
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100190 self.response.status_code = http_client.BAD_REQUEST
Copybara854996b2021-09-07 19:36:02 +0000191 return
192
193 move_to = post_data.get('move_to', '').lower()
194 if move_to and not self.CheckPerm(mr, permissions.DELETE_ISSUE):
195 logging.info('user has no permission to move issue')
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100196 self.response.status_code = http_client.BAD_REQUEST
Copybara854996b2021-09-07 19:36:02 +0000197 return
198
199 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
200
201 parsed = tracker_helpers.ParseIssueRequest(
202 mr.cnxn, post_data, self.services, mr.errors, mr.project_name)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100203
204 field_helpers.ValidateLabels(
205 mr.cnxn,
206 self.services,
207 mr.project_id,
208 parsed.labels,
209 ezt_errors=mr.errors)
210
Copybara854996b2021-09-07 19:36:02 +0000211 bounce_labels = (
212 parsed.labels[:] +
213 ['-%s' % lr for lr in parsed.labels_remove])
214 bounce_fields = tracker_views.MakeBounceFieldValueViews(
215 parsed.fields.vals, parsed.fields.phase_vals, config)
216 field_helpers.ShiftEnumFieldsIntoLabels(
217 parsed.labels, parsed.labels_remove,
218 parsed.fields.vals, parsed.fields.vals_remove,
219 config)
220 issue_list = self.services.issue.GetIssuesByLocalIDs(
221 mr.cnxn, mr.project_id, mr.local_id_list)
222 issue_phases = list(
223 itertools.chain.from_iterable(issue.phases for issue in issue_list))
224 phase_ids_by_name = collections.defaultdict(set)
225 for phase in issue_phases:
226 phase_ids_by_name[phase.name.lower()].add(phase.phase_id)
227 # Note: Not all parsed phase field values will be applicable to every issue.
228 # tracker_bizobj.ApplyFieldValueChanges will take care of not adding
229 # phase field values to issues that don't contain the correct phase.
230 field_vals = field_helpers.ParseFieldValues(
231 mr.cnxn, self.services.user, parsed.fields.vals,
232 parsed.fields.phase_vals, config,
233 phase_ids_by_name=phase_ids_by_name)
234 field_vals_remove = field_helpers.ParseFieldValues(
235 mr.cnxn, self.services.user, parsed.fields.vals_remove,
236 parsed.fields.phase_vals_remove, config,
237 phase_ids_by_name=phase_ids_by_name)
238
239 field_helpers.AssertCustomFieldsEditPerms(
240 mr, config, field_vals, field_vals_remove, parsed.fields.fields_clear,
241 parsed.labels, parsed.labels_remove)
242 field_helpers.ValidateCustomFields(
243 mr.cnxn, self.services, field_vals, config, mr.project,
244 ezt_errors=mr.errors)
245
246 # Treat status '' as no change and explicit 'clear' as clearing the status.
247 status = parsed.status
248 if status == '':
249 status = None
250 if post_data.get('op_statusenter') == 'clear':
251 status = ''
252
253 reporter_id = mr.auth.user_id
254 logging.info('bulk edit request by %s', reporter_id)
255
256 if parsed.users.owner_id is None:
257 mr.errors.owner = 'Invalid owner username'
258 else:
259 valid, msg = tracker_helpers.IsValidIssueOwner(
260 mr.cnxn, mr.project, parsed.users.owner_id, self.services)
261 if not valid:
262 mr.errors.owner = msg
263
264 if (status in config.statuses_offer_merge and
265 not post_data.get('merge_into')):
266 mr.errors.merge_into_id = 'Please enter a valid issue ID'
267
268 move_to_project = None
269 if move_to:
270 if mr.project_name == move_to:
271 mr.errors.move_to = 'The issues are already in project ' + move_to
272 else:
273 move_to_project = self.services.project.GetProjectByName(
274 mr.cnxn, move_to)
275 if not move_to_project:
276 mr.errors.move_to = 'No such project: ' + move_to
277
278 # Treat owner '' as no change, and explicit 'clear' as NO_USER_SPECIFIED
279 owner_id = parsed.users.owner_id
280 if parsed.users.owner_username == '':
281 owner_id = None
282 if post_data.get('op_ownerenter') == 'clear':
283 owner_id = framework_constants.NO_USER_SPECIFIED
284
285 comp_ids = tracker_helpers.LookupComponentIDs(
286 parsed.components.paths, config, mr.errors)
287 comp_ids_remove = tracker_helpers.LookupComponentIDs(
288 parsed.components.paths_remove, config, mr.errors)
289 if post_data.get('op_componententer') == 'remove':
290 comp_ids, comp_ids_remove = comp_ids_remove, comp_ids
291
292 cc_ids, cc_ids_remove = parsed.users.cc_ids, parsed.users.cc_ids_remove
293 if post_data.get('op_memberenter') == 'remove':
294 cc_ids, cc_ids_remove = parsed.users.cc_ids_remove, parsed.users.cc_ids
295
296 issue_list_iids = {issue.issue_id for issue in issue_list}
297 if post_data.get('op_blockedonenter') == 'append':
298 if issue_list_iids.intersection(parsed.blocked_on.iids):
299 mr.errors.blocked_on = 'Cannot block an issue on itself.'
300 blocked_on_add = parsed.blocked_on.iids
301 blocked_on_remove = []
302 else:
303 blocked_on_add = []
304 blocked_on_remove = parsed.blocked_on.iids
305 if post_data.get('op_blockingenter') == 'append':
306 if issue_list_iids.intersection(parsed.blocking.iids):
307 mr.errors.blocking = 'Cannot block an issue on itself.'
308 blocking_add = parsed.blocking.iids
309 blocking_remove = []
310 else:
311 blocking_add = []
312 blocking_remove = parsed.blocking.iids
313
314 if len(parsed.comment) > tracker_constants.MAX_COMMENT_CHARS:
315 mr.errors.comment = 'Comment is too long.'
316
317 iids_actually_changed = []
318 old_owner_ids = []
319 combined_amendments = []
320 merge_into_issue = None
321 new_starrers = set()
322
323 if not mr.errors.AnyErrors():
324 # Because we will modify issues, load from DB rather than cache.
325 issue_list = self.services.issue.GetIssuesByLocalIDs(
326 mr.cnxn, mr.project_id, mr.local_id_list, use_cache=False)
327
328 # Skip any individual issues that the user is not allowed to edit.
329 editable_issues = [
330 issue for issue in issue_list
331 if permissions.CanEditIssue(
332 mr.auth.effective_ids, mr.perms, mr.project, issue)]
333
334 # Skip any restrict issues that cannot be moved
335 if move_to:
336 editable_issues = [
337 issue for issue in editable_issues
338 if not permissions.GetRestrictions(issue)]
339
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100340 # Check that we can modify issues we want to block with.
341 if post_data.get('blocked_on'):
342 for issue_ref in post_data.get('blocked_on').split(','):
343 if not issue_ref:
344 continue
345 project_name, iid = tracker_bizobj.ParseIssueRef(issue_ref)
346 project_name = project_name or mr.project_name
347 project = self.services.project.GetProjectByName(
348 mr.cnxn, project_name)
349 issue = self.services.issue.GetIssueByLocalID(
350 mr.cnxn, project.project_id, iid, use_cache=False)
351 if not self._CheckEditIssuePermissions(mr, project, issue):
352 mr.errors.blocked_on = 'Target issue %s cannot be modified' % (iid)
353 break
354
355 if post_data.get('blocking'):
356 for issue_ref in post_data.get('blocking').split(','):
357 if not issue_ref:
358 continue
359 project_name, iid = tracker_bizobj.ParseIssueRef(issue_ref)
360 project_name = project_name or mr.project_name
361 project = self.services.project.GetProjectByName(
362 mr.cnxn, project_name)
363 issue = self.services.issue.GetIssueByLocalID(
364 mr.cnxn, project.project_id, iid, use_cache=False)
365 if not self._CheckEditIssuePermissions(mr, project, issue):
366 mr.errors.blocking = 'Target issue %s cannot be modified' % (iid)
367 break
368
Copybara854996b2021-09-07 19:36:02 +0000369 # If 'Duplicate' status is specified ensure there are no permission issues
370 # with the issue we want to merge with.
371 if post_data.get('merge_into'):
372 for issue in editable_issues:
373 _, merge_into_issue = tracker_helpers.ParseMergeFields(
374 mr.cnxn, self.services, mr.project_name, post_data, parsed.status,
375 config, issue, mr.errors)
376 if merge_into_issue:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100377 project = self.services.project.GetProjectByName(
378 mr.cnxn, issue.project_name)
379 if not self._CheckEditIssuePermissions(mr, project,
380 merge_into_issue):
Copybara854996b2021-09-07 19:36:02 +0000381 mr.errors.merge_into_id = 'Target issue %s cannot be modified' % (
382 merge_into_issue.local_id)
383 break
384
385 # Update the new_starrers set.
386 new_starrers.update(tracker_helpers.GetNewIssueStarrers(
387 mr.cnxn, self.services, [issue.issue_id],
388 merge_into_issue.issue_id))
389
390 # Proceed with amendments only if there are no reported errors.
391 if not mr.errors.AnyErrors():
392 # Sort the issues: we want them in this order so that the
393 # corresponding old_owner_id are found in the same order.
394 editable_issues.sort(key=lambda issue: issue.local_id)
395
396 iids_to_invalidate = set()
397 rules = self.services.features.GetFilterRules(
398 mr.cnxn, config.project_id)
399 predicate_asts = filterrules_helpers.ParsePredicateASTs(
400 rules, config, [])
401 for issue in editable_issues:
402 old_owner_id = tracker_bizobj.GetOwnerId(issue)
403 merge_into_iid = (
404 merge_into_issue.issue_id if merge_into_issue else None)
405
406 delta = tracker_bizobj.MakeIssueDelta(
407 status, owner_id, cc_ids, cc_ids_remove, comp_ids, comp_ids_remove,
408 parsed.labels, parsed.labels_remove, field_vals, field_vals_remove,
409 parsed.fields.fields_clear, blocked_on_add, blocked_on_remove,
410 blocking_add, blocking_remove, merge_into_iid, None)
411 amendments, _ = self.services.issue.DeltaUpdateIssue(
412 mr.cnxn, self.services, mr.auth.user_id, mr.project_id, config,
413 issue, delta, comment=parsed.comment,
414 iids_to_invalidate=iids_to_invalidate, rules=rules,
415 predicate_asts=predicate_asts)
416
417 if amendments or parsed.comment: # Avoid empty comments.
418 iids_actually_changed.append(issue.issue_id)
419 old_owner_ids.append(old_owner_id)
420 combined_amendments.extend(amendments)
421
422 self.services.issue.InvalidateIIDs(mr.cnxn, iids_to_invalidate)
423 self.services.project.UpdateRecentActivity(
424 mr.cnxn, mr.project.project_id)
425
426 # Add new_starrers and new CCs to merge_into_issue.
427 if merge_into_issue:
428 merge_into_project = self.services.project.GetProjectByName(
429 mr.cnxn, merge_into_issue.project_name)
430 tracker_helpers.AddIssueStarrers(
431 mr.cnxn, self.services, mr, merge_into_issue.issue_id,
432 merge_into_project, new_starrers)
433 # Load target issue again to get the updated star count.
434 merge_into_issue = self.services.issue.GetIssue(
435 mr.cnxn, merge_into_issue.issue_id, use_cache=False)
436 tracker_helpers.MergeCCsAndAddCommentMultipleIssues(
437 self.services, mr, editable_issues, merge_into_issue)
438
439 if move_to and editable_issues:
440 tracker_fulltext.UnindexIssues(
441 [issue.issue_id for issue in editable_issues])
442 for issue in editable_issues:
443 old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
444 moved_back_iids = self.services.issue.MoveIssues(
445 mr.cnxn, move_to_project, [issue], self.services.user)
446 new_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
447 if issue.issue_id in moved_back_iids:
448 content = 'Moved %s back to %s again.' % (
449 old_text_ref, new_text_ref)
450 else:
451 content = 'Moved %s to now be %s.' % (old_text_ref, new_text_ref)
452 self.services.issue.CreateIssueComment(
453 mr.cnxn, issue, mr.auth.user_id, content, amendments=[
454 tracker_bizobj.MakeProjectAmendment(
455 move_to_project.project_name)])
456
457 send_email = 'send_email' in post_data
458
459 users_by_id = framework_views.MakeAllUserViews(
460 mr.cnxn, self.services.user,
461 [owner_id], cc_ids, cc_ids_remove, old_owner_ids,
462 tracker_bizobj.UsersInvolvedInAmendments(combined_amendments))
463 if move_to and editable_issues:
464 iids_actually_changed = [
465 issue.issue_id for issue in editable_issues]
466
467 send_notifications.SendIssueBulkChangeNotification(
468 iids_actually_changed, mr.request.host,
469 old_owner_ids, parsed.comment,
470 reporter_id, combined_amendments, send_email, users_by_id)
471
472 if mr.errors.AnyErrors():
473 bounce_cc_parts = (
474 parsed.users.cc_usernames +
475 ['-%s' % ccur for ccur in parsed.users.cc_usernames_remove])
476 self.PleaseCorrect(
477 mr, initial_status=parsed.status,
478 initial_owner=parsed.users.owner_username,
479 initial_merge_into=post_data.get('merge_into', 0),
480 initial_cc=', '.join(bounce_cc_parts),
481 initial_comment=parsed.comment,
482 initial_components=parsed.components.entered_str,
483 labels=bounce_labels,
484 fields=bounce_fields)
485 return
486
487 with mr.profiler.Phase('reindexing issues'):
488 logging.info('starting reindexing')
489 start = time.time()
490 # Get the updated issues and index them
491 issue_list = self.services.issue.GetIssuesByLocalIDs(
492 mr.cnxn, mr.project_id, mr.local_id_list)
493 tracker_fulltext.IndexIssues(
494 mr.cnxn, issue_list, self.services.user, self.services.issue,
495 self.services.config)
496 logging.info('reindexing %d issues took %s sec',
497 len(issue_list), time.time() - start)
498
499 # TODO(jrobbins): These could be put into the form action attribute.
500 mr.can = int(post_data['can'])
501 mr.query = post_data['q']
502 mr.col_spec = post_data['colspec']
503 mr.sort_spec = post_data['sort']
504 mr.group_by_spec = post_data['groupby']
505 mr.start = int(post_data['start'])
506 mr.num = int(post_data['num'])
507
508 # TODO(jrobbins): implement bulk=N param for a better confirmation alert.
509 return tracker_helpers.FormatIssueListURL(
510 mr, config, saved=len(mr.local_id_list), ts=int(time.time()))
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200511
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100512 def GetIssueBulkEdit(self, **kwargs):
513 return self.handler(**kwargs)
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200514
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100515 def PostIssueBulkEdit(self, **kwargs):
516 return self.handler(**kwargs)
517
518 def _CheckEditIssuePermissions(self, mr, project, issue):
519 config = self.services.config.GetProjectConfig(mr.cnxn, project.project_id)
520 granted_perms = tracker_bizobj.GetGrantedPerms(
521 issue, mr.auth.effective_ids, config)
522 return tracker_helpers.CanEditProjectIssue(
523 mr, project, issue, granted_perms)