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