Index: appengine/monorail/tracker/issuebulkedit.py |
diff --git a/appengine/monorail/tracker/issuebulkedit.py b/appengine/monorail/tracker/issuebulkedit.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..7d4871bcc158a6b0c1cb0ce344aaa7770938cb94 |
--- /dev/null |
+++ b/appengine/monorail/tracker/issuebulkedit.py |
@@ -0,0 +1,418 @@ |
+# Copyright 2016 The Chromium Authors. All rights reserved. |
+# Use of this source code is govered by a BSD-style |
+# license that can be found in the LICENSE file or at |
+# https://developers.google.com/open-source/licenses/bsd |
+ |
+"""Classes that implement the issue bulk edit page and related forms. |
+ |
+Summary of classes: |
+ IssueBulkEdit: Show a form for editing multiple issues and allow the |
+ user to update them all at once. |
+""" |
+ |
+import httplib |
+import logging |
+import time |
+ |
+from third_party import ezt |
+ |
+from features import filterrules_helpers |
+from features import notify |
+from framework import actionlimit |
+from framework import framework_constants |
+from framework import framework_views |
+from framework import monorailrequest |
+from framework import permissions |
+from framework import servlet |
+from framework import template_helpers |
+from services import tracker_fulltext |
+from tracker import field_helpers |
+from tracker import tracker_bizobj |
+from tracker import tracker_helpers |
+from tracker import tracker_views |
+ |
+ |
+class IssueBulkEdit(servlet.Servlet): |
+ """IssueBulkEdit lists multiple issues and allows an edit to all of them.""" |
+ |
+ _PAGE_TEMPLATE = 'tracker/issue-bulk-edit-page.ezt' |
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES |
+ _CAPTCHA_ACTION_TYPES = [actionlimit.ISSUE_BULK_EDIT] |
+ |
+ _SECONDS_OVERHEAD = 4 |
+ _SECONDS_PER_UPDATE = 0.12 |
+ _SLOWNESS_THRESHOLD = 10 |
+ |
+ def AssertBasePermission(self, mr): |
+ """Check whether the user has any permission to visit this page. |
+ |
+ Args: |
+ mr: commonly used info parsed from the request. |
+ |
+ Raises: |
+ PermissionException: if the user is not allowed to enter an issue. |
+ """ |
+ super(IssueBulkEdit, self).AssertBasePermission(mr) |
+ can_edit = self.CheckPerm(mr, permissions.EDIT_ISSUE) |
+ can_comment = self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT) |
+ if not (can_edit and can_comment): |
+ raise permissions.PermissionException('bulk edit forbidden') |
+ |
+ def GatherPageData(self, mr): |
+ """Build up a dictionary of data values to use when rendering the page. |
+ |
+ Args: |
+ mr: commonly used info parsed from the request. |
+ |
+ Returns: |
+ Dict of values used by EZT for rendering the page. |
+ """ |
+ with self.profiler.Phase('getting issues'): |
+ if not mr.local_id_list: |
+ raise monorailrequest.InputException() |
+ requested_issues = self.services.issue.GetIssuesByLocalIDs( |
+ mr.cnxn, mr.project_id, sorted(mr.local_id_list)) |
+ |
+ with self.profiler.Phase('filtering issues'): |
+ # TODO(jrobbins): filter out issues that the user cannot edit and |
+ # provide that as feedback rather than just siliently ignoring them. |
+ open_issues, closed_issues = ( |
+ tracker_helpers.GetAllowedOpenedAndClosedIssues( |
+ mr, [issue.issue_id for issue in requested_issues], |
+ self.services)) |
+ issues = open_issues + closed_issues |
+ |
+ if not issues: |
+ self.abort(404, 'no issues found') |
+ |
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) |
+ type_label_set = { |
+ lab.lower() for lab in issues[0].labels |
+ if lab.lower().startswith('type-')} |
+ for issue in issues[1:]: |
+ new_type_set = { |
+ lab.lower() for lab in issue.labels |
+ if lab.lower().startswith('type-')} |
+ type_label_set &= new_type_set |
+ |
+ field_views = [ |
+ tracker_views.MakeFieldValueView( |
+ fd, config, type_label_set, [], [], {}) |
+ # TODO(jrobbins): field-level view restrictions, display options |
+ # TODO(jrobbins): custom fields in templates supply values to view. |
+ for fd in config.field_defs |
+ if not fd.is_deleted] |
+ # Explicitly set all field views to not required. We do not want to force |
+ # users to have to set it for issues missing required fields. |
+ # See https://bugs.chromium.org/p/monorail/issues/detail?id=500 for more |
+ # details. |
+ for fv in field_views: |
+ fv.field_def.is_required_bool = None |
+ |
+ with self.profiler.Phase('making issue proxies'): |
+ issue_views = [ |
+ template_helpers.EZTItem( |
+ local_id=issue.local_id, summary=issue.summary, |
+ closed=ezt.boolean(issue in closed_issues)) |
+ for issue in issues] |
+ |
+ num_seconds = (int(len(issue_views) * self._SECONDS_PER_UPDATE) + |
+ self._SECONDS_OVERHEAD) |
+ |
+ page_perms = self.MakePagePerms( |
+ mr, None, |
+ permissions.CREATE_ISSUE, |
+ permissions.DELETE_ISSUE) |
+ |
+ return { |
+ 'issue_tab_mode': 'issueBulkEdit', |
+ 'issues': issue_views, |
+ 'num_issues': len(issue_views), |
+ 'show_progress': ezt.boolean(num_seconds > self._SLOWNESS_THRESHOLD), |
+ 'num_seconds': num_seconds, |
+ |
+ 'initial_comment': '', |
+ 'initial_status': '', |
+ 'initial_owner': '', |
+ 'initial_merge_into': '', |
+ 'initial_cc': '', |
+ 'initial_components': '', |
+ 'labels': [], |
+ 'fields': field_views, |
+ |
+ 'restrict_to_known': ezt.boolean(config.restrict_to_known), |
+ 'page_perms': page_perms, |
+ 'statuses_offer_merge': config.statuses_offer_merge, |
+ } |
+ |
+ def ProcessFormData(self, mr, post_data): |
+ """Process the posted issue update form. |
+ |
+ Args: |
+ mr: commonly used info parsed from the request. |
+ post_data: HTML form data from the request. |
+ |
+ Returns: |
+ String URL to redirect the user to after processing. |
+ """ |
+ if not mr.local_id_list: |
+ logging.info('missing issue local IDs, probably tampered') |
+ self.response.status = httplib.BAD_REQUEST |
+ return |
+ |
+ # Check that the user is logged in; anon users cannot update issues. |
+ if not mr.auth.user_id: |
+ logging.info('user was not logged in, cannot update issue') |
+ self.response.status = httplib.BAD_REQUEST # xxx should raise except |
+ return |
+ |
+ self.CountRateLimitedActions( |
+ mr, {actionlimit.ISSUE_BULK_EDIT: len(mr.local_id_list)}) |
+ |
+ # Check that the user has permission to add a comment, and to enter |
+ # metadata if they are trying to do that. |
+ if not self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT): |
+ logging.info('user has no permission to add issue comment') |
+ self.response.status = httplib.BAD_REQUEST |
+ return |
+ |
+ if not self.CheckPerm(mr, permissions.EDIT_ISSUE): |
+ logging.info('user has no permission to edit issue metadata') |
+ self.response.status = httplib.BAD_REQUEST |
+ return |
+ |
+ move_to = post_data.get('move_to', '').lower() |
+ if move_to and not self.CheckPerm(mr, permissions.DELETE_ISSUE): |
+ logging.info('user has no permission to move issue') |
+ self.response.status = httplib.BAD_REQUEST |
+ return |
+ |
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) |
+ |
+ parsed = tracker_helpers.ParseIssueRequest( |
+ mr.cnxn, post_data, self.services, mr.errors, mr.project_name) |
+ field_helpers.ShiftEnumFieldsIntoLabels( |
+ parsed.labels, parsed.labels_remove, |
+ parsed.fields.vals, parsed.fields.vals_remove, |
+ config) |
+ field_vals = field_helpers.ParseFieldValues( |
+ mr.cnxn, self.services.user, parsed.fields.vals, config) |
+ field_vals_remove = field_helpers.ParseFieldValues( |
+ mr.cnxn, self.services.user, parsed.fields.vals_remove, config) |
+ |
+ # Treat status '' as no change and explicit 'clear' as clearing the status. |
+ status = parsed.status |
+ if status == '': |
+ status = None |
+ if post_data.get('op_statusenter') == 'clear': |
+ status = '' |
+ |
+ reporter_id = mr.auth.user_id |
+ logging.info('bulk edit request by %s', reporter_id) |
+ self.CheckCaptcha(mr, post_data) |
+ |
+ if parsed.users.owner_id is None: |
+ mr.errors.owner = 'Invalid owner username' |
+ else: |
+ valid, msg = tracker_helpers.IsValidIssueOwner( |
+ mr.cnxn, mr.project, parsed.users.owner_id, self.services) |
+ if not valid: |
+ mr.errors.owner = msg |
+ |
+ if (status in config.statuses_offer_merge and |
+ not post_data.get('merge_into')): |
+ mr.errors.merge_into_id = 'Please enter a valid issue ID' |
+ |
+ move_to_project = None |
+ if move_to: |
+ if mr.project_name == move_to: |
+ mr.errors.move_to = 'The issues are already in project ' + move_to |
+ else: |
+ move_to_project = self.services.project.GetProjectByName( |
+ mr.cnxn, move_to) |
+ if not move_to_project: |
+ mr.errors.move_to = 'No such project: ' + move_to |
+ |
+ # Treat owner '' as no change, and explicit 'clear' as NO_USER_SPECIFIED |
+ owner_id = parsed.users.owner_id |
+ if parsed.users.owner_username == '': |
+ owner_id = None |
+ if post_data.get('op_ownerenter') == 'clear': |
+ owner_id = framework_constants.NO_USER_SPECIFIED |
+ |
+ comp_ids = tracker_helpers.LookupComponentIDs( |
+ parsed.components.paths, config, mr.errors) |
+ comp_ids_remove = tracker_helpers.LookupComponentIDs( |
+ parsed.components.paths_remove, config, mr.errors) |
+ if post_data.get('op_componententer') == 'remove': |
+ comp_ids, comp_ids_remove = comp_ids_remove, comp_ids |
+ |
+ cc_ids, cc_ids_remove = parsed.users.cc_ids, parsed.users.cc_ids_remove |
+ if post_data.get('op_memberenter') == 'remove': |
+ cc_ids, cc_ids_remove = parsed.users.cc_ids_remove, parsed.users.cc_ids |
+ |
+ local_ids_actually_changed = [] |
+ old_owner_ids = [] |
+ combined_amendments = [] |
+ merge_into_issue = None |
+ new_starrers = set() |
+ |
+ if not mr.errors.AnyErrors(): |
+ issue_list = self.services.issue.GetIssuesByLocalIDs( |
+ mr.cnxn, mr.project_id, mr.local_id_list) |
+ |
+ # Skip any individual issues that the user is not allowed to edit. |
+ editable_issues = [ |
+ issue for issue in issue_list |
+ if permissions.CanEditIssue( |
+ mr.auth.effective_ids, mr.perms, mr.project, issue)] |
+ |
+ # Skip any restrict issues that cannot be moved |
+ if move_to: |
+ editable_issues = [ |
+ issue for issue in editable_issues |
+ if not permissions.GetRestrictions(issue)] |
+ |
+ # If 'Duplicate' status is specified ensure there are no permission issues |
+ # with the issue we want to merge with. |
+ if post_data.get('merge_into'): |
+ for issue in editable_issues: |
+ _, merge_into_issue = tracker_helpers.ParseMergeFields( |
+ mr.cnxn, self.services, mr.project_name, post_data, parsed.status, |
+ config, issue, mr.errors) |
+ if merge_into_issue: |
+ merge_allowed = tracker_helpers.IsMergeAllowed( |
+ merge_into_issue, mr, self.services) |
+ if not merge_allowed: |
+ mr.errors.merge_into_id = 'Target issue %s cannot be modified' % ( |
+ merge_into_issue.local_id) |
+ break |
+ |
+ # Update the new_starrers set. |
+ new_starrers.update(tracker_helpers.GetNewIssueStarrers( |
+ mr.cnxn, self.services, issue.issue_id, |
+ merge_into_issue.issue_id)) |
+ |
+ # Proceed with amendments only if there are no reported errors. |
+ if not mr.errors.AnyErrors(): |
+ # Sort the issues: we want them in this order so that the |
+ # corresponding old_owner_id are found in the same order. |
+ editable_issues.sort(lambda i1, i2: cmp(i1.local_id, i2.local_id)) |
+ |
+ iids_to_invalidate = set() |
+ rules = self.services.features.GetFilterRules( |
+ mr.cnxn, config.project_id) |
+ predicate_asts = filterrules_helpers.ParsePredicateASTs( |
+ rules, config, None) |
+ for issue in editable_issues: |
+ old_owner_id = tracker_bizobj.GetOwnerId(issue) |
+ merge_into_iid = ( |
+ merge_into_issue.issue_id if merge_into_issue else None) |
+ |
+ amendments, _ = self.services.issue.DeltaUpdateIssue( |
+ mr.cnxn, self.services, mr.auth.user_id, mr.project_id, config, |
+ issue, status, owner_id, cc_ids, cc_ids_remove, comp_ids, |
+ comp_ids_remove, parsed.labels, parsed.labels_remove, field_vals, |
+ field_vals_remove, parsed.fields.fields_clear, |
+ merged_into=merge_into_iid, comment=parsed.comment, |
+ iids_to_invalidate=iids_to_invalidate, rules=rules, |
+ predicate_asts=predicate_asts) |
+ |
+ if amendments or parsed.comment: # Avoid empty comments. |
+ local_ids_actually_changed.append(issue.local_id) |
+ old_owner_ids.append(old_owner_id) |
+ combined_amendments.extend(amendments) |
+ |
+ self.services.issue.InvalidateIIDs(mr.cnxn, iids_to_invalidate) |
+ self.services.project.UpdateRecentActivity( |
+ mr.cnxn, mr.project.project_id) |
+ |
+ # Add new_starrers and new CCs to merge_into_issue. |
+ if merge_into_issue: |
+ merge_into_project = self.services.project.GetProjectByName( |
+ mr.cnxn, merge_into_issue.project_name) |
+ tracker_helpers.AddIssueStarrers( |
+ mr.cnxn, self.services, mr, merge_into_issue.issue_id, |
+ merge_into_project, new_starrers) |
+ tracker_helpers.MergeCCsAndAddCommentMultipleIssues( |
+ self.services, mr, editable_issues, merge_into_project, |
+ merge_into_issue) |
+ |
+ if move_to and editable_issues: |
+ tracker_fulltext.UnindexIssues( |
+ [issue.issue_id for issue in editable_issues]) |
+ for issue in editable_issues: |
+ old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id) |
+ moved_back_iids = self.services.issue.MoveIssues( |
+ mr.cnxn, move_to_project, [issue], self.services.user) |
+ new_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id) |
+ if issue.issue_id in moved_back_iids: |
+ content = 'Moved %s back to %s again.' % ( |
+ old_text_ref, new_text_ref) |
+ else: |
+ content = 'Moved %s to now be %s.' % (old_text_ref, new_text_ref) |
+ self.services.issue.CreateIssueComment( |
+ mr.cnxn, move_to_project.project_id, issue.local_id, |
+ mr.auth.user_id, content, amendments=[ |
+ tracker_bizobj.MakeProjectAmendment( |
+ move_to_project.project_name)]) |
+ |
+ send_email = 'send_email' in post_data |
+ |
+ users_by_id = framework_views.MakeAllUserViews( |
+ mr.cnxn, self.services.user, |
+ [owner_id], cc_ids, cc_ids_remove, old_owner_ids, |
+ tracker_bizobj.UsersInvolvedInAmendments(combined_amendments)) |
+ if move_to and editable_issues: |
+ project_id = move_to_project.project_id |
+ local_ids_actually_changed = [ |
+ issue.local_id for issue in editable_issues] |
+ else: |
+ project_id = mr.project_id |
+ |
+ notify.SendIssueBulkChangeNotification( |
+ mr.request.host, project_id, |
+ local_ids_actually_changed, old_owner_ids, parsed.comment, |
+ reporter_id, combined_amendments, send_email, users_by_id) |
+ |
+ if mr.errors.AnyErrors(): |
+ bounce_cc_parts = ( |
+ parsed.users.cc_usernames + |
+ ['-%s' % ccur for ccur in parsed.users.cc_usernames_remove]) |
+ bounce_labels = ( |
+ parsed.labels + |
+ ['-%s' % lr for lr in parsed.labels_remove]) |
+ self.PleaseCorrect( |
+ mr, initial_status=parsed.status, |
+ initial_owner=parsed.users.owner_username, |
+ initial_merge_into=post_data.get('merge_into', 0), |
+ initial_cc=', '.join(bounce_cc_parts), |
+ initial_comment=parsed.comment, |
+ initial_components=parsed.components.entered_str, |
+ labels=bounce_labels) |
+ return |
+ |
+ with self.profiler.Phase('reindexing issues'): |
+ logging.info('starting reindexing') |
+ start = time.time() |
+ # Get the updated issues and index them |
+ issue_list = self.services.issue.GetIssuesByLocalIDs( |
+ mr.cnxn, mr.project_id, mr.local_id_list) |
+ tracker_fulltext.IndexIssues( |
+ mr.cnxn, issue_list, self.services.user, self.services.issue, |
+ self.services.config) |
+ logging.info('reindexing %d issues took %s sec', |
+ len(issue_list), time.time() - start) |
+ |
+ # TODO(jrobbins): These could be put into the form action attribute. |
+ mr.can = int(post_data['can']) |
+ mr.query = post_data['q'] |
+ mr.col_spec = post_data['colspec'] |
+ mr.sort_spec = post_data['sort'] |
+ mr.group_by_spec = post_data['groupby'] |
+ mr.start = int(post_data['start']) |
+ mr.num = int(post_data['num']) |
+ |
+ # TODO(jrobbins): implement bulk=N param for a better confirmation alert. |
+ return tracker_helpers.FormatIssueListURL( |
+ mr, config, saved=len(mr.local_id_list), ts=int(time.time())) |