Index: appengine/monorail/framework/grid_view_helpers.py |
diff --git a/appengine/monorail/framework/grid_view_helpers.py b/appengine/monorail/framework/grid_view_helpers.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..833d2ae3382d2aef036d50e8eec66001b6c430d5 |
--- /dev/null |
+++ b/appengine/monorail/framework/grid_view_helpers.py |
@@ -0,0 +1,275 @@ |
+# 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 |
+ |
+"""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. |
+""" |
+ |
+import collections |
+import logging |
+ |
+from framework import framework_constants |
+from framework import sorting |
+from framework import template_helpers |
+from proto import tracker_pb2 |
+from tracker import tracker_bizobj |
+ |
+ |
+# 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: # 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 |
+ wk_labels = [wkl.label.lower().split('-', 1)[-1] |
+ for wkl in config.well_known_labels] |
+ 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): |
+ """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. |
+ |
+ 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: |
+ label_value_dict = all_label_values[art.local_id] |
+ x_vals = GetArtifactAttr( |
+ art, x_attr, users_by_id, label_value_dict, config) |
+ y_vals = GetArtifactAttr( |
+ art, y_attr, users_by_id, label_value_dict, config) |
+ 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 = [] |
+ for y in y_headings: |
+ cells_in_row = [] |
+ for x in x_headings: |
+ tiles = x_y_data[x, y] |
+ |
+ 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): |
+ """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. |
+ |
+ 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 blocked on, blocking, and mergedinto. |
+ 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): |
+ """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: |
+ vals = GetArtifactAttr( |
+ art, attr_name.lower(), users_by_id, all_label_values[art.local_id], |
+ config) |
+ if framework_constants.NO_VALUES in vals: |
+ return True |
+ |
+ return False |