OLD | NEW |
(Empty) | |
| 1 # Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 # Use of this source code is govered by a BSD-style |
| 3 # license that can be found in the LICENSE file or at |
| 4 # https://developers.google.com/open-source/licenses/bsd |
| 5 |
| 6 """Implementation of the issue list feature of the Monorail Issue Tracker. |
| 7 |
| 8 Summary of page classes: |
| 9 IssueList: Shows a table of issue that satisfy search criteria. |
| 10 """ |
| 11 |
| 12 import logging |
| 13 from third_party import ezt |
| 14 |
| 15 import settings |
| 16 from framework import framework_constants |
| 17 from framework import framework_helpers |
| 18 from framework import framework_views |
| 19 from framework import grid_view_helpers |
| 20 from framework import permissions |
| 21 from framework import servlet |
| 22 from framework import sql |
| 23 from framework import table_view_helpers |
| 24 from framework import template_helpers |
| 25 from framework import urls |
| 26 from framework import xsrf |
| 27 from search import frontendsearchpipeline |
| 28 from search import searchpipeline |
| 29 from search import query2ast |
| 30 from services import issue_svc |
| 31 from tracker import tablecell |
| 32 from tracker import tracker_bizobj |
| 33 from tracker import tracker_constants |
| 34 from tracker import tracker_helpers |
| 35 from tracker import tracker_views |
| 36 |
| 37 |
| 38 class IssueList(servlet.Servlet): |
| 39 """IssueList shows a page with a list of issues (search results). |
| 40 |
| 41 The issue list is actually a table with a configurable set of columns |
| 42 that can be edited by the user. |
| 43 """ |
| 44 |
| 45 _PAGE_TEMPLATE = 'tracker/issue-list-page.ezt' |
| 46 _ELIMINATE_BLANK_LINES = True |
| 47 _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES |
| 48 _DEFAULT_RESULTS_PER_PAGE = tracker_constants.DEFAULT_RESULTS_PER_PAGE |
| 49 |
| 50 def GatherPageData(self, mr): |
| 51 """Build up a dictionary of data values to use when rendering the page. |
| 52 |
| 53 Args: |
| 54 mr: commonly used info parsed from the request. |
| 55 |
| 56 Returns: |
| 57 Dict of values used by EZT for rendering the page. |
| 58 """ |
| 59 # Check if the user's query is just the ID of an existing issue. |
| 60 # TODO(jrobbins): consider implementing this for cross-project search. |
| 61 if mr.project and tracker_constants.JUMP_RE.match(mr.query): |
| 62 local_id = int(mr.query) |
| 63 try: |
| 64 _issue = self.services.issue.GetIssueByLocalID( |
| 65 mr.cnxn, mr.project_id, local_id) # does it exist? |
| 66 url = framework_helpers.FormatAbsoluteURL( |
| 67 mr, urls.ISSUE_DETAIL, id=local_id) |
| 68 self.redirect(url, abort=True) # Jump to specified issue. |
| 69 except issue_svc.NoSuchIssueException: |
| 70 pass # The user is searching for a number that is not an issue ID. |
| 71 |
| 72 with self.profiler.Phase('finishing config work'): |
| 73 if mr.project_id: |
| 74 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) |
| 75 else: |
| 76 config = tracker_bizobj.MakeDefaultProjectIssueConfig(None) |
| 77 |
| 78 with self.profiler.Phase('starting frontend search pipeline'): |
| 79 pipeline = frontendsearchpipeline.FrontendSearchPipeline( |
| 80 mr, self.services, self.profiler, self._DEFAULT_RESULTS_PER_PAGE) |
| 81 |
| 82 # Perform promises that require authentication information. |
| 83 with self.profiler.Phase('getting stars'): |
| 84 starred_iid_set = _GetStarredIssues( |
| 85 mr.cnxn, mr.auth.user_id, self.services) |
| 86 |
| 87 with self.profiler.Phase('computing col_spec'): |
| 88 mr.ComputeColSpec(config) |
| 89 |
| 90 if not mr.errors.AnyErrors(): |
| 91 pipeline.SearchForIIDs() |
| 92 pipeline.MergeAndSortIssues() |
| 93 pipeline.Paginate() |
| 94 |
| 95 with self.profiler.Phase('publishing emails'): |
| 96 framework_views.RevealAllEmailsToMembers(mr, pipeline.users_by_id) |
| 97 # TODO(jrobbins): get the configs for all result issues and |
| 98 # harmonize them to get field defs including restrictions. |
| 99 |
| 100 with self.profiler.Phase('getting related issues'): |
| 101 related_iids = set() |
| 102 if pipeline.grid_mode: |
| 103 results_needing_related = pipeline.allowed_results or [] |
| 104 else: |
| 105 results_needing_related = pipeline.visible_results or [] |
| 106 lower_cols = mr.col_spec.lower().split() |
| 107 for issue in results_needing_related: |
| 108 if 'blockedon' in lower_cols: |
| 109 related_iids.update(issue.blocked_on_iids) |
| 110 if 'blocking' in lower_cols: |
| 111 related_iids.update(issue.blocking_iids) |
| 112 if 'mergedinto' in lower_cols: |
| 113 related_iids.add(issue.merged_into) |
| 114 related_issues_list = self.services.issue.GetIssues( |
| 115 mr.cnxn, list(related_iids)) |
| 116 related_issues = {issue.issue_id: issue for issue in related_issues_list} |
| 117 |
| 118 with self.profiler.Phase('building table/grid'): |
| 119 if pipeline.grid_mode: |
| 120 page_data = self.GetGridViewData( |
| 121 mr, pipeline.allowed_results or [], config, pipeline.users_by_id, |
| 122 starred_iid_set, pipeline.grid_limited) |
| 123 else: |
| 124 page_data = self.GetTableViewData( |
| 125 mr, pipeline.visible_results or [], config, pipeline.users_by_id, |
| 126 starred_iid_set, related_issues) |
| 127 |
| 128 # We show a special message when no query will every produce any results |
| 129 # because the project has no issues in it. |
| 130 with self.profiler.Phase('starting stars promise'): |
| 131 if mr.project_id: |
| 132 project_has_any_issues = ( |
| 133 pipeline.allowed_results or |
| 134 self.services.issue.GetHighestLocalID(mr.cnxn, mr.project_id) != 0) |
| 135 else: |
| 136 project_has_any_issues = True # Message only applies in a project. |
| 137 |
| 138 with self.profiler.Phase('making page perms'): |
| 139 page_perms = self.MakePagePerms( |
| 140 mr, None, |
| 141 permissions.SET_STAR, |
| 142 permissions.CREATE_ISSUE, |
| 143 permissions.EDIT_ISSUE) |
| 144 |
| 145 # Update page data with variables that are shared between list and |
| 146 # grid view. |
| 147 page_data.update({ |
| 148 'issue_tab_mode': 'issueList', |
| 149 'pagination': pipeline.pagination, |
| 150 'is_cross_project': ezt.boolean(len(pipeline.query_project_ids) != 1), |
| 151 'project_has_any_issues': ezt.boolean(project_has_any_issues), |
| 152 'colspec': mr.col_spec, |
| 153 'page_perms': page_perms, |
| 154 'grid_mode': ezt.boolean(pipeline.grid_mode), |
| 155 'panel_id': mr.panel_id, |
| 156 'set_star_token': xsrf.GenerateToken( |
| 157 mr.auth.user_id, '/p/%s%s.do' % ( |
| 158 mr.project_name, urls.ISSUE_SETSTAR_JSON)), |
| 159 'is_missing_shards': ezt.boolean(len(pipeline.error_responses)), |
| 160 'missing_shard_count': len(pipeline.error_responses), |
| 161 }) |
| 162 |
| 163 return page_data |
| 164 |
| 165 def GetGridViewData( |
| 166 self, mr, results, config, users_by_id, starred_iid_set, grid_limited): |
| 167 """EZT template values to render a Grid View of issues. |
| 168 |
| 169 Args: |
| 170 mr: commonly used info parsed from the request. |
| 171 results: The Issue PBs that are the search results to be displayed. |
| 172 config: The ProjectConfig PB for the project this view is in. |
| 173 users_by_id: A dictionary {user_id: user_view,...} for all the users |
| 174 involved in results. |
| 175 starred_iid_set: Set of issues that the user has starred. |
| 176 grid_limited: True if the results were limited to fit within the grid. |
| 177 |
| 178 Returns: |
| 179 Dictionary for EZT template rendering of the Grid View. |
| 180 """ |
| 181 # We need ordered_columns because EZT loops have no loop-counter available. |
| 182 # And, we use column number in the Javascript to hide/show columns. |
| 183 columns = mr.col_spec.split() |
| 184 ordered_columns = [template_helpers.EZTItem(col_index=i, name=col) |
| 185 for i, col in enumerate(columns)] |
| 186 unshown_columns = table_view_helpers.ComputeUnshownColumns( |
| 187 results, columns, config, tracker_constants.OTHER_BUILT_IN_COLS) |
| 188 |
| 189 grid_x_attr = (mr.x or config.default_x_attr or '--').lower() |
| 190 grid_y_attr = (mr.y or config.default_y_attr or '--').lower() |
| 191 all_label_values = {} |
| 192 for art in results: |
| 193 all_label_values[art.local_id] = ( |
| 194 grid_view_helpers.MakeLabelValuesDict(art)) |
| 195 |
| 196 if grid_x_attr == '--': |
| 197 grid_x_headings = ['All'] |
| 198 else: |
| 199 grid_x_items = table_view_helpers.ExtractUniqueValues( |
| 200 [grid_x_attr], results, users_by_id, config) |
| 201 grid_x_headings = grid_x_items[0].filter_values |
| 202 if grid_view_helpers.AnyArtifactHasNoAttr( |
| 203 results, grid_x_attr, users_by_id, all_label_values, config): |
| 204 grid_x_headings.append(framework_constants.NO_VALUES) |
| 205 grid_x_headings = grid_view_helpers.SortGridHeadings( |
| 206 grid_x_attr, grid_x_headings, users_by_id, config, |
| 207 tracker_helpers.SORTABLE_FIELDS) |
| 208 |
| 209 if grid_y_attr == '--': |
| 210 grid_y_headings = ['All'] |
| 211 else: |
| 212 grid_y_items = table_view_helpers.ExtractUniqueValues( |
| 213 [grid_y_attr], results, users_by_id, config) |
| 214 grid_y_headings = grid_y_items[0].filter_values |
| 215 if grid_view_helpers.AnyArtifactHasNoAttr( |
| 216 results, grid_y_attr, users_by_id, all_label_values, config): |
| 217 grid_y_headings.append(framework_constants.NO_VALUES) |
| 218 grid_y_headings = grid_view_helpers.SortGridHeadings( |
| 219 grid_y_attr, grid_y_headings, users_by_id, config, |
| 220 tracker_helpers.SORTABLE_FIELDS) |
| 221 |
| 222 logging.info('grid_x_headings = %s', grid_x_headings) |
| 223 logging.info('grid_y_headings = %s', grid_y_headings) |
| 224 grid_data = _MakeGridData( |
| 225 results, mr.auth.user_id, |
| 226 starred_iid_set, grid_x_attr, grid_x_headings, |
| 227 grid_y_attr, grid_y_headings, users_by_id, all_label_values, |
| 228 config) |
| 229 |
| 230 grid_axis_choice_dict = {} |
| 231 for oc in ordered_columns: |
| 232 grid_axis_choice_dict[oc.name] = True |
| 233 for uc in unshown_columns: |
| 234 grid_axis_choice_dict[uc] = True |
| 235 for bad_axis in tracker_constants.NOT_USED_IN_GRID_AXES: |
| 236 if bad_axis in grid_axis_choice_dict: |
| 237 del grid_axis_choice_dict[bad_axis] |
| 238 grid_axis_choices = grid_axis_choice_dict.keys() |
| 239 grid_axis_choices.sort() |
| 240 |
| 241 grid_cell_mode = mr.cells |
| 242 if len(results) > settings.max_tiles_in_grid and mr.cells == 'tiles': |
| 243 grid_cell_mode = 'ids' |
| 244 |
| 245 grid_view_data = { |
| 246 'grid_limited': ezt.boolean(grid_limited), |
| 247 'grid_shown': len(results), |
| 248 'grid_x_headings': grid_x_headings, |
| 249 'grid_y_headings': grid_y_headings, |
| 250 'grid_data': grid_data, |
| 251 'grid_axis_choices': grid_axis_choices, |
| 252 'grid_cell_mode': grid_cell_mode, |
| 253 'results': results, # Really only useful in if-any. |
| 254 } |
| 255 return grid_view_data |
| 256 |
| 257 def GetCellFactories(self): |
| 258 return tablecell.CELL_FACTORIES |
| 259 |
| 260 def GetTableViewData( |
| 261 self, mr, results, config, users_by_id, starred_iid_set, related_issues): |
| 262 """EZT template values to render a Table View of issues. |
| 263 |
| 264 Args: |
| 265 mr: commonly used info parsed from the request. |
| 266 results: list of Issue PBs for the search results to be displayed. |
| 267 config: The ProjectIssueConfig PB for the current project. |
| 268 users_by_id: A dictionary {user_id: UserView} for all the users |
| 269 involved in results. |
| 270 starred_iid_set: Set of issues that the user has starred. |
| 271 related_issues: dict {issue_id: issue} of pre-fetched related issues. |
| 272 |
| 273 Returns: |
| 274 Dictionary of page data for rendering of the Table View. |
| 275 """ |
| 276 # We need ordered_columns because EZT loops have no loop-counter available. |
| 277 # And, we use column number in the Javascript to hide/show columns. |
| 278 columns = mr.col_spec.split() |
| 279 ordered_columns = [template_helpers.EZTItem(col_index=i, name=col) |
| 280 for i, col in enumerate(columns)] |
| 281 unshown_columns = table_view_helpers.ComputeUnshownColumns( |
| 282 results, columns, config, tracker_constants.OTHER_BUILT_IN_COLS) |
| 283 |
| 284 lower_columns = mr.col_spec.lower().split() |
| 285 lower_group_by = mr.group_by_spec.lower().split() |
| 286 table_data = _MakeTableData( |
| 287 results, mr.auth.user_id, |
| 288 starred_iid_set, lower_columns, lower_group_by, |
| 289 users_by_id, self.GetCellFactories(), related_issues, config) |
| 290 |
| 291 # Used to offer easy filtering of each unique value in each column. |
| 292 column_values = table_view_helpers.ExtractUniqueValues( |
| 293 lower_columns, results, users_by_id, config) |
| 294 |
| 295 table_view_data = { |
| 296 'table_data': table_data, |
| 297 'column_values': column_values, |
| 298 # Put ordered_columns inside a list of exactly 1 panel so that |
| 299 # it can work the same as the dashboard initial panel list headers. |
| 300 'panels': [template_helpers.EZTItem(ordered_columns=ordered_columns)], |
| 301 'unshown_columns': unshown_columns, |
| 302 'cursor': mr.cursor or mr.preview, |
| 303 'preview': mr.preview, |
| 304 'default_colspec': tracker_constants.DEFAULT_COL_SPEC, |
| 305 'default_results_per_page': tracker_constants.DEFAULT_RESULTS_PER_PAGE, |
| 306 'csv_link': framework_helpers.FormatURL(mr, 'csv'), |
| 307 'preview_on_hover': ezt.boolean( |
| 308 _ShouldPreviewOnHover(mr.auth.user_pb)), |
| 309 } |
| 310 return table_view_data |
| 311 |
| 312 def GatherHelpData(self, mr, page_data): |
| 313 """Return a dict of values to drive on-page user help. |
| 314 |
| 315 Args: |
| 316 mr: common information parsed from the HTTP request. |
| 317 page_data: Dictionary of base and page template data. |
| 318 |
| 319 Returns: |
| 320 A dict of values to drive on-page user help, to be added to page_data. |
| 321 """ |
| 322 cue = None |
| 323 dismissed = [] |
| 324 if mr.auth.user_pb: |
| 325 dismissed = mr.auth.user_pb.dismissed_cues |
| 326 |
| 327 if mr.project_id: |
| 328 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) |
| 329 else: |
| 330 config = tracker_bizobj.MakeDefaultProjectIssueConfig(None) |
| 331 |
| 332 try: |
| 333 _query_ast, is_fulltext_query = searchpipeline.ParseQuery( |
| 334 mr, config, self.services) |
| 335 except query2ast.InvalidQueryError: |
| 336 is_fulltext_query = False |
| 337 |
| 338 if mr.mode == 'grid' and mr.cells == 'tiles': |
| 339 if len(page_data.get('results', [])) > settings.max_tiles_in_grid: |
| 340 if 'showing_ids_instead_of_tiles' not in dismissed: |
| 341 cue = 'showing_ids_instead_of_tiles' |
| 342 |
| 343 if self.CheckPerm(mr, permissions.EDIT_ISSUE): |
| 344 if ('italics_mean_derived' not in dismissed and |
| 345 _AnyDerivedValues(page_data.get('table_data', []))): |
| 346 cue = 'italics_mean_derived' |
| 347 elif 'dit_keystrokes' not in dismissed and mr.mode != 'grid': |
| 348 cue = 'dit_keystrokes' |
| 349 elif 'stale_fulltext' not in dismissed and is_fulltext_query: |
| 350 cue = 'stale_fulltext' |
| 351 |
| 352 return { |
| 353 'cue': cue, |
| 354 } |
| 355 |
| 356 |
| 357 def _AnyDerivedValues(table_data): |
| 358 """Return True if any value in the given table_data was derived.""" |
| 359 for row in table_data: |
| 360 for cell in row.cells: |
| 361 for item in cell.values: |
| 362 if item.is_derived: |
| 363 return True |
| 364 |
| 365 return False |
| 366 |
| 367 |
| 368 def _MakeTableData( |
| 369 visible_results, logged_in_user_id, starred_iid_set, |
| 370 lower_columns, lower_group_by, users_by_id, cell_factories, |
| 371 related_issues, config): |
| 372 """Return a list of list row objects for display by EZT.""" |
| 373 table_data = table_view_helpers.MakeTableData( |
| 374 visible_results, logged_in_user_id, starred_iid_set, |
| 375 lower_columns, lower_group_by, users_by_id, cell_factories, |
| 376 lambda issue: issue.issue_id, related_issues, config) |
| 377 |
| 378 for row, art in zip(table_data, visible_results): |
| 379 row.local_id = art.local_id |
| 380 row.project_name = art.project_name |
| 381 row.issue_ref = '%s:%d' % (art.project_name, art.local_id) |
| 382 row.issue_url = tracker_helpers.FormatRelativeIssueURL( |
| 383 art.project_name, urls.ISSUE_DETAIL, id=art.local_id) |
| 384 |
| 385 return table_data |
| 386 |
| 387 |
| 388 def _MakeGridData( |
| 389 allowed_results, _logged_in_user_id, starred_iid_set, x_attr, |
| 390 grid_col_values, y_attr, grid_row_values, users_by_id, all_label_values, |
| 391 config): |
| 392 """Return all data needed for EZT to render the body of the grid view.""" |
| 393 |
| 394 def IssueViewFactory(issue): |
| 395 return template_helpers.EZTItem( |
| 396 summary=issue.summary, local_id=issue.local_id, issue_id=issue.issue_id, |
| 397 status=issue.status or issue.derived_status, starred=None) |
| 398 |
| 399 grid_data = grid_view_helpers.MakeGridData( |
| 400 allowed_results, x_attr, grid_col_values, y_attr, grid_row_values, |
| 401 users_by_id, IssueViewFactory, all_label_values, config) |
| 402 for grid_row in grid_data: |
| 403 for grid_cell in grid_row.cells_in_row: |
| 404 for tile in grid_cell.tiles: |
| 405 if tile.issue_id in starred_iid_set: |
| 406 tile.starred = ezt.boolean(True) |
| 407 |
| 408 return grid_data |
| 409 |
| 410 |
| 411 def _GetStarredIssues(cnxn, logged_in_user_id, services): |
| 412 """Get the set of issues that the logged in user has starred.""" |
| 413 starred_iids = services.issue_star.LookupStarredItemIDs( |
| 414 cnxn, logged_in_user_id) |
| 415 return set(starred_iids) |
| 416 |
| 417 |
| 418 def _ShouldPreviewOnHover(user): |
| 419 """Return true if we should show the issue preview when the user hovers. |
| 420 |
| 421 Args: |
| 422 user: User PB for the currently signed in user. |
| 423 |
| 424 Returns: |
| 425 True if the preview (peek) should open on hover over the issue ID. |
| 426 """ |
| 427 return settings.enable_quick_edit and user.preview_on_hover |
OLD | NEW |