blob: 10fbdc85a8fa3c806acf9f548f709c101a2f5137 [file] [log] [blame]
# 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 flaskservlet
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 = flaskservlet.FlaskServlet.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 = flaskservlet.FlaskServlet.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
# def GetAdminStatusesPage(self, **kwargs):
# return self.handler(**kwargs)
# def PostAdminStatusesPage(self, **kwargs):
# return self.handler(**kwargs)
class AdminLabels(IssueAdminBase):
"""Servlet allowing project owners to labels and fields."""
_PAGE_TEMPLATE = 'tracker/admin-labels-page.ezt'
_PROCESS_SUBTAB = flaskservlet.FlaskServlet.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
# def GetAdminLabelsPage(self, **kwargs):
# return self.handler(**kwargs)
# def PostAdminLabelsPage(self, **kwargs):
# return self.handler(**kwargs)
class AdminTemplates(IssueAdminBase):
"""Servlet allowing project owners to configure templates."""
_PAGE_TEMPLATE = 'tracker/admin-templates-page.ezt'
_PROCESS_SUBTAB = flaskservlet.FlaskServlet.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'))
# def GetAdminTemplatesPage(self, **kwargs):
# return self.handler(**kwargs)
# def PostAdminTemplatesPage(self, **kwargs):
# return self.handler(**kwargs)
class AdminComponents(IssueAdminBase):
"""Servlet allowing project owners to view the list of components."""
_PAGE_TEMPLATE = 'tracker/admin-components-page.ezt'
_PROCESS_SUBTAB = flaskservlet.FlaskServlet.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))
# def GetAdminComponentsPage(self, **kwargs):
# return self.handler(**kwargs)
# def PostAdminComponentsPage(self, **kwargs):
# return self.handler(**kwargs)
class AdminViews(IssueAdminBase):
"""Servlet for project owners to set default columns, axes, and sorting."""
_PAGE_TEMPLATE = 'tracker/admin-views-page.ezt'
_PROCESS_SUBTAB = flaskservlet.FlaskServlet.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 GetAdminViewsPage(self, **kwargs):
# return self.handler(**kwargs)
# def PostAdminViewsPage(self, **kwargs):
# return self.handler(**kwargs)
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 = flaskservlet.FlaskServlet.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
# def GetAdminRulesPage(self, **kwargs):
# return self.handler(**kwargs)
# def PostAdminRulesPage(self, **kwargs):
# return self.handler(**kwargs)