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