blob: 1ff9b92e1fb858b6db7ae042ead0a2dba6bb37b5 [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 businesslogic import work_env
from framework import exceptions
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 searchpipeline
from search import query2ast
from tracker import tablecell
from tracker import tracker_bizobj
from tracker import tracker_constants
from tracker import tracker_helpers
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
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.
"""
search_error_message = ''
with work_env.WorkEnv(mr, self.services) as we:
# 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:
we.GetIssueByLocalID(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 exceptions.NoSuchIssueException:
pass # The user is searching for a number that is not an issue ID.
with mr.profiler.Phase('finishing config work'):
if mr.project_id:
config = we.GetProjectConfig(mr.project_id)
else:
config = tracker_bizobj.MakeDefaultProjectIssueConfig(None)
pipeline = we.ListIssues()
starred_iid_set = set(we.ListStarredIssueIDs())
with mr.profiler.Phase('computing col_spec'):
mr.ComputeColSpec(config)
with mr.profiler.Phase('publishing emails'):
framework_views.RevealAllEmailsToMembers(mr, pipeline.users_by_id)
with mr.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()
lower_cols.extend(mr.group_by_spec.lower().split())
grid_x = (mr.x or config.default_x_attr or '--').lower()
grid_y = (mr.y or config.default_y_attr or '--').lower()
lower_cols.append(grid_x)
lower_cols.append(grid_y)
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 mr.profiler.Phase('building table/grid'):
if pipeline.grid_mode:
page_data = grid_view_helpers.GetGridViewData(
mr, pipeline.allowed_results or [], pipeline.harmonized_config,
pipeline.users_by_id, starred_iid_set, pipeline.grid_limited,
related_issues)
else:
page_data = self.GetTableViewData(
mr, pipeline.visible_results or [], pipeline.harmonized_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 mr.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 mr.profiler.Phase('making page perms'):
page_perms = self.MakePagePerms(
mr, None,
permissions.SET_STAR,
permissions.CREATE_ISSUE,
permissions.EDIT_ISSUE)
if pipeline.error_responses:
search_error_message = (
'%d search backends did not respond or had errors. '
'These results are probably incomplete.'
% len(pipeline.error_responses))
# Update page data with variables that are shared between list and
# grid view.
user_hotlists = self.services.features.GetHotlistsByUserID(
mr.cnxn, mr.auth.user_id)
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),
'list_mode': ezt.boolean(pipeline.list_mode),
'chart_mode': ezt.boolean(pipeline.chart_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)),
'search_error_message': search_error_message,
'is_hotlist': ezt.boolean(False),
# for update-issues-hotlists-dialog, user_remininag_hotlists
# are displayed with their checkboxes unchecked.
'user_remaining_hotlists': user_hotlists,
'user_issue_hotlists': [], # for update-issues-hotlsits-dialog
# the following are needed by templates for hotlists
'owner_permissions': ezt.boolean(False),
'editor_permissions': ezt.boolean(False),
'edit_hotlist_token': '',
'add_local_ids': '',
'placeholder': '',
'col_spec': ''
})
return page_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, 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, related_issues)
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', num=settings.max_artifact_search_results_per_page),
'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.
"""
help_data = super(IssueList, self).GatherHelpData(mr, page_data)
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
query_plus_col_spec = '%r %r' % (
mr.query and mr.query.lower(), mr.col_spec and mr.col_spec.lower())
uses_timestamp_term = any(
col_name in query_plus_col_spec
for col_name in ('ownermodified', 'statusmodified', 'componentmodified'))
if (mr.mode == 'grid' and mr.cells == 'tiles' and
len(page_data.get('results', [])) > settings.max_tiles_in_grid and
'showing_ids_instead_of_tiles' not in dismissed):
help_data['cue'] = 'showing_ids_instead_of_tiles'
elif (_AnyDerivedValues(page_data.get('table_data', [])) and
'italics_mean_derived' not in dismissed):
help_data['cue'] = 'italics_mean_derived'
elif (uses_timestamp_term and
'issue_timestamps' not in dismissed):
help_data['cue'] = 'issue_timestamps'
# Note that the following are only offered to signed in users because
# otherwise the first one would appear all the time to anon users.
elif (mr.auth.user_id and mr.mode != 'grid' and
'dit_keystrokes' not in dismissed):
help_data['cue'] = 'dit_keystrokes'
elif (mr.auth.user_id and is_fulltext_query and
'stale_fulltext' not in dismissed):
help_data['cue'] = 'stale_fulltext'
return help_data
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, 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, 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 _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