| Index: appengine/monorail/tracker/issuelist.py
|
| diff --git a/appengine/monorail/tracker/issuelist.py b/appengine/monorail/tracker/issuelist.py
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..293c1da0ce16aac8703d3d0c2916f74acbee381b
|
| --- /dev/null
|
| +++ b/appengine/monorail/tracker/issuelist.py
|
| @@ -0,0 +1,427 @@
|
| +# 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
|
|
|