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