| # Copyright 2016 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed 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. |
| """ |
| from __future__ import print_function |
| from __future__ import division |
| from __future__ import absolute_import |
| |
| import collections |
| import httplib |
| import itertools |
| import logging |
| import time |
| |
| import ezt |
| |
| from features import filterrules_helpers |
| from features import send_notifications |
| from framework import exceptions |
| from framework import framework_constants |
| from framework import framework_views |
| 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_constants |
| 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 |
| _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 mr.profiler.Phase('getting issues'): |
| if not mr.local_id_list: |
| raise exceptions.InputException() |
| requested_issues = self.services.issue.GetIssuesByLocalIDs( |
| mr.cnxn, mr.project_id, sorted(mr.local_id_list)) |
| |
| with mr.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 |
| |
| issue_phases = list( |
| itertools.chain.from_iterable(issue.phases for issue in issues)) |
| |
| field_views = tracker_views.MakeAllFieldValueViews( |
| config, type_label_set, [], [], {}, phases=issue_phases) |
| for fv in field_views: |
| # 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. |
| fv.field_def.is_required_bool = None |
| |
| if permissions.CanEditValueForFieldDef( |
| mr.auth.effective_ids, mr.perms, mr.project, fv.field_def.field_def): |
| fv.is_editable = ezt.boolean(True) |
| else: |
| fv.is_editable = ezt.boolean(False) |
| |
| with mr.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, |
| 'local_ids_str': ','.join([str(issue.local_id) for issue in issues]), |
| 'num_issues': len(issue_views), |
| 'show_progress': ezt.boolean(num_seconds > self._SLOWNESS_THRESHOLD), |
| 'num_seconds': num_seconds, |
| |
| 'initial_blocked_on': '', |
| 'initial_blocking': '', |
| '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, |
| 'issue_phase_names': list( |
| {phase.name.lower() for phase in issue_phases}), |
| } |
| |
| def ProcessFormData(self, mr, post_data): |
| # (...) -> str |
| """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 |
| |
| # 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) |
| bounce_labels = ( |
| parsed.labels[:] + |
| ['-%s' % lr for lr in parsed.labels_remove]) |
| bounce_fields = tracker_views.MakeBounceFieldValueViews( |
| parsed.fields.vals, parsed.fields.phase_vals, config) |
| field_helpers.ShiftEnumFieldsIntoLabels( |
| parsed.labels, parsed.labels_remove, |
| parsed.fields.vals, parsed.fields.vals_remove, |
| config) |
| issue_list = self.services.issue.GetIssuesByLocalIDs( |
| mr.cnxn, mr.project_id, mr.local_id_list) |
| issue_phases = list( |
| itertools.chain.from_iterable(issue.phases for issue in issue_list)) |
| phase_ids_by_name = collections.defaultdict(set) |
| for phase in issue_phases: |
| phase_ids_by_name[phase.name.lower()].add(phase.phase_id) |
| # Note: Not all parsed phase field values will be applicable to every issue. |
| # tracker_bizobj.ApplyFieldValueChanges will take care of not adding |
| # phase field values to issues that don't contain the correct phase. |
| field_vals = field_helpers.ParseFieldValues( |
| mr.cnxn, self.services.user, parsed.fields.vals, |
| parsed.fields.phase_vals, config, |
| phase_ids_by_name=phase_ids_by_name) |
| field_vals_remove = field_helpers.ParseFieldValues( |
| mr.cnxn, self.services.user, parsed.fields.vals_remove, |
| parsed.fields.phase_vals_remove, config, |
| phase_ids_by_name=phase_ids_by_name) |
| |
| field_helpers.AssertCustomFieldsEditPerms( |
| mr, config, field_vals, field_vals_remove, parsed.fields.fields_clear, |
| parsed.labels, parsed.labels_remove) |
| field_helpers.ValidateCustomFields( |
| mr.cnxn, self.services, field_vals, config, mr.project, |
| ezt_errors=mr.errors) |
| |
| # 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) |
| |
| 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 |
| |
| issue_list_iids = {issue.issue_id for issue in issue_list} |
| if post_data.get('op_blockedonenter') == 'append': |
| if issue_list_iids.intersection(parsed.blocked_on.iids): |
| mr.errors.blocked_on = 'Cannot block an issue on itself.' |
| blocked_on_add = parsed.blocked_on.iids |
| blocked_on_remove = [] |
| else: |
| blocked_on_add = [] |
| blocked_on_remove = parsed.blocked_on.iids |
| if post_data.get('op_blockingenter') == 'append': |
| if issue_list_iids.intersection(parsed.blocking.iids): |
| mr.errors.blocking = 'Cannot block an issue on itself.' |
| blocking_add = parsed.blocking.iids |
| blocking_remove = [] |
| else: |
| blocking_add = [] |
| blocking_remove = parsed.blocking.iids |
| |
| if len(parsed.comment) > tracker_constants.MAX_COMMENT_CHARS: |
| mr.errors.comment = 'Comment is too long.' |
| |
| iids_actually_changed = [] |
| old_owner_ids = [] |
| combined_amendments = [] |
| merge_into_issue = None |
| new_starrers = set() |
| |
| if not mr.errors.AnyErrors(): |
| # Because we will modify issues, load from DB rather than cache. |
| issue_list = self.services.issue.GetIssuesByLocalIDs( |
| mr.cnxn, mr.project_id, mr.local_id_list, use_cache=False) |
| |
| # 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(key=lambda issue: issue.local_id) |
| |
| iids_to_invalidate = set() |
| rules = self.services.features.GetFilterRules( |
| mr.cnxn, config.project_id) |
| predicate_asts = filterrules_helpers.ParsePredicateASTs( |
| rules, config, []) |
| 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) |
| |
| delta = tracker_bizobj.MakeIssueDelta( |
| 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, blocked_on_add, blocked_on_remove, |
| blocking_add, blocking_remove, merge_into_iid, None) |
| amendments, _ = self.services.issue.DeltaUpdateIssue( |
| mr.cnxn, self.services, mr.auth.user_id, mr.project_id, config, |
| issue, delta, comment=parsed.comment, |
| iids_to_invalidate=iids_to_invalidate, rules=rules, |
| predicate_asts=predicate_asts) |
| |
| if amendments or parsed.comment: # Avoid empty comments. |
| iids_actually_changed.append(issue.issue_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) |
| # Load target issue again to get the updated star count. |
| merge_into_issue = self.services.issue.GetIssue( |
| mr.cnxn, merge_into_issue.issue_id, use_cache=False) |
| tracker_helpers.MergeCCsAndAddCommentMultipleIssues( |
| self.services, mr, editable_issues, 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, issue, 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: |
| iids_actually_changed = [ |
| issue.issue_id for issue in editable_issues] |
| |
| send_notifications.SendIssueBulkChangeNotification( |
| iids_actually_changed, mr.request.host, |
| 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]) |
| 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, |
| fields=bounce_fields) |
| return |
| |
| with mr.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())) |