Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/tracker/issueadmin.py b/tracker/issueadmin.py
new file mode 100644
index 0000000..5c34f72
--- /dev/null
+++ b/tracker/issueadmin.py
@@ -0,0 +1,587 @@
+# 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
+
+"""Servlets for issue tracker configuration.
+
+These classes implement the Statuses, Labels and fields, Components, Rules, and
+Views subtabs under the Process tab.  Unlike most servlet modules, this single
+file holds a base class and several related servlet classes.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import itertools
+import logging
+import time
+
+import ezt
+
+from features import filterrules_helpers
+from features import filterrules_views
+from features import savedqueries_helpers
+from framework import authdata
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import monorailrequest
+from framework import permissions
+from framework import servlet
+from framework import urls
+from proto import tracker_pb2
+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 IssueAdminBase(servlet.Servlet):
+  """Base class for servlets allowing project owners to configure tracker."""
+
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+  _PROCESS_SUBTAB = None  # specified in subclasses
+
+  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.
+    """
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config_view = tracker_views.ConfigView(mr, self.services, config,
+        template=None, load_all_templates=True)
+    open_text, closed_text = tracker_views.StatusDefsAsText(config)
+    labels_text = tracker_views.LabelDefsAsText(config)
+
+    return {
+        'admin_tab_mode': self._PROCESS_SUBTAB,
+        'config': config_view,
+        'open_text': open_text,
+        'closed_text': closed_text,
+        'labels_text': labels_text,
+        }
+
+  def ProcessFormData(self, mr, post_data):
+    """Validate and store the contents of the issues tracker admin page.
+
+    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, or None if response was already sent.
+    """
+    page_url = self.ProcessSubtabForm(post_data, mr)
+
+    if page_url:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, page_url, saved=1, ts=int(time.time()))
+
+
+class AdminStatuses(IssueAdminBase):
+  """Servlet allowing project owners to configure well-known statuses."""
+
+  _PAGE_TEMPLATE = 'tracker/admin-statuses-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_STATUSES
+
+  def ProcessSubtabForm(self, post_data, mr):
+    """Process the status definition section of the admin page.
+
+    Args:
+      post_data: HTML form data for the HTTP request being processed.
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      The URL of the page to show after processing.
+    """
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'Only project owners may edit the status definitions')
+
+    wks_open_text = post_data.get('predefinedopen', '')
+    wks_open_matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(
+        wks_open_text)
+    wks_open_tuples = [
+        (status.lstrip('#'), docstring.strip(), True, status.startswith('#'))
+        for status, docstring in wks_open_matches]
+    if not wks_open_tuples:
+      mr.errors.open_statuses = 'A project cannot have zero open statuses'
+
+    wks_closed_text = post_data.get('predefinedclosed', '')
+    wks_closed_matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(
+        wks_closed_text)
+    wks_closed_tuples = [
+        (status.lstrip('#'), docstring.strip(), False, status.startswith('#'))
+        for status, docstring in wks_closed_matches]
+    if not wks_closed_tuples:
+      mr.errors.closed_statuses = 'A project cannot have zero closed statuses'
+
+    statuses_offer_merge_text = post_data.get('statuses_offer_merge', '')
+    statuses_offer_merge = framework_constants.IDENTIFIER_RE.findall(
+        statuses_offer_merge_text)
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(
+          mr, open_text=wks_open_text, closed_text=wks_closed_text)
+      return
+
+    self.services.config.UpdateConfig(
+        mr.cnxn, mr.project, statuses_offer_merge=statuses_offer_merge,
+        well_known_statuses=wks_open_tuples + wks_closed_tuples)
+
+    # TODO(jrobbins): define a "strict" mode that affects only statuses.
+
+    return urls.ADMIN_STATUSES
+
+
+class AdminLabels(IssueAdminBase):
+  """Servlet allowing project owners to labels and fields."""
+
+  _PAGE_TEMPLATE = 'tracker/admin-labels-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_LABELS
+
+  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.
+    """
+    page_data = super(AdminLabels, self).GatherPageData(mr)
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    field_def_views = [
+        tracker_views.FieldDefView(fd, config)
+        # TODO(jrobbins): future field-level view restrictions.
+        for fd in config.field_defs
+        if not fd.is_deleted]
+    page_data.update({
+        'field_defs': field_def_views,
+        })
+    return page_data
+
+  def ProcessSubtabForm(self, post_data, mr):
+    """Process changes to labels and custom field definitions.
+
+    Args:
+      post_data: HTML form data for the HTTP request being processed.
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      The URL of the page to show after processing.
+    """
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'Only project owners may edit the label definitions')
+
+    wkl_text = post_data.get('predefinedlabels', '')
+    wkl_matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(wkl_text)
+    wkl_tuples = [
+        (label.lstrip('#'), docstring.strip(), label.startswith('#'))
+        for label, docstring in wkl_matches]
+    if not wkl_tuples:
+      mr.errors.label_defs = 'A project cannot have zero labels'
+    label_counter = collections.Counter(wkl[0].lower() for wkl in wkl_tuples)
+    for lab, count in label_counter.items():
+      if count > 1:
+        mr.errors.label_defs = 'Duplicate label: %s' % lab
+
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    field_names = [fd.field_name for fd in config.field_defs
+                   if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE
+                   and not fd.is_deleted]
+    masked_labels = tracker_helpers.LabelsMaskedByFields(config, field_names)
+    field_names_lower = [field_name.lower() for field_name in field_names]
+    for wkl in wkl_tuples:
+      conflict = tracker_bizobj.LabelIsMaskedByField(wkl[0], field_names_lower)
+      if conflict:
+        mr.errors.label_defs = (
+            'Label "%s" should be defined in enum "%s"' % (wkl[0], conflict))
+    wkl_tuples.extend([
+        (masked.name, masked.docstring, False) for masked in masked_labels])
+
+    excl_prefix_text = post_data.get('excl_prefixes', '')
+    excl_prefixes = framework_constants.IDENTIFIER_RE.findall(excl_prefix_text)
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(mr, labels_text=wkl_text)
+      return
+
+    self.services.config.UpdateConfig(
+        mr.cnxn, mr.project,
+        well_known_labels=wkl_tuples, excl_label_prefixes=excl_prefixes)
+
+    # TODO(jrobbins): define a "strict" mode that affects only labels.
+
+    return urls.ADMIN_LABELS
+
+
+class AdminTemplates(IssueAdminBase):
+  """Servlet allowing project owners to configure templates."""
+
+  _PAGE_TEMPLATE = 'tracker/admin-templates-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_TEMPLATES
+
+  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.
+    """
+    return super(AdminTemplates, self).GatherPageData(mr)
+
+  def ProcessSubtabForm(self, post_data, mr):
+    """Process changes to new issue templates.
+
+    Args:
+      post_data: HTML form data for the HTTP request being processed.
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      The URL of the page to show after processing.
+    """
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'Only project owners may edit the default templates')
+
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+    templates = self.services.template.GetProjectTemplates(mr.cnxn,
+        config.project_id)
+    default_template_id_for_developers, default_template_id_for_users = (
+        self._ParseDefaultTemplateSelections(post_data, templates))
+    if default_template_id_for_developers or default_template_id_for_users:
+      self.services.config.UpdateConfig(
+          mr.cnxn, mr.project,
+          default_template_for_developers=default_template_id_for_developers,
+          default_template_for_users=default_template_id_for_users)
+
+    return urls.ADMIN_TEMPLATES
+
+  def _ParseDefaultTemplateSelections(self, post_data, templates):
+    """Parse the input for the default templates to offer users."""
+    def GetSelectedTemplateID(name):
+      """Find the ID of the template specified in post_data[name]."""
+      if name not in post_data:
+        return None
+      selected_template_name = post_data[name]
+      for template in templates:
+        if selected_template_name == template.name:
+          return template.template_id
+
+      logging.error('User somehow selected an invalid template: %r',
+                    selected_template_name)
+      return None
+
+    return (GetSelectedTemplateID('default_template_for_developers'),
+            GetSelectedTemplateID('default_template_for_users'))
+
+
+class AdminComponents(IssueAdminBase):
+  """Servlet allowing project owners to view the list of components."""
+
+  _PAGE_TEMPLATE = 'tracker/admin-components-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_COMPONENTS
+
+  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.
+    """
+    page_data = super(AdminComponents, self).GatherPageData(mr)
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user,
+        *[list(cd.admin_ids) + list(cd.cc_ids)
+          for cd in config.component_defs])
+    framework_views.RevealAllEmailsToMembers(
+        mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
+    component_def_views = [
+        tracker_views.ComponentDefView(mr.cnxn, self.services, cd, users_by_id)
+        # TODO(jrobbins): future component-level view restrictions.
+        for cd in config.component_defs]
+    for cd in component_def_views:
+      if mr.auth.email in [user.email for user in cd.admins]:
+        cd.classes += 'myadmin '
+      if mr.auth.email in [user.email for user in cd.cc]:
+        cd.classes += 'mycc '
+
+    page_data.update({
+        'component_defs': component_def_views,
+        'failed_perm': mr.GetParam('failed_perm'),
+        'failed_subcomp': mr.GetParam('failed_subcomp'),
+        'failed_templ': mr.GetParam('failed_templ'),
+        })
+    return page_data
+
+  def _GetComponentDefs(self, _mr, post_data, config):
+    """Get the config and component definitions from the request."""
+    component_defs = []
+    component_paths = post_data.get('delete_components').split(',')
+    for component_path in component_paths:
+      component_def = tracker_bizobj.FindComponentDef(component_path, config)
+      component_defs.append(component_def)
+    return component_defs
+
+  def _ProcessDeleteComponent(self, mr, component_def):
+    """Delete the specified component and its references."""
+    self.services.issue.DeleteComponentReferences(
+        mr.cnxn, component_def.component_id)
+    self.services.config.DeleteComponentDef(
+        mr.cnxn, mr.project_id, component_def.component_id)
+
+  def ProcessFormData(self, mr, post_data):
+    """Processes a POST command to delete components.
+
+    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, or None if response was already sent.
+    """
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    component_defs = self._GetComponentDefs(mr, post_data, config)
+    # Reverse the component_defs so that we start deleting from subcomponents.
+    component_defs.reverse()
+
+    # Collect errors.
+    perm_errors = []
+    subcomponents_errors = []
+    templates_errors = []
+    # Collect successes.
+    deleted_components = []
+
+    for component_def in component_defs:
+      allow_edit = permissions.CanEditComponentDef(
+          mr.auth.effective_ids, mr.perms, mr.project, component_def, config)
+      if not allow_edit:
+        perm_errors.append(component_def.path)
+
+      subcomponents = tracker_bizobj.FindDescendantComponents(
+          config, component_def)
+      if subcomponents:
+        subcomponents_errors.append(component_def.path)
+
+      templates = self.services.template.TemplatesWithComponent(
+          mr.cnxn, component_def.component_id)
+      if templates:
+        templates_errors.append(component_def.path)
+
+      allow_delete = allow_edit and not subcomponents and not templates
+      if allow_delete:
+        self._ProcessDeleteComponent(mr, component_def)
+        deleted_components.append(component_def.path)
+        # Refresh project config after the component deletion.
+        config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+    return framework_helpers.FormatAbsoluteURL(
+        mr, urls.ADMIN_COMPONENTS, ts=int(time.time()),
+        failed_perm=','.join(perm_errors),
+        failed_subcomp=','.join(subcomponents_errors),
+        failed_templ=','.join(templates_errors),
+        deleted=','.join(deleted_components))
+
+
+class AdminViews(IssueAdminBase):
+  """Servlet for project owners to set default columns, axes, and sorting."""
+
+  _PAGE_TEMPLATE = 'tracker/admin-views-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_VIEWS
+
+  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.
+    """
+    page_data = super(AdminViews, self).GatherPageData(mr)
+    with mr.profiler.Phase('getting canned queries'):
+      canned_queries = self.services.features.GetCannedQueriesByProjectID(
+          mr.cnxn, mr.project_id)
+      canned_query_views = [
+          savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
+          for idx, sq in enumerate(canned_queries)]
+
+    page_data.update({
+        'canned_queries': canned_query_views,
+        'new_query_indexes': list(range(
+            len(canned_queries) + 1, savedqueries_helpers.MAX_QUERIES + 1)),
+        'issue_notify': mr.project.issue_notify_address,
+        'max_queries': savedqueries_helpers.MAX_QUERIES,
+        })
+    return page_data
+
+  def ProcessSubtabForm(self, post_data, mr):
+    """Process the Views subtab.
+
+    Args:
+      post_data: HTML form data for the HTTP request being processed.
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      The URL of the page to show after processing.
+    """
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'Only project owners may edit the default views')
+    existing_queries = savedqueries_helpers.ParseSavedQueries(
+        mr.cnxn, post_data, self.services.project)
+    added_queries = savedqueries_helpers.ParseSavedQueries(
+        mr.cnxn, post_data, self.services.project, prefix='new_')
+    canned_queries = existing_queries + added_queries
+
+    list_prefs = _ParseListPreferences(post_data)
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(mr)
+      return
+
+    self.services.config.UpdateConfig(
+        mr.cnxn, mr.project, list_prefs=list_prefs)
+    self.services.features.UpdateCannedQueries(
+        mr.cnxn, mr.project_id, canned_queries)
+
+    return urls.ADMIN_VIEWS
+
+
+def _ParseListPreferences(post_data):
+  """Parse the part of a project admin form about artifact list preferences."""
+  default_col_spec = ''
+  if 'default_col_spec' in post_data:
+    default_col_spec = post_data['default_col_spec']
+  # Don't allow empty colum spec
+  if not default_col_spec:
+    default_col_spec = tracker_constants.DEFAULT_COL_SPEC
+  col_spec_words = monorailrequest.ParseColSpec(
+      default_col_spec, max_parts=framework_constants.MAX_COL_PARTS)
+  col_spec = ' '.join(word for word in col_spec_words)
+
+  default_sort_spec = ''
+  if 'default_sort_spec' in post_data:
+    default_sort_spec = post_data['default_sort_spec']
+  sort_spec_words = monorailrequest.ParseColSpec(default_sort_spec)
+  sort_spec = ' '.join(sort_spec_words)
+
+  x_attr_str = ''
+  if 'default_x_attr' in post_data:
+    x_attr_str = post_data['default_x_attr']
+  x_attr_words = monorailrequest.ParseColSpec(x_attr_str)
+  x_attr = ''
+  if x_attr_words:
+    x_attr = x_attr_words[0]
+
+  y_attr_str = ''
+  if 'default_y_attr' in post_data:
+    y_attr_str = post_data['default_y_attr']
+  y_attr_words = monorailrequest.ParseColSpec(y_attr_str)
+  y_attr = ''
+  if y_attr_words:
+    y_attr = y_attr_words[0]
+
+  member_default_query = ''
+  if 'member_default_query' in post_data:
+    member_default_query = post_data['member_default_query']
+
+  return col_spec, sort_spec, x_attr, y_attr, member_default_query
+
+
+class AdminRules(IssueAdminBase):
+  """Servlet allowing project owners to configure filter rules."""
+
+  _PAGE_TEMPLATE = 'tracker/admin-rules-page.ezt'
+  _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_RULES
+
+  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(AdminRules, self).AssertBasePermission(mr)
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'User is not allowed to administer this project')
+
+  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.
+    """
+    page_data = super(AdminRules, self).GatherPageData(mr)
+    rules = self.services.features.GetFilterRules(
+        mr.cnxn, mr.project_id)
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user,
+        [rule.default_owner_id for rule in rules],
+        *[rule.add_cc_ids for rule in rules])
+    framework_views.RevealAllEmailsToMembers(
+        mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
+    rule_views = [filterrules_views.RuleView(rule, users_by_id)
+                  for rule in rules]
+
+    for idx, rule_view in enumerate(rule_views):
+      rule_view.idx = idx + 1  # EZT has no loop index, so we set idx.
+
+    page_data.update({
+        'rules': rule_views,
+        'new_rule_indexes': (
+            list(range(len(rules) + 1, filterrules_helpers.MAX_RULES + 1))),
+        'max_rules': filterrules_helpers.MAX_RULES,
+        })
+    return page_data
+
+  def ProcessSubtabForm(self, post_data, mr):
+    """Process the Rules subtab.
+
+    Args:
+      post_data: HTML form data for the HTTP request being processed.
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      The URL of the page to show after processing.
+    """
+    old_rules = self.services.features.GetFilterRules(mr.cnxn, mr.project_id)
+    rules = filterrules_helpers.ParseRules(
+        mr.cnxn, post_data, self.services.user, mr.errors)
+    new_rules = filterrules_helpers.ParseRules(
+        mr.cnxn, post_data, self.services.user, mr.errors, prefix='new_')
+    rules.extend(new_rules)
+
+    if mr.errors.AnyErrors():
+      self.PleaseCorrect(mr)
+      return
+
+    config = self.services.features.UpdateFilterRules(
+        mr.cnxn, mr.project_id, rules)
+
+    if old_rules != rules:
+      logging.info('recomputing derived fields')
+      config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+      filterrules_helpers.RecomputeAllDerivedFields(
+          mr.cnxn, self.services, mr.project, config)
+
+    return urls.ADMIN_RULES