| # Copyright 2016 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Classes and functions for displaying grids of project artifacts. |
| |
| A grid is a two-dimensional display of items where the user can choose |
| the X and Y axes. |
| """ |
| from __future__ import print_function |
| from __future__ import division |
| from __future__ import absolute_import |
| |
| import ezt |
| |
| import collections |
| import logging |
| import settings |
| |
| from features import features_constants |
| from framework import framework_constants |
| from framework import sorting |
| from framework import table_view_helpers |
| from framework import template_helpers |
| from framework import urls |
| from mrproto import tracker_pb2 |
| from tracker import tracker_bizobj |
| from tracker import tracker_constants |
| from tracker import tracker_helpers |
| |
| |
| # We shorten long attribute values to fit into the table cells. |
| _MAX_CELL_DISPLAY_CHARS = 70 |
| |
| |
| def SortGridHeadings(col_name, heading_value_list, users_by_id, config, |
| asc_accessors): |
| """Sort the grid headings according to well-known status and label order. |
| |
| Args: |
| col_name: String column name that is used on that grid axis. |
| heading_value_list: List of grid row or column heading values. |
| users_by_id: Dict mapping user_ids to UserViews. |
| config: ProjectIssueConfig PB for the current project. |
| asc_accessors: Dict (col_name -> function()) for special columns. |
| |
| Returns: |
| The same heading values, but sorted in a logical order. |
| """ |
| decorated_list = [] |
| fd = tracker_bizobj.FindFieldDef(col_name, config) |
| if fd and fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE: # Handle fields. |
| for value in heading_value_list: |
| field_value = tracker_bizobj.GetFieldValueWithRawValue( |
| fd.field_type, None, users_by_id, value) |
| decorated_list.append([field_value, field_value]) |
| elif col_name == 'status': |
| wk_statuses = [wks.status.lower() |
| for wks in config.well_known_statuses] |
| decorated_list = [(_WKSortingValue(value.lower(), wk_statuses), value) |
| for value in heading_value_list] |
| |
| elif col_name in asc_accessors: # Special cols still sort alphabetically. |
| decorated_list = [(value, value) |
| for value in heading_value_list] |
| |
| else: # Anything else is assumed to be a label prefix |
| col_name_dash = col_name + '-' |
| wk_labels = [] |
| for wkl in config.well_known_labels: |
| lab_lower = wkl.label.lower() |
| if lab_lower.startswith(col_name_dash): |
| wk_labels.append(lab_lower.split('-', 1)[-1]) |
| decorated_list = [(_WKSortingValue(value.lower(), wk_labels), value) |
| for value in heading_value_list] |
| |
| decorated_list.sort() |
| result = [decorated_tuple[1] for decorated_tuple in decorated_list] |
| logging.info('Headers for %s are: %r', col_name, result) |
| return result |
| |
| |
| def _WKSortingValue(value, well_known_list): |
| """Return a value used to sort headings so that well-known ones are first.""" |
| if not value: |
| return sorting.MAX_STRING # Undefined values sort last. |
| try: |
| # well-known values sort by index |
| return '%09d' % well_known_list.index(value) |
| except ValueError: |
| return value # odd-ball values lexicographically after all well-known ones |
| |
| |
| def MakeGridData( |
| artifacts, x_attr, x_headings, y_attr, y_headings, users_by_id, |
| artifact_view_factory, all_label_values, config, related_issues, |
| hotlist_context_dict=None): |
| """Return a list of grid row items for display by EZT. |
| |
| Args: |
| artifacts: a list of issues to consider showing. |
| x_attr: lowercase name of the attribute that defines the x-axis. |
| x_headings: list of values for column headings. |
| y_attr: lowercase name of the attribute that defines the y-axis. |
| y_headings: list of values for row headings. |
| users_by_id: dict {user_id: user_view, ...} for referenced users. |
| artifact_view_factory: constructor for grid tiles. |
| all_label_values: pre-parsed dictionary of values from the key-value |
| labels on each issue: {issue_id: {key: [val,...], ...}, ...} |
| config: ProjectIssueConfig PB for the current project. |
| related_issues: dict {issue_id: issue} of pre-fetched related issues. |
| hotlist_context_dict: dict{issue_id: {hotlist_item_field: field_value, ..}} |
| |
| Returns: |
| A list of EZTItems, each representing one grid row, and each having |
| a nested list of grid cells. |
| |
| Each grid row has a row name, and a list of cells. Each cell has a |
| list of tiles. Each tile represents one artifact. Artifacts are |
| represented once in each cell that they match, so one artifact that |
| has multiple values for a certain attribute can occur in multiple cells. |
| """ |
| x_attr = x_attr.lower() |
| y_attr = y_attr.lower() |
| |
| # A flat dictionary {(x, y): [cell, ...], ...] for the whole grid. |
| x_y_data = collections.defaultdict(list) |
| |
| # Put each issue into the grid cell(s) where it belongs. |
| for art in artifacts: |
| if hotlist_context_dict: |
| hotlist_issues_context = hotlist_context_dict[art.issue_id] |
| else: |
| hotlist_issues_context = None |
| label_value_dict = all_label_values[art.local_id] |
| x_vals = GetArtifactAttr( |
| art, x_attr, users_by_id, label_value_dict, config, related_issues, |
| hotlist_issue_context=hotlist_issues_context) |
| y_vals = GetArtifactAttr( |
| art, y_attr, users_by_id, label_value_dict, config, related_issues, |
| hotlist_issue_context=hotlist_issues_context) |
| tile = artifact_view_factory(art) |
| |
| # Put the current issue into each cell where it belongs, which will usually |
| # be exactly 1 cell, but it could be a few. |
| if x_attr != '--' and y_attr != '--': # User specified both axes. |
| for x in x_vals: |
| for y in y_vals: |
| x_y_data[x, y].append(tile) |
| elif y_attr != '--': # User only specified Y axis. |
| for y in y_vals: |
| x_y_data['All', y].append(tile) |
| elif x_attr != '--': # User only specified X axis. |
| for x in x_vals: |
| x_y_data[x, 'All'].append(tile) |
| else: # User specified neither axis. |
| x_y_data['All', 'All'].append(tile) |
| |
| # Convert the dictionary to a list-of-lists so that EZT can iterate over it. |
| grid_data = [] |
| i = 0 |
| for y in y_headings: |
| cells_in_row = [] |
| for x in x_headings: |
| tiles = x_y_data[x, y] |
| for tile in tiles: |
| tile.data_idx = i |
| i += 1 |
| |
| drill_down = '' |
| if x_attr != '--': |
| drill_down = MakeDrillDownSearch(x_attr, x) |
| if y_attr != '--': |
| drill_down += MakeDrillDownSearch(y_attr, y) |
| |
| cells_in_row.append(template_helpers.EZTItem( |
| tiles=tiles, count=len(tiles), drill_down=drill_down)) |
| grid_data.append(template_helpers.EZTItem( |
| grid_y_heading=y, cells_in_row=cells_in_row)) |
| |
| return grid_data |
| |
| |
| def MakeDrillDownSearch(attr, value): |
| """Constructs search term for drill-down. |
| |
| Args: |
| attr: lowercase name of the attribute to narrow the search on. |
| value: value to narrow the search to. |
| |
| Returns: |
| String with user-query term to narrow a search to the given attr value. |
| """ |
| if value == framework_constants.NO_VALUES: |
| return '-has:%s ' % attr |
| else: |
| return '%s=%s ' % (attr, value) |
| |
| |
| def MakeLabelValuesDict(art): |
| """Return a dict of label values and a list of one-word labels. |
| |
| Args: |
| art: artifact object, e.g., an issue PB. |
| |
| Returns: |
| A dict {prefix: [suffix,...], ...} for each key-value label. |
| """ |
| label_values = collections.defaultdict(list) |
| for label_name in tracker_bizobj.GetLabels(art): |
| if '-' in label_name: |
| key, value = label_name.split('-', 1) |
| label_values[key.lower()].append(value) |
| |
| return label_values |
| |
| |
| def GetArtifactAttr( |
| art, attribute_name, users_by_id, label_attr_values_dict, |
| config, related_issues, hotlist_issue_context=None): |
| """Return the requested attribute values of the given artifact. |
| |
| Args: |
| art: a tracked artifact with labels, local_id, summary, stars, and owner. |
| attribute_name: lowercase string name of attribute to get. |
| users_by_id: dictionary of UserViews already created. |
| label_attr_values_dict: dictionary {'key': [value, ...], }. |
| config: ProjectIssueConfig PB for the current project. |
| related_issues: dict {issue_id: issue} of pre-fetched related issues. |
| hotlist_issue_context: dict of {hotlist_issue_field: field_value,..} |
| |
| Returns: |
| A list of string attribute values, or [framework_constants.NO_VALUES] |
| if the artifact has no value for that attribute. |
| """ |
| if attribute_name == '--': |
| return [] |
| if attribute_name == 'id': |
| return [art.local_id] |
| if attribute_name == 'summary': |
| return [art.summary] |
| if attribute_name == 'status': |
| return [tracker_bizobj.GetStatus(art)] |
| if attribute_name == 'stars': |
| return [art.star_count] |
| if attribute_name == 'attachments': |
| return [art.attachment_count] |
| # TODO(jrobbins): support blocking |
| if attribute_name == 'project': |
| return [art.project_name] |
| if attribute_name == 'mergedinto': |
| if art.merged_into and art.merged_into != 0: |
| return [tracker_bizobj.FormatIssueRef(( |
| related_issues[art.merged_into].project_name, |
| related_issues[art.merged_into].local_id))] |
| else: |
| return [framework_constants.NO_VALUES] |
| if attribute_name == 'blocked': |
| return ['Yes' if art.blocked_on_iids else 'No'] |
| if attribute_name == 'blockedon': |
| if not art.blocked_on_iids: |
| return [framework_constants.NO_VALUES] |
| else: |
| return [tracker_bizobj.FormatIssueRef(( |
| related_issues[blocked_on_iid].project_name, |
| related_issues[blocked_on_iid].local_id)) for |
| blocked_on_iid in art.blocked_on_iids] |
| if attribute_name == 'blocking': |
| if not art.blocking_iids: |
| return [framework_constants.NO_VALUES] |
| return [tracker_bizobj.FormatIssueRef(( |
| related_issues[blocking_iid].project_name, |
| related_issues[blocking_iid].local_id)) for |
| blocking_iid in art.blocking_iids] |
| if attribute_name == 'adder': |
| if hotlist_issue_context: |
| adder_id = hotlist_issue_context['adder_id'] |
| return [users_by_id[adder_id].display_name] |
| else: |
| return [framework_constants.NO_VALUES] |
| if attribute_name == 'added': |
| if hotlist_issue_context: |
| return [hotlist_issue_context['date_added']] |
| else: |
| return [framework_constants.NO_VALUES] |
| if attribute_name == 'reporter': |
| return [users_by_id[art.reporter_id].display_name] |
| if attribute_name == 'owner': |
| owner_id = tracker_bizobj.GetOwnerId(art) |
| if not owner_id: |
| return [framework_constants.NO_VALUES] |
| else: |
| return [users_by_id[owner_id].display_name] |
| if attribute_name == 'cc': |
| cc_ids = tracker_bizobj.GetCcIds(art) |
| if not cc_ids: |
| return [framework_constants.NO_VALUES] |
| else: |
| return [users_by_id[cc_id].display_name for cc_id in cc_ids] |
| if attribute_name == 'component': |
| comp_ids = list(art.component_ids) + list(art.derived_component_ids) |
| if not comp_ids: |
| return [framework_constants.NO_VALUES] |
| else: |
| paths = [] |
| for comp_id in comp_ids: |
| cd = tracker_bizobj.FindComponentDefByID(comp_id, config) |
| if cd: |
| paths.append(cd.path) |
| return paths |
| |
| # Check to see if it is a field. Process as field only if it is not an enum |
| # type because enum types are stored as key-value labels. |
| fd = tracker_bizobj.FindFieldDef(attribute_name, config) |
| if fd and fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE: |
| values = [] |
| for fv in art.field_values: |
| if fv.field_id == fd.field_id: |
| value = tracker_bizobj.GetFieldValueWithRawValue( |
| fd.field_type, fv, users_by_id, None) |
| values.append(value) |
| return values |
| |
| # Since it is not a built-in attribute or a field, it must be a key-value |
| # label. |
| return label_attr_values_dict.get( |
| attribute_name, [framework_constants.NO_VALUES]) |
| |
| |
| def AnyArtifactHasNoAttr( |
| artifacts, attr_name, users_by_id, all_label_values, config, |
| related_issues, hotlist_context_dict=None): |
| """Return true if any artifact does not have a value for attr_name.""" |
| # TODO(jrobbins): all_label_values needs to be keyed by issue_id to allow |
| # cross-project grid views. |
| for art in artifacts: |
| if hotlist_context_dict: |
| hotlist_issue_context = hotlist_context_dict[art.issue_id] |
| else: |
| hotlist_issue_context = None |
| vals = GetArtifactAttr( |
| art, attr_name.lower(), users_by_id, all_label_values[art.local_id], |
| config, related_issues, hotlist_issue_context=hotlist_issue_context) |
| if framework_constants.NO_VALUES in vals: |
| return True |
| |
| return False |
| |
| |
| def GetGridViewData( |
| mr, results, config, users_by_id, starred_iid_set, |
| grid_limited, related_issues, hotlist_context_dict=None): |
| """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. |
| related_issues: dict {issue_id: issue} of pre-fetched related issues. |
| hotlist_context_dict: dict for building a hotlist grid table |
| |
| 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)] |
| other_built_in_cols = (features_constants.OTHER_BUILT_IN_COLS if |
| hotlist_context_dict else |
| tracker_constants.OTHER_BUILT_IN_COLS) |
| unshown_columns = table_view_helpers.ComputeUnshownColumns( |
| results, columns, config, 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() |
| |
| # Prevent the user from using an axis that we don't support. |
| for bad_axis in tracker_constants.NOT_USED_IN_GRID_AXES: |
| lower_bad_axis = bad_axis.lower() |
| if grid_x_attr == lower_bad_axis: |
| grid_x_attr = '--' |
| if grid_y_attr == lower_bad_axis: |
| grid_y_attr = '--' |
| # Using the same attribute on both X and Y is not useful. |
| if grid_x_attr == grid_y_attr: |
| grid_x_attr = '--' |
| |
| all_label_values = {} |
| for art in results: |
| all_label_values[art.local_id] = ( |
| 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, related_issues, |
| hotlist_context_dict=hotlist_context_dict) |
| grid_x_headings = grid_x_items[0].filter_values |
| if AnyArtifactHasNoAttr( |
| results, grid_x_attr, users_by_id, all_label_values, |
| config, related_issues, hotlist_context_dict= hotlist_context_dict): |
| grid_x_headings.append(framework_constants.NO_VALUES) |
| grid_x_headings = 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, related_issues, |
| hotlist_context_dict=hotlist_context_dict) |
| grid_y_headings = grid_y_items[0].filter_values |
| if AnyArtifactHasNoAttr( |
| results, grid_y_attr, users_by_id, all_label_values, |
| config, related_issues, hotlist_context_dict= hotlist_context_dict): |
| grid_y_headings.append(framework_constants.NO_VALUES) |
| grid_y_headings = 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 = PrepareForMakeGridData( |
| results, starred_iid_set, grid_x_attr, grid_x_headings, |
| grid_y_attr, grid_y_headings, users_by_id, all_label_values, |
| config, related_issues, hotlist_context_dict=hotlist_context_dict) |
| |
| 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 = list(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 PrepareForMakeGridData( |
| allowed_results, starred_iid_set, x_attr, |
| grid_col_values, y_attr, grid_row_values, users_by_id, all_label_values, |
| config, related_issues, hotlist_context_dict=None): |
| """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, data_idx=0, |
| project_name=issue.project_name) |
| |
| grid_data = MakeGridData( |
| allowed_results, x_attr, grid_col_values, y_attr, grid_row_values, |
| users_by_id, IssueViewFactory, all_label_values, config, related_issues, |
| hotlist_context_dict=hotlist_context_dict) |
| issue_dict = {issue.issue_id: issue for issue in allowed_results} |
| 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) |
| issue = issue_dict[tile.issue_id] |
| tile.issue_url = tracker_helpers.FormatRelativeIssueURL( |
| issue.project_name, urls.ISSUE_DETAIL, id=tile.local_id) |
| tile.issue_ref = issue.project_name + ':' + str(tile.local_id) |
| |
| return grid_data |