Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/tracker/issueentry.py b/tracker/issueentry.py
new file mode 100644
index 0000000..77de114
--- /dev/null
+++ b/tracker/issueentry.py
@@ -0,0 +1,630 @@
+# 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
+
+"""Servlet that implements the entry of new issues."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import difflib
+import logging
+import string
+import time
+
+from businesslogic import work_env
+from features import hotlist_helpers
+from features import send_notifications
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from framework import servlet
+from framework import template_helpers
+from framework import urls
+import ezt
+from tracker import field_helpers
+from tracker import template_helpers as issue_tmpl_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tracker_views
+from proto import tracker_pb2
+
+PLACEHOLDER_SUMMARY = 'Enter one-line summary'
+PHASES_WITH_MILESTONES = ['Beta', 'Stable', 'Stable-Exp', 'Stable-Full']
+CORP_RESTRICTION_LABEL = 'Restrict-View-Google'
+RESTRICTED_FLT_FIELDS = ['notice', 'whitepaper', 'm-approved']
+
+
+class IssueEntry(servlet.Servlet):
+  """IssueEntry shows a page with a simple form to enter a new issue."""
+
+  _PAGE_TEMPLATE = 'tracker/issue-entry-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+  # The issue filing wizard is a separate app that posted back to Monorail's
+  # issue entry page. To make this possible for the wizard, we need to allow
+  # XHR-scoped XSRF tokens.
+  ALLOW_XHR = True
+
+  def AssertBasePermission(self, mr):
+    """Check whether the user has any permission to visit this page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+    """
+    super(IssueEntry, self).AssertBasePermission(mr)
+    if not self.CheckPerm(mr, permissions.CREATE_ISSUE):
+      raise permissions.PermissionException(
+          'User is not allowed to enter an issue')
+
+  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 config'):
+      config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+    # In addition to checking perms, we adjust some default field values for
+    # project members.
+    is_member = framework_bizobj.UserIsInProject(
+        mr.project, mr.auth.effective_ids)
+    page_perms = self.MakePagePerms(
+        mr, None,
+        permissions.CREATE_ISSUE,
+        permissions.SET_STAR,
+        permissions.EDIT_ISSUE,
+        permissions.EDIT_ISSUE_SUMMARY,
+        permissions.EDIT_ISSUE_STATUS,
+        permissions.EDIT_ISSUE_OWNER,
+        permissions.EDIT_ISSUE_CC)
+
+
+    with work_env.WorkEnv(mr, self.services) as we:
+      userprefs = we.GetUserPrefs(mr.auth.user_id)
+      code_font = any(pref for pref in userprefs.prefs
+                      if pref.name == 'code_font' and pref.value == 'true')
+
+    template = self._GetTemplate(mr.cnxn, config, mr.template_name, is_member)
+
+    if template.summary:
+      initial_summary = template.summary
+      initial_summary_must_be_edited = template.summary_must_be_edited
+    else:
+      initial_summary = PLACEHOLDER_SUMMARY
+      initial_summary_must_be_edited = True
+
+    if template.status:
+      initial_status = template.status
+    elif is_member:
+      initial_status = 'Accepted'
+    else:
+      initial_status = 'New'  # not offering meta, only used in hidden field.
+
+    component_paths = []
+    for component_id in template.component_ids:
+      component_paths.append(
+          tracker_bizobj.FindComponentDefByID(component_id, config).path)
+    initial_components = ', '.join(component_paths)
+
+    if template.owner_id:
+      initial_owner = framework_views.MakeUserView(
+          mr.cnxn, self.services.user, template.owner_id)
+    elif template.owner_defaults_to_member and page_perms.EditIssue:
+      initial_owner = mr.auth.user_view
+    else:
+      initial_owner = None
+
+    if initial_owner:
+      initial_owner_name = initial_owner.email
+      owner_avail_state = initial_owner.avail_state
+      owner_avail_message_short = initial_owner.avail_message_short
+    else:
+      initial_owner_name = ''
+      owner_avail_state = None
+      owner_avail_message_short = None
+
+    # Check whether to allow attachments from the entry page
+    allow_attachments = tracker_helpers.IsUnderSoftAttachmentQuota(mr.project)
+
+    config_view = tracker_views.ConfigView(mr, self.services, config, template)
+    # If the user followed a link that specified the template name, make sure
+    # that it is also in the menu as the current choice.
+    # TODO(jeffcarp): Unit test this.
+    config_view.template_view.can_view = ezt.boolean(True)
+
+    # TODO(jeffcarp): Unit test this.
+    offer_templates = len(config_view.template_names) > 1
+    restrict_to_known = config.restrict_to_known
+    link_or_template_labels = mr.GetListParam('labels', template.labels)
+    labels, _derived_labels = tracker_bizobj.ExplicitAndDerivedNonMaskedLabels(
+        link_or_template_labels, [], config)
+
+    # Users with restrict_new_issues user pref automatically add R-V-G.
+    with work_env.WorkEnv(mr, self.services) as we:
+      userprefs = we.GetUserPrefs(mr.auth.user_id)
+      restrict_new_issues = any(
+          up.name == 'restrict_new_issues' and up.value == 'true'
+          for up in userprefs.prefs)
+      if restrict_new_issues:
+        if not any(lab.lower().startswith('restrict-view-') for lab in labels):
+          labels.append(CORP_RESTRICTION_LABEL)
+
+    field_user_views = tracker_views.MakeFieldUserViews(
+        mr.cnxn, template, self.services.user)
+    approval_ids = [av.approval_id for av in template.approval_values]
+    field_views = tracker_views.MakeAllFieldValueViews(
+        config, link_or_template_labels, [], template.field_values,
+        field_user_views, parent_approval_ids=approval_ids,
+        phases=template.phases)
+    # TODO(jojwang): monorail:6305, remove this hack when Edit perms for field
+    # values are implemented.
+    field_views = [view for view in field_views
+                   if view.field_name.lower() not in RESTRICTED_FLT_FIELDS]
+    uneditable_fields = ezt.boolean(False)
+    for fv in field_views:
+      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)
+        uneditable_fields = ezt.boolean(True)
+
+    # TODO(jrobbins): remove "or []" after next release.
+    (prechecked_approvals, required_approval_ids,
+     phases) = issue_tmpl_helpers.GatherApprovalsPageData(
+         template.approval_values or [], template.phases, config)
+    approvals = [view for view in field_views if view.field_id in
+                 approval_ids]
+
+    page_data = {
+        'issue_tab_mode':
+            'issueEntry',
+        'initial_summary':
+            initial_summary,
+        'template_summary':
+            initial_summary,
+        'clear_summary_on_click':
+            ezt.boolean(
+                initial_summary_must_be_edited and
+                'initial_summary' not in mr.form_overrides),
+        'must_edit_summary':
+            ezt.boolean(initial_summary_must_be_edited),
+        'initial_description':
+            template.content,
+        'template_name':
+            template.name,
+        'component_required':
+            ezt.boolean(template.component_required),
+        'initial_status':
+            initial_status,
+        'initial_owner':
+            initial_owner_name,
+        'owner_avail_state':
+            owner_avail_state,
+        'owner_avail_message_short':
+            owner_avail_message_short,
+        'initial_components':
+            initial_components,
+        'initial_cc':
+            '',
+        'initial_blocked_on':
+            '',
+        'initial_blocking':
+            '',
+        'initial_hotlists':
+            '',
+        'labels':
+            labels,
+        'fields':
+            field_views,
+        'any_errors':
+            ezt.boolean(mr.errors.AnyErrors()),
+        'page_perms':
+            page_perms,
+        'allow_attachments':
+            ezt.boolean(allow_attachments),
+        'max_attach_size':
+            template_helpers.BytesKbOrMb(
+                framework_constants.MAX_POST_BODY_SIZE),
+        'offer_templates':
+            ezt.boolean(offer_templates),
+        'config':
+            config_view,
+        'restrict_to_known':
+            ezt.boolean(restrict_to_known),
+        'is_member':
+            ezt.boolean(is_member),
+        'code_font':
+            ezt.boolean(code_font),
+        # The following are necessary for displaying phases that come with
+        # this template. These are read-only.
+        'allow_edit':
+            ezt.boolean(False),
+        'uneditable_fields':
+            uneditable_fields,
+        'initial_phases':
+            phases,
+        'approvals':
+            approvals,
+        'prechecked_approvals':
+            prechecked_approvals,
+        'required_approval_ids':
+            required_approval_ids,
+        # See monorail:4692 and the use of PHASES_WITH_MILESTONES
+        # in elements/flt/mr-launch-overview/mr-phase.js
+        'issue_phase_names':
+            list(
+                {
+                    phase.name.lower()
+                    for phase in phases
+                    if phase.name in PHASES_WITH_MILESTONES
+                }),
+    }
+
+    return page_data
+
+  def GatherHelpData(self, mr, page_data):
+    """Return a dict of values to drive on-page user help.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      page_data: Dictionary of base and page template data.
+
+    Returns:
+      A dict of values to drive on-page user help, to be added to page_data.
+    """
+    help_data = super(IssueEntry, self).GatherHelpData(mr, page_data)
+    dismissed = []
+    if mr.auth.user_pb:
+      with work_env.WorkEnv(mr, self.services) as we:
+        userprefs = we.GetUserPrefs(mr.auth.user_id)
+      dismissed = [
+          pv.name for pv in userprefs.prefs if pv.value == 'true']
+    is_privileged_domain_user = framework_bizobj.IsPriviledgedDomainUser(
+        mr.auth.user_pb.email)
+    if (mr.auth.user_id and
+        'privacy_click_through' not in dismissed):
+      help_data['cue'] = 'privacy_click_through'
+    elif (mr.auth.user_id and
+        'code_of_conduct' not in dismissed):
+      help_data['cue'] = 'code_of_conduct'
+
+    help_data.update({
+        'is_privileged_domain_user': ezt.boolean(is_privileged_domain_user),
+        })
+    return help_data
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the issue entry form.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: The post_data dict for the current request.
+
+    Returns:
+      String URL to redirect the user to after processing.
+    """
+    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)
+
+    # Updates parsed.labels and parsed.fields in place.
+    field_helpers.ShiftEnumFieldsIntoLabels(
+        parsed.labels, parsed.labels_remove, parsed.fields.vals,
+        parsed.fields.vals_remove, config)
+
+    labels = _DiscardUnusedTemplateLabelPrefixes(parsed.labels)
+
+    is_member = framework_bizobj.UserIsInProject(
+        mr.project, mr.auth.effective_ids)
+    template = self._GetTemplate(
+        mr.cnxn, config, parsed.template_name, is_member)
+
+    (approval_values,
+     phases) = issue_tmpl_helpers.FilterApprovalsAndPhases(
+         template.approval_values or [], template.phases, config)
+
+    # Issue PB with only approval_values and labels filled out, for the purpose
+    # of computing applicable fields.
+    partial_issue = tracker_pb2.Issue(
+        approval_values=approval_values, labels=labels)
+    applicable_fields = field_helpers.ListApplicableFieldDefs(
+        [partial_issue], config)
+
+    bounce_labels = parsed.labels[:]
+    bounce_fields = tracker_views.MakeBounceFieldValueViews(
+        parsed.fields.vals,
+        parsed.fields.phase_vals,
+        config,
+        applicable_fields=applicable_fields)
+
+    phase_ids_by_name = {
+        phase.name.lower(): [phase.phase_id] for phase in template.phases}
+    field_values = field_helpers.ParseFieldValues(
+        mr.cnxn, self.services.user, parsed.fields.vals,
+        parsed.fields.phase_vals, config,
+        phase_ids_by_name=phase_ids_by_name)
+
+    component_ids = tracker_helpers.LookupComponentIDs(
+        parsed.components.paths, config, mr.errors)
+
+    if not parsed.summary.strip() or parsed.summary == PLACEHOLDER_SUMMARY:
+      mr.errors.summary = 'Summary is required'
+
+    if not parsed.comment.strip():
+      mr.errors.comment = 'A description is required'
+
+    if len(parsed.comment) > tracker_constants.MAX_COMMENT_CHARS:
+      mr.errors.comment = 'Comment is too long'
+    if len(parsed.summary) > tracker_constants.MAX_SUMMARY_CHARS:
+      mr.errors.summary = 'Summary is too long'
+
+    if _MatchesTemplate(parsed.comment, template):
+      mr.errors.comment = 'Template must be filled out.'
+
+    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 None in parsed.users.cc_ids:
+      mr.errors.cc = 'Invalid Cc username'
+
+    field_helpers.AssertCustomFieldsEditPerms(
+        mr, config, field_values, [], [], labels, [])
+    field_helpers.ApplyRestrictedDefaultValues(
+        mr, config, field_values, labels, template.field_values,
+        template.labels)
+
+    # This ValidateCustomFields call is redundant with work already done
+    # in CreateIssue. However, this instance passes in an ezt_errors object
+    # to allow showing related errors next to the fields they happen on.
+    field_helpers.ValidateCustomFields(
+        mr.cnxn,
+        self.services,
+        field_values,
+        config,
+        mr.project,
+        ezt_errors=mr.errors,
+        issue=partial_issue)
+
+    hotlist_pbs = ProcessParsedHotlistRefs(
+        mr, self.services, parsed.hotlists.hotlist_refs)
+
+    if not mr.errors.AnyErrors():
+      with work_env.WorkEnv(mr, self.services) as we:
+        try:
+          if parsed.attachments:
+            new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
+                mr.project, parsed.attachments)
+            # TODO(jrobbins): Make quota be calculated and stored as
+            # part of applying the comment.
+            self.services.project.UpdateProject(
+                mr.cnxn, mr.project.project_id,
+                attachment_bytes_used=new_bytes_used)
+
+          marked_description = tracker_helpers.MarkupDescriptionOnInput(
+              parsed.comment, template.content)
+          has_star = 'star' in post_data and post_data['star'] == '1'
+
+          if approval_values:
+            _AttachDefaultApprovers(config, approval_values)
+
+          # To preserve previous behavior, do not raise filter rule errors.
+          issue, _ = we.CreateIssue(
+              mr.project_id,
+              parsed.summary,
+              parsed.status,
+              parsed.users.owner_id,
+              parsed.users.cc_ids,
+              labels,
+              field_values,
+              component_ids,
+              marked_description,
+              blocked_on=parsed.blocked_on.iids,
+              blocking=parsed.blocking.iids,
+              dangling_blocked_on=[
+                  tracker_pb2.DanglingIssueRef(ext_issue_identifier=ref_string)
+                  for ref_string in parsed.blocked_on.federated_ref_strings
+              ],
+              dangling_blocking=[
+                  tracker_pb2.DanglingIssueRef(ext_issue_identifier=ref_string)
+                  for ref_string in parsed.blocking.federated_ref_strings
+              ],
+              attachments=parsed.attachments,
+              approval_values=approval_values,
+              phases=phases,
+              raise_filter_errors=False)
+
+          if has_star:
+            we.StarIssue(issue, True)
+
+          if hotlist_pbs:
+            hotlist_ids = {hotlist.hotlist_id for hotlist in hotlist_pbs}
+            issue_tuple = (issue.issue_id, mr.auth.user_id, int(time.time()),
+                           '')
+            self.services.features.AddIssueToHotlists(
+                mr.cnxn, hotlist_ids, issue_tuple, self.services.issue,
+                self.services.chart)
+
+        except exceptions.OverAttachmentQuota:
+          mr.errors.attachments = 'Project attachment quota exceeded.'
+        except exceptions.InputException as e:
+          if 'Undefined or deprecated component with id' in e.message:
+            mr.errors.components = 'Undefined or deprecated component'
+
+    mr.template_name = parsed.template_name
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(
+          mr, initial_summary=parsed.summary, initial_status=parsed.status,
+          initial_owner=parsed.users.owner_username,
+          initial_cc=', '.join(parsed.users.cc_usernames),
+          initial_components=', '.join(parsed.components.paths),
+          initial_comment=parsed.comment, labels=bounce_labels,
+          fields=bounce_fields, template_name=parsed.template_name,
+          initial_blocked_on=parsed.blocked_on.entered_str,
+          initial_blocking=parsed.blocking.entered_str,
+          initial_hotlists=parsed.hotlists.entered_str,
+          component_required=ezt.boolean(template.component_required))
+      return
+
+    # format a redirect url
+    return framework_helpers.FormatAbsoluteURL(
+        mr, urls.ISSUE_DETAIL, id=issue.local_id)
+
+  def _GetTemplate(self, cnxn, config, template_name, is_member):
+    """Tries to fetch template by name and implements default template logic
+    if not found."""
+    template = None
+    if template_name:
+      template_name = template_name.replace('+', ' ')
+      template = self.services.template.GetTemplateByName(cnxn,
+          template_name, config.project_id)
+
+    if not template:
+      if is_member:
+        template_id = config.default_template_for_developers
+      else:
+        template_id = config.default_template_for_users
+      template = self.services.template.GetTemplateById(cnxn, template_id)
+      # If the default templates were deleted, load all and pick the first one.
+      if not template:
+        templates = self.services.template.GetProjectTemplates(cnxn,
+            config.project_id)
+        assert len(templates) > 0, 'Project has no templates!'
+        template = templates[0]
+
+    return template
+
+
+def _AttachDefaultApprovers(config, approval_values):
+  approval_defs_by_id = {ad.approval_id: ad for ad in config.approval_defs}
+  for av in approval_values:
+    ad = approval_defs_by_id.get(av.approval_id)
+    if ad:
+      av.approver_ids = ad.approver_ids[:]
+    else:
+      logging.info('ApprovalDef with approval_id %r could not be found',
+          av.approval_id)
+
+
+def _MatchesTemplate(content, template):
+  content = content.strip(string.whitespace)
+  template_content = template.content.strip(string.whitespace)
+  diff = difflib.unified_diff(content.splitlines(),
+      template_content.splitlines())
+  return len('\n'.join(diff)) == 0
+
+
+def _DiscardUnusedTemplateLabelPrefixes(labels):
+  """Drop any labels that end in '-?'.
+
+  Args:
+    labels: a list of label strings.
+
+  Returns:
+    A list of the same labels, but without any that end with '-?'.
+    Those label prefixes in the new issue templates are intended to
+    prompt the user to enter some label with that prefix, but if
+    nothing is entered there, we do not store anything.
+  """
+  return [lab for lab in labels
+          if not lab.endswith('-?')]
+
+
+def ProcessParsedHotlistRefs(mr, services, parsed_hotlist_refs):
+  """Process a list of ParsedHotlistRefs, returning referenced hotlists.
+
+  This function validates the given ParsedHotlistRefs using four checks; if all
+  of them succeed, then it returns the corresponding hotlist protobuf objects.
+  If any of them fail, it sets the appropriate error string in mr.errors, and
+  returns an empty list.
+
+  Args:
+    mr: the MonorailRequest object
+    services: the service manager
+    parsed_hotlist_refs: a list of ParsedHotlistRef objects
+
+  Returns:
+    on valid input, a list of hotlist protobuf objects
+    if a check fails (and the input is thus considered invalid), an empty list
+
+  Side-effects:
+    if any of the checks fails, set mr.errors.hotlists to a descriptive error
+  """
+  # Pre-processing; common pieces used by functions later.
+  user_hotlist_pbs = services.features.GetHotlistsByUserID(
+      mr.cnxn, mr.auth.user_id)
+  user_hotlist_owners_ids = {hotlist.owner_ids[0]
+      for hotlist in user_hotlist_pbs}
+  user_hotlist_owners_to_emails = services.user.LookupUserEmails(
+      mr.cnxn, user_hotlist_owners_ids)
+  user_hotlist_emails_to_owners = {v: k
+      for k, v in user_hotlist_owners_to_emails.items()}
+  user_hotlist_refs_to_pbs = {
+      hotlist_helpers.HotlistRef(hotlist.owner_ids[0], hotlist.name): hotlist
+      for hotlist in user_hotlist_pbs }
+  short_refs = list()
+  full_refs = list()
+  for parsed_ref in parsed_hotlist_refs:
+    if parsed_ref.user_email is None:
+      short_refs.append(parsed_ref)
+    else:
+      full_refs.append(parsed_ref)
+
+  invalid_names = hotlist_helpers.InvalidParsedHotlistRefsNames(
+      parsed_hotlist_refs, user_hotlist_pbs)
+  if invalid_names:
+    mr.errors.hotlists = (
+        'You have no hotlist(s) named: %s' % ', '.join(invalid_names))
+    return []
+
+  ambiguous_names = hotlist_helpers.AmbiguousShortrefHotlistNames(
+      short_refs, user_hotlist_pbs)
+  if ambiguous_names:
+    mr.errors.hotlists = (
+        'Ambiguous hotlist(s) specified: %s' % ', '.join(ambiguous_names))
+    return []
+
+  # At this point, all refs' named hotlists are guaranteed to exist, and
+  # short refs are guaranteed to be unambiguous;
+  # therefore, short refs are also valid.
+  short_refs_hotlist_names = {sref.hotlist_name for sref in short_refs}
+  shortref_valid_pbs = [hotlist for hotlist in user_hotlist_pbs
+      if hotlist.name in short_refs_hotlist_names]
+
+  invalid_emails = hotlist_helpers.InvalidParsedHotlistRefsEmails(
+      full_refs, user_hotlist_emails_to_owners)
+  if invalid_emails:
+    mr.errors.hotlists = (
+        'You have no hotlist(s) owned by: %s' % ', '.join(invalid_emails))
+    return []
+
+  fullref_valid_pbs, invalid_refs = (
+      hotlist_helpers.GetHotlistsOfParsedHotlistFullRefs(
+        full_refs, user_hotlist_emails_to_owners, user_hotlist_refs_to_pbs))
+  if invalid_refs:
+    invalid_refs_readable = [':'.join(parsed_ref)
+        for parsed_ref in invalid_refs]
+    mr.errors.hotlists = (
+        'Not in your hotlist(s): %s' % ', '.join(invalid_refs_readable))
+    return []
+
+  hotlist_pbs = shortref_valid_pbs + fullref_valid_pbs
+
+  return hotlist_pbs