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