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 |