blob: 293c1da0ce16aac8703d3d0c2916f74acbee381b [file] [log] [blame]
# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is govered by a BSD-style
# license that can be found in the LICENSE file or at
# https://developers.google.com/open-source/licenses/bsd
"""Implementation of the issue list feature of the Monorail Issue Tracker.
Summary of page classes:
IssueList: Shows a table of issue that satisfy search criteria.
"""
import logging
from third_party import ezt
import settings
from framework import framework_constants
from framework import framework_helpers
from framework import framework_views
from framework import grid_view_helpers
from framework import permissions
from framework import servlet
from framework import sql
from framework import table_view_helpers
from framework import template_helpers
from framework import urls
from framework import xsrf
from search import frontendsearchpipeline
from search import searchpipeline
from search import query2ast
from services import issue_svc
from tracker import tablecell
from tracker import tracker_bizobj
from tracker import tracker_constants
from tracker import tracker_helpers
from tracker import tracker_views
class IssueList(servlet.Servlet):
"""IssueList shows a page with a list of issues (search results).
The issue list is actually a table with a configurable set of columns
that can be edited by the user.
"""
_PAGE_TEMPLATE = 'tracker/issue-list-page.ezt'
_ELIMINATE_BLANK_LINES = True
_MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
_DEFAULT_RESULTS_PER_PAGE = tracker_constants.DEFAULT_RESULTS_PER_PAGE
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.
"""
# Check if the user's query is just the ID of an existing issue.
# TODO(jrobbins): consider implementing this for cross-project search.
if mr.project and tracker_constants.JUMP_RE.match(mr.query):
local_id = int(mr.query)
try:
_issue = self.services.issue.GetIssueByLocalID(
mr.cnxn, mr.project_id, local_id) # does it exist?
url = framework_helpers.FormatAbsoluteURL(
mr, urls.ISSUE_DETAIL, id=local_id)
self.redirect(url, abort=True) # Jump to specified issue.
except issue_svc.NoSuchIssueException:
pass # The user is searching for a number that is not an issue ID.
with self.profiler.Phase('finishing config work'):
if mr.project_id:
config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
else:
config = tracker_bizobj.MakeDefaultProjectIssueConfig(None)
with self.profiler.Phase('starting frontend search pipeline'):
pipeline = frontendsearchpipeline.FrontendSearchPipeline(
mr, self.services, self.profiler, self._DEFAULT_RESULTS_PER_PAGE)
# Perform promises that require authentication information.
with self.profiler.Phase('getting stars'):
starred_iid_set = _GetStarredIssues(
mr.cnxn, mr.auth.user_id, self.services)
with self.profiler.Phase('computing col_spec'):
mr.ComputeColSpec(config)
if not mr.errors.AnyErrors():
pipeline.SearchForIIDs()
pipeline.MergeAndSortIssues()
pipeline.Paginate()
with self.profiler.Phase('publishing emails'):
framework_views.RevealAllEmailsToMembers(mr, pipeline.users_by_id)
# TODO(jrobbins): get the configs for all result issues and
# harmonize them to get field defs including restrictions.
with self.profiler.Phase('getting related issues'):
related_iids = set()
if pipeline.grid_mode:
results_needing_related = pipeline.allowed_results or []
else:
results_needing_related = pipeline.visible_results or []
lower_cols = mr.col_spec.lower().split()
for issue in results_needing_related:
if 'blockedon' in lower_cols:
related_iids.update(issue.blocked_on_iids)
if 'blocking' in lower_cols:
related_iids.update(issue.blocking_iids)
if 'mergedinto' in lower_cols:
related_iids.add(issue.merged_into)
related_issues_list = self.services.issue.GetIssues(
mr.cnxn, list(related_iids))
related_issues = {issue.issue_id: issue for issue in related_issues_list}
with self.profiler.Phase('building table/grid'):
if pipeline.grid_mode:
page_data = self.GetGridViewData(
mr, pipeline.allowed_results or [], config, pipeline.users_by_id,
starred_iid_set, pipeline.grid_limited)
else:
page_data = self.GetTableViewData(
mr, pipeline.visible_results or [], config, pipeline.users_by_id,
starred_iid_set, related_issues)
# We show a special message when no query will every produce any results
# because the project has no issues in it.
with self.profiler.Phase('starting stars promise'):
if mr.project_id:
project_has_any_issues = (
pipeline.allowed_results or
self.services.issue.GetHighestLocalID(mr.cnxn, mr.project_id) != 0)
else:
project_has_any_issues = True # Message only applies in a project.
with self.profiler.Phase('making page perms'):
page_perms = self.MakePagePerms(
mr, None,
permissions.SET_STAR,
permissions.CREATE_ISSUE,
permissions.EDIT_ISSUE)
# Update page data with variables that are shared between list and
# grid view.
page_data.update({
'issue_tab_mode': 'issueList',
'pagination': pipeline.pagination,
'is_cross_project': ezt.boolean(len(pipeline.query_project_ids) != 1),
'project_has_any_issues': ezt.boolean(project_has_any_issues),
'colspec': mr.col_spec,
'page_perms': page_perms,
'grid_mode': ezt.boolean(pipeline.grid_mode),
'panel_id': mr.panel_id,
'set_star_token': xsrf.GenerateToken(
mr.auth.user_id, '/p/%s%s.do' % (
mr.project_name, urls.ISSUE_SETSTAR_JSON)),
'is_missing_shards': ezt.boolean(len(pipeline.error_responses)),
'missing_shard_count': len(pipeline.error_responses),
})
return page_data
def GetGridViewData(
self, mr, results, config, users_by_id, starred_iid_set, grid_limited):
"""EZT template values to render a Grid View of issues.
Args:
mr: commonly used info parsed from the request.
results: The Issue PBs that are the search results to be displayed.
config: The ProjectConfig PB for the project this view is in.
users_by_id: A dictionary {user_id: user_view,...} for all the users
involved in results.
starred_iid_set: Set of issues that the user has starred.
grid_limited: True if the results were limited to fit within the grid.
Returns:
Dictionary for EZT template rendering of the Grid View.
"""
# We need ordered_columns because EZT loops have no loop-counter available.
# And, we use column number in the Javascript to hide/show columns.
columns = mr.col_spec.split()
ordered_columns = [template_helpers.EZTItem(col_index=i, name=col)
for i, col in enumerate(columns)]
unshown_columns = table_view_helpers.ComputeUnshownColumns(
results, columns, config, tracker_constants.OTHER_BUILT_IN_COLS)
grid_x_attr = (mr.x or config.default_x_attr or '--').lower()
grid_y_attr = (mr.y or config.default_y_attr or '--').lower()
all_label_values = {}
for art in results:
all_label_values[art.local_id] = (
grid_view_helpers.MakeLabelValuesDict(art))
if grid_x_attr == '--':
grid_x_headings = ['All']
else:
grid_x_items = table_view_helpers.ExtractUniqueValues(
[grid_x_attr], results, users_by_id, config)
grid_x_headings = grid_x_items[0].filter_values
if grid_view_helpers.AnyArtifactHasNoAttr(
results, grid_x_attr, users_by_id, all_label_values, config):
grid_x_headings.append(framework_constants.NO_VALUES)
grid_x_headings = grid_view_helpers.SortGridHeadings(
grid_x_attr, grid_x_headings, users_by_id, config,
tracker_helpers.SORTABLE_FIELDS)
if grid_y_attr == '--':
grid_y_headings = ['All']
else:
grid_y_items = table_view_helpers.ExtractUniqueValues(
[grid_y_attr], results, users_by_id, config)
grid_y_headings = grid_y_items[0].filter_values
if grid_view_helpers.AnyArtifactHasNoAttr(
results, grid_y_attr, users_by_id, all_label_values, config):
grid_y_headings.append(framework_constants.NO_VALUES)
grid_y_headings = grid_view_helpers.SortGridHeadings(
grid_y_attr, grid_y_headings, users_by_id, config,
tracker_helpers.SORTABLE_FIELDS)
logging.info('grid_x_headings = %s', grid_x_headings)
logging.info('grid_y_headings = %s', grid_y_headings)
grid_data = _MakeGridData(
results, mr.auth.user_id,
starred_iid_set, grid_x_attr, grid_x_headings,
grid_y_attr, grid_y_headings, users_by_id, all_label_values,
config)
grid_axis_choice_dict = {}
for oc in ordered_columns:
grid_axis_choice_dict[oc.name] = True
for uc in unshown_columns:
grid_axis_choice_dict[uc] = True
for bad_axis in tracker_constants.NOT_USED_IN_GRID_AXES:
if bad_axis in grid_axis_choice_dict:
del grid_axis_choice_dict[bad_axis]
grid_axis_choices = grid_axis_choice_dict.keys()
grid_axis_choices.sort()
grid_cell_mode = mr.cells
if len(results) > settings.max_tiles_in_grid and mr.cells == 'tiles':
grid_cell_mode = 'ids'
grid_view_data = {
'grid_limited': ezt.boolean(grid_limited),
'grid_shown': len(results),
'grid_x_headings': grid_x_headings,
'grid_y_headings': grid_y_headings,
'grid_data': grid_data,
'grid_axis_choices': grid_axis_choices,
'grid_cell_mode': grid_cell_mode,
'results': results, # Really only useful in if-any.
}
return grid_view_data
def GetCellFactories(self):
return tablecell.CELL_FACTORIES
def GetTableViewData(
self, mr, results, config, users_by_id, starred_iid_set, related_issues):
"""EZT template values to render a Table View of issues.
Args:
mr: commonly used info parsed from the request.
results: list of Issue PBs for the search results to be displayed.
config: The ProjectIssueConfig PB for the current project.
users_by_id: A dictionary {user_id: UserView} for all the users
involved in results.
starred_iid_set: Set of issues that the user has starred.
related_issues: dict {issue_id: issue} of pre-fetched related issues.
Returns:
Dictionary of page data for rendering of the Table View.
"""
# We need ordered_columns because EZT loops have no loop-counter available.
# And, we use column number in the Javascript to hide/show columns.
columns = mr.col_spec.split()
ordered_columns = [template_helpers.EZTItem(col_index=i, name=col)
for i, col in enumerate(columns)]
unshown_columns = table_view_helpers.ComputeUnshownColumns(
results, columns, config, tracker_constants.OTHER_BUILT_IN_COLS)
lower_columns = mr.col_spec.lower().split()
lower_group_by = mr.group_by_spec.lower().split()
table_data = _MakeTableData(
results, mr.auth.user_id,
starred_iid_set, lower_columns, lower_group_by,
users_by_id, self.GetCellFactories(), related_issues, config)
# Used to offer easy filtering of each unique value in each column.
column_values = table_view_helpers.ExtractUniqueValues(
lower_columns, results, users_by_id, config)
table_view_data = {
'table_data': table_data,
'column_values': column_values,
# Put ordered_columns inside a list of exactly 1 panel so that
# it can work the same as the dashboard initial panel list headers.
'panels': [template_helpers.EZTItem(ordered_columns=ordered_columns)],
'unshown_columns': unshown_columns,
'cursor': mr.cursor or mr.preview,
'preview': mr.preview,
'default_colspec': tracker_constants.DEFAULT_COL_SPEC,
'default_results_per_page': tracker_constants.DEFAULT_RESULTS_PER_PAGE,
'csv_link': framework_helpers.FormatURL(mr, 'csv'),
'preview_on_hover': ezt.boolean(
_ShouldPreviewOnHover(mr.auth.user_pb)),
}
return table_view_data
def GatherHelpData(self, mr, page_data):
"""Return a dict of values to drive on-page user help.
Args:
mr: common information parsed from the HTTP 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.
"""
cue = None
dismissed = []
if mr.auth.user_pb:
dismissed = mr.auth.user_pb.dismissed_cues
if mr.project_id:
config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
else:
config = tracker_bizobj.MakeDefaultProjectIssueConfig(None)
try:
_query_ast, is_fulltext_query = searchpipeline.ParseQuery(
mr, config, self.services)
except query2ast.InvalidQueryError:
is_fulltext_query = False
if mr.mode == 'grid' and mr.cells == 'tiles':
if len(page_data.get('results', [])) > settings.max_tiles_in_grid:
if 'showing_ids_instead_of_tiles' not in dismissed:
cue = 'showing_ids_instead_of_tiles'
if self.CheckPerm(mr, permissions.EDIT_ISSUE):
if ('italics_mean_derived' not in dismissed and
_AnyDerivedValues(page_data.get('table_data', []))):
cue = 'italics_mean_derived'
elif 'dit_keystrokes' not in dismissed and mr.mode != 'grid':
cue = 'dit_keystrokes'
elif 'stale_fulltext' not in dismissed and is_fulltext_query:
cue = 'stale_fulltext'
return {
'cue': cue,
}
def _AnyDerivedValues(table_data):
"""Return True if any value in the given table_data was derived."""
for row in table_data:
for cell in row.cells:
for item in cell.values:
if item.is_derived:
return True
return False
def _MakeTableData(
visible_results, logged_in_user_id, starred_iid_set,
lower_columns, lower_group_by, users_by_id, cell_factories,
related_issues, config):
"""Return a list of list row objects for display by EZT."""
table_data = table_view_helpers.MakeTableData(
visible_results, logged_in_user_id, starred_iid_set,
lower_columns, lower_group_by, users_by_id, cell_factories,
lambda issue: issue.issue_id, related_issues, config)
for row, art in zip(table_data, visible_results):
row.local_id = art.local_id
row.project_name = art.project_name
row.issue_ref = '%s:%d' % (art.project_name, art.local_id)
row.issue_url = tracker_helpers.FormatRelativeIssueURL(
art.project_name, urls.ISSUE_DETAIL, id=art.local_id)
return table_data
def _MakeGridData(
allowed_results, _logged_in_user_id, starred_iid_set, x_attr,
grid_col_values, y_attr, grid_row_values, users_by_id, all_label_values,
config):
"""Return all data needed for EZT to render the body of the grid view."""
def IssueViewFactory(issue):
return template_helpers.EZTItem(
summary=issue.summary, local_id=issue.local_id, issue_id=issue.issue_id,
status=issue.status or issue.derived_status, starred=None)
grid_data = grid_view_helpers.MakeGridData(
allowed_results, x_attr, grid_col_values, y_attr, grid_row_values,
users_by_id, IssueViewFactory, all_label_values, config)
for grid_row in grid_data:
for grid_cell in grid_row.cells_in_row:
for tile in grid_cell.tiles:
if tile.issue_id in starred_iid_set:
tile.starred = ezt.boolean(True)
return grid_data
def _GetStarredIssues(cnxn, logged_in_user_id, services):
"""Get the set of issues that the logged in user has starred."""
starred_iids = services.issue_star.LookupStarredItemIDs(
cnxn, logged_in_user_id)
return set(starred_iids)
def _ShouldPreviewOnHover(user):
"""Return true if we should show the issue preview when the user hovers.
Args:
user: User PB for the currently signed in user.
Returns:
True if the preview (peek) should open on hover over the issue ID.
"""
return settings.enable_quick_edit and user.preview_on_hover