Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/tracker/issuebulkedit.py b/tracker/issuebulkedit.py
new file mode 100644
index 0000000..c1f5229
--- /dev/null
+++ b/tracker/issuebulkedit.py
@@ -0,0 +1,473 @@
+# 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()))