Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/framework/grid_view_helpers.py b/framework/grid_view_helpers.py
new file mode 100644
index 0000000..44af6b7
--- /dev/null
+++ b/framework/grid_view_helpers.py
@@ -0,0 +1,491 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""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 proto 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 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