Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(2190)

Unified Diff: appengine/monorail/framework/table_view_helpers.py

Issue 1868553004: Open Source Monorail (Closed) Base URL: https://chromium.googlesource.com/infra/infra.git@master
Patch Set: Rebase Created 4 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
« no previous file with comments | « appengine/monorail/framework/sql.py ('k') | appengine/monorail/framework/template_helpers.py » ('j') | no next file with comments »
Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
Index: appengine/monorail/framework/table_view_helpers.py
diff --git a/appengine/monorail/framework/table_view_helpers.py b/appengine/monorail/framework/table_view_helpers.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ca8098cce4ab00ea8421d27b2a056c93f174c0a
--- /dev/null
+++ b/appengine/monorail/framework/table_view_helpers.py
@@ -0,0 +1,627 @@
+# 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 lists of project artifacts.
+
+This file exports classes TableRow and TableCell that help
+represent HTML table rows and cells. These classes make rendering
+HTML tables that list project artifacts much easier to do with EZT.
+"""
+
+import collections
+import logging
+
+from third_party import ezt
+
+from framework import framework_constants
+from framework import template_helpers
+from proto import tracker_pb2
+from tracker import tracker_bizobj
+
+
+def ComputeUnshownColumns(results, shown_columns, config, built_in_cols):
+ """Return a list of unshown columns that the user could add.
+
+ Args:
+ results: list of search result PBs. Each must have labels.
+ shown_columns: list of column names to be used in results table.
+ config: harmonized config for the issue search, including all
+ well known labels and custom fields.
+ built_in_cols: list of other column names that are built into the tool.
+ E.g., star count, or creation date.
+
+ Returns:
+ List of column names to append to the "..." menu.
+ """
+ unshown_set = set() # lowercases column names
+ unshown_list = [] # original-case column names
+ shown_set = {col.lower() for col in shown_columns}
+ labels_already_seen = set() # whole labels, original case
+
+ def _MaybeAddLabel(label_name):
+ """Add the key part of the given label if needed."""
+ if label_name.lower() in labels_already_seen:
+ return
+ labels_already_seen.add(label_name.lower())
+ if '-' in label_name:
+ col, _value = label_name.split('-', 1)
+ _MaybeAddCol(col)
+
+ def _MaybeAddCol(col):
+ if col.lower() not in shown_set and col.lower() not in unshown_set:
+ unshown_list.append(col)
+ unshown_set.add(col.lower())
+
+ # The user can always add any of the default columns.
+ for col in config.default_col_spec.split():
+ _MaybeAddCol(col)
+
+ # The user can always add any of the built-in columns.
+ for col in built_in_cols:
+ _MaybeAddCol(col)
+
+ # The user can add a column for any well-known labels
+ for wkl in config.well_known_labels:
+ _MaybeAddLabel(wkl.label)
+
+ # The user can add a column for any custom field
+ field_ids_alread_seen = set()
+ for fd in config.field_defs:
+ field_lower = fd.field_name.lower()
+ field_ids_alread_seen.add(fd.field_id)
+ if field_lower not in shown_set and field_lower not in unshown_set:
+ unshown_list.append(fd.field_name)
+ unshown_set.add(field_lower)
+
+ # The user can add a column for any key-value label or field in the results.
+ for r in results:
+ for label_name in tracker_bizobj.GetLabels(r):
+ _MaybeAddLabel(label_name)
+ for field_value in r.field_values:
+ if field_value.field_id not in field_ids_alread_seen:
+ field_ids_alread_seen.add(field_value.field_id)
+ fd = tracker_bizobj.FindFieldDefByID(field_value.field_id, config)
+ if fd: # could be None for a foreign field, which we don't display.
+ field_lower = fd.field_name.lower()
+ if field_lower not in shown_set and field_lower not in unshown_set:
+ unshown_list.append(fd.field_name)
+ unshown_set.add(field_lower)
+
+ return sorted(unshown_list)
+
+
+def ExtractUniqueValues(columns, artifact_list, users_by_id, config):
+ """Build a nested list of unique values so the user can auto-filter.
+
+ Args:
+ columns: a list of lowercase column name strings, which may contain
+ combined columns like "priority/pri".
+ artifact_list: a list of artifacts in the complete set of search results.
+ users_by_id: dict mapping user_ids to UserViews.
+ config: ProjectIssueConfig PB for the current project.
+
+ Returns:
+ [EZTItem(col1, colname1, [val11, val12,...]), ...]
+ A list of EZTItems, each of which has a col_index, column_name,
+ and a list of unique values that appear in that column.
+ """
+ column_values = {col_name: {} for col_name in columns}
+
+ # For each combined column "a/b/c", add entries that point from "a" back
+ # to "a/b/c", from "b" back to "a/b/c", and from "c" back to "a/b/c".
+ combined_column_parts = collections.defaultdict(list)
+ for col in columns:
+ if '/' in col:
+ for col_part in col.split('/'):
+ combined_column_parts[col_part].append(col)
+
+ unique_labels = set()
+ for art in artifact_list:
+ unique_labels.update(tracker_bizobj.GetLabels(art))
+
+ for label in unique_labels:
+ if '-' in label:
+ col, val = label.split('-', 1)
+ col = col.lower()
+ if col in column_values:
+ column_values[col][val.lower()] = val
+ if col in combined_column_parts:
+ for combined_column in combined_column_parts[col]:
+ column_values[combined_column][val.lower()] = val
+ else:
+ if 'summary' in column_values:
+ column_values['summary'][label.lower()] = label
+
+ # TODO(jrobbins): Consider refacting some of this to tracker_bizobj
+ # or a new builtins.py to reduce duplication.
+ if 'reporter' in column_values:
+ for art in artifact_list:
+ reporter_id = art.reporter_id
+ if reporter_id and reporter_id in users_by_id:
+ reporter_username = users_by_id[reporter_id].display_name
+ column_values['reporter'][reporter_username] = reporter_username
+
+ if 'owner' in column_values:
+ for art in artifact_list:
+ owner_id = tracker_bizobj.GetOwnerId(art)
+ if owner_id and owner_id in users_by_id:
+ owner_username = users_by_id[owner_id].display_name
+ column_values['owner'][owner_username] = owner_username
+
+ if 'cc' in column_values:
+ for art in artifact_list:
+ cc_ids = tracker_bizobj.GetCcIds(art)
+ for cc_id in cc_ids:
+ if cc_id and cc_id in users_by_id:
+ cc_username = users_by_id[cc_id].display_name
+ column_values['cc'][cc_username] = cc_username
+
+ if 'component' in column_values:
+ for art in artifact_list:
+ all_comp_ids = list(art.component_ids) + list(art.derived_component_ids)
+ for component_id in all_comp_ids:
+ cd = tracker_bizobj.FindComponentDefByID(component_id, config)
+ if cd:
+ column_values['component'][cd.path] = cd.path
+
+ if 'stars' in column_values:
+ for art in artifact_list:
+ star_count = art.star_count
+ column_values['stars'][star_count] = star_count
+
+ if 'status' in column_values:
+ for art in artifact_list:
+ status = tracker_bizobj.GetStatus(art)
+ if status:
+ column_values['status'][status.lower()] = status
+
+ # TODO(jrobbins): merged into, blocked on, and blocking. And, the ability
+ # to parse a user query on those fields and do a SQL search.
+
+ if 'attachments' in column_values:
+ for art in artifact_list:
+ attachment_count = art.attachment_count
+ column_values['attachments'][attachment_count] = attachment_count
+
+ # Add all custom field values if the custom field name is a shown column.
+ field_id_to_col = {}
+ for art in artifact_list:
+ for fv in art.field_values:
+ field_col, field_type = field_id_to_col.get(fv.field_id, (None, None))
+ if field_col == 'NOT_SHOWN':
+ continue
+ if field_col is None:
+ fd = tracker_bizobj.FindFieldDefByID(fv.field_id, config)
+ if not fd:
+ field_id_to_col[fv.field_id] = 'NOT_SHOWN', None
+ continue
+ field_col = fd.field_name.lower()
+ field_type = fd.field_type
+ if field_col not in column_values:
+ field_id_to_col[fv.field_id] = 'NOT_SHOWN', None
+ continue
+ field_id_to_col[fv.field_id] = field_col, field_type
+
+ if field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
+ continue # Already handled by label parsing
+ elif field_type == tracker_pb2.FieldTypes.INT_TYPE:
+ val = fv.int_value
+ elif field_type == tracker_pb2.FieldTypes.STR_TYPE:
+ val = fv.str_value
+ elif field_type == tracker_pb2.FieldTypes.USER_TYPE:
+ user = users_by_id.get(fv.user_id)
+ val = user.email if user else framework_constants.NO_USER_NAME
+ elif field_type == tracker_pb2.FieldTypes.DATE_TYPE:
+ val = fv.int_value # TODO(jrobbins): convert to date
+ elif field_type == tracker_pb2.FieldTypes.BOOL_TYPE:
+ val = 'Yes' if fv.int_value else 'No'
+
+ column_values[field_col][val] = val
+
+ # TODO(jrobbins): make the capitalization of well-known unique label and
+ # status values match the way it is written in the issue config.
+
+ # Return EZTItems for each column in left-to-right display order.
+ result = []
+ for i, col_name in enumerate(columns):
+ # TODO(jrobbins): sort each set of column values top-to-bottom, by the
+ # order specified in the project artifact config. For now, just sort
+ # lexicographically to make expected output defined.
+ sorted_col_values = sorted(column_values[col_name].values())
+ result.append(template_helpers.EZTItem(
+ col_index=i, column_name=col_name, filter_values=sorted_col_values))
+
+ return result
+
+
+def MakeTableData(
+ visible_results, logged_in_user_id, starred_items,
+ lower_columns, lower_group_by, users_by_id, cell_factories,
+ id_accessor, related_issues, config):
+ """Return a list of list row objects for display by EZT.
+
+ Args:
+ visible_results: list of artifacts to display on one pagination page.
+ logged_in_user_id: user ID of the signed in user, or None.
+ starred_items: list of IDs/names of items in the current project
+ that the signed in user has starred.
+ lower_columns: list of column names to display, all lowercase. These can
+ be combined column names, e.g., 'priority/pri'.
+ lower_group_by: list of column names that define row groups, all lowercase.
+ users_by_id: dict mapping user IDs to UserViews.
+ cell_factories: dict of functions that each create TableCell objects.
+ id_accessor: function that maps from an artifact to the ID/name that might
+ be in the starred items list.
+ related_issues: dict {issue_id: issue} of pre-fetched related issues.
+ config: ProjectIssueConfig PB for the current project.
+
+ Returns:
+ A list of TableRow objects, one for each visible result.
+ """
+ table_data = []
+
+ group_cell_factories = [
+ ChooseCellFactory(group.strip('-'), cell_factories, config)
+ for group in lower_group_by]
+
+ # Make a list of cell factories, one for each column.
+ factories_to_use = [
+ ChooseCellFactory(col, cell_factories, config) for col in lower_columns]
+
+ current_group = None
+ for idx, art in enumerate(visible_results):
+ owner_is_me = ezt.boolean(
+ logged_in_user_id and
+ tracker_bizobj.GetOwnerId(art) == logged_in_user_id)
+ row = MakeRowData(
+ art, lower_columns, owner_is_me, users_by_id, factories_to_use,
+ related_issues, config)
+ row.starred = ezt.boolean(id_accessor(art) in starred_items)
+ row.idx = idx # EZT does not have loop counters, so add idx.
+ table_data.append(row)
+ row.group = None
+
+ # Also include group information for the first row in each group.
+ # TODO(jrobbins): This seems like more overhead than we need for the
+ # common case where no new group heading row is to be inserted.
+ group = MakeRowData(
+ art, [group_name.strip('-') for group_name in lower_group_by],
+ owner_is_me, users_by_id, group_cell_factories, related_issues,
+ config)
+ for cell, group_name in zip(group.cells, lower_group_by):
+ cell.group_name = group_name
+ if group == current_group:
+ current_group.rows_in_group += 1
+ else:
+ row.group = group
+ current_group = group
+ current_group.rows_in_group = 1
+
+ return table_data
+
+
+def MakeRowData(
+ art, columns, owner_is_me, users_by_id, cell_factory_list,
+ related_issues, config):
+ """Make a TableRow for use by EZT when rendering HTML table of results.
+
+ Args:
+ art: a project artifact PB
+ columns: list of lower-case column names
+ owner_is_me: boolean indicating that the logged in user is the owner
+ of the current artifact
+ users_by_id: dictionary {user_id: UserView} with each UserView having
+ a "display_name" member.
+ cell_factory_list: list of functions that each create TableCell
+ objects for a given column.
+ related_issues: dict {issue_id: issue} of pre-fetched related issues.
+ config: ProjectIssueConfig PB for the current project.
+
+ Returns:
+ A TableRow object for use by EZT to render a table of results.
+ """
+ ordered_row_data = []
+ non_col_labels = []
+ label_values = collections.defaultdict(list)
+
+ flattened_columns = set()
+ for col in columns:
+ if '/' in col:
+ flattened_columns.update(col.split('/'))
+ else:
+ flattened_columns.add(col)
+
+ # Group all "Key-Value" labels by key, and separate the "OneWord" labels.
+ _AccumulateLabelValues(
+ art.labels, flattened_columns, label_values, non_col_labels)
+
+ _AccumulateLabelValues(
+ art.derived_labels, flattened_columns, label_values,
+ non_col_labels, is_derived=True)
+
+ # Build up a list of TableCell objects for this row.
+ for i, col in enumerate(columns):
+ factory = cell_factory_list[i]
+ new_cell = factory(
+ art, col, users_by_id, non_col_labels, label_values, related_issues,
+ config)
+ new_cell.col_index = i
+ ordered_row_data.append(new_cell)
+
+ return TableRow(ordered_row_data, owner_is_me)
+
+
+def _AccumulateLabelValues(
+ labels, columns, label_values, non_col_labels, is_derived=False):
+ """Parse OneWord and Key-Value labels for display in a list page.
+
+ Args:
+ labels: a list of label strings.
+ columns: a list of column names.
+ label_values: mutable dictionary {key: [value, ...]} of label values
+ seen so far.
+ non_col_labels: mutable list of OneWord labels seen so far.
+ is_derived: true if these labels were derived via rules.
+
+ Returns:
+ Nothing. But, the given label_values dictionary will grow to hold
+ the values of the key-value labels passed in, and the non_col_labels
+ list will grow to hold the OneWord labels passed in. These are shown
+ in label columns, and in the summary column, respectively
+ """
+ for label_name in labels:
+ if '-' in label_name:
+ parts = label_name.split('-')
+ for pivot in range(1, len(parts)):
+ column_name = '-'.join(parts[:pivot])
+ value = '-'.join(parts[pivot:])
+ column_name = column_name.lower()
+ if column_name in columns:
+ label_values[column_name].append((value, is_derived))
+ else:
+ non_col_labels.append((label_name, is_derived))
+
+
+class TableRow(object):
+ """A tiny auxiliary class to represent a row in an HTML table."""
+
+ def __init__(self, cells, owner_is_me):
+ """Initialize the table row with the given data."""
+ self.cells = cells
+ self.owner_is_me = ezt.boolean(owner_is_me) # Shows tiny ">" on my issues.
+ # Used by MakeTableData for layout.
+ self.idx = None
+ self.group = None
+ self.rows_in_group = None
+ self.starred = None
+
+ def __cmp__(self, other):
+ """A row is == if each cell is == to the cells in the other row."""
+ return cmp(self.cells, other.cells) if other else -1
+
+ def DebugString(self):
+ """Return a string that is useful for on-page debugging."""
+ return 'TR(%s)' % self.cells
+
+
+# TODO(jrobbins): also add unsortable... or change this to a list of operations
+# that can be done.
+CELL_TYPE_ID = 'ID'
+CELL_TYPE_SUMMARY = 'summary'
+CELL_TYPE_ATTR = 'attr'
+CELL_TYPE_UNFILTERABLE = 'unfilterable'
+
+
+class TableCell(object):
+ """Helper class to represent a table cell when rendering using EZT."""
+
+ # Should instances of this class be rendered with whitespace:nowrap?
+ # Subclasses can override this constant, e.g., issuelist TableCellOwner.
+ NOWRAP = ezt.boolean(False)
+
+ def __init__(self, cell_type, explicit_values,
+ derived_values=None, non_column_labels=None, align='',
+ sort_values=True):
+ """Store all the given data for later access by EZT."""
+ self.type = cell_type
+ self.align = align
+ self.col_index = 0 # Is set afterward
+ self.values = []
+ if non_column_labels:
+ self.non_column_labels = [
+ template_helpers.EZTItem(value=v, is_derived=ezt.boolean(d))
+ for v, d in non_column_labels]
+ else:
+ self.non_column_labels = []
+
+ for v in (sorted(explicit_values) if sort_values else explicit_values):
+ self.values.append(CellItem(v))
+
+ if derived_values:
+ for v in (sorted(derived_values) if sort_values else derived_values):
+ self.values.append(CellItem(v, is_derived=True))
+
+ def __cmp__(self, other):
+ """A cell is == if each value is == to the values in the other cells."""
+ return cmp(self.values, other.values) if other else -1
+
+ def DebugString(self):
+ return 'TC(%r, %r, %r)' % (
+ self.type,
+ [v.DebugString() for v in self.values],
+ self.non_column_labels)
+
+
+def CompositeTableCell(columns_to_combine, cell_factories):
+ """Cell factory that combines multiple cells in a combined column."""
+
+ class FactoryClass(TableCell):
+ def __init__(self, art, _col, users_by_id,
+ non_col_labels, label_values, related_issues, config):
+ TableCell.__init__(self, CELL_TYPE_UNFILTERABLE, [])
+
+ for sub_col in columns_to_combine:
+ sub_factory = ChooseCellFactory(sub_col, cell_factories, config)
+ sub_cell = sub_factory(
+ art, sub_col, users_by_id, non_col_labels, label_values,
+ related_issues, config)
+ self.non_column_labels.extend(sub_cell.non_column_labels)
+ self.values.extend(sub_cell.values)
+
+ return FactoryClass
+
+
+class CellItem(object):
+ """Simple class to display one part of a table cell's value, with style."""
+
+ def __init__(self, item, is_derived=False):
+ self.item = item
+ self.is_derived = ezt.boolean(is_derived)
+
+ def __cmp__(self, other):
+ return cmp(self.item, other.item) if other else -1
+
+ def DebugString(self):
+ if self.is_derived:
+ return 'CI(derived: %r)' % self.item
+ else:
+ return 'CI(%r)' % self.item
+
+
+class TableCellKeyLabels(TableCell):
+ """TableCell subclass specifically for showing user-defined label values."""
+
+ def __init__(
+ self, _art, col, _users_by_id, _non_col_labels,
+ label_values, _related_issues, _config):
+ label_value_pairs = label_values.get(col, [])
+ explicit_values = [value for value, is_derived in label_value_pairs
+ if not is_derived]
+ derived_values = [value for value, is_derived in label_value_pairs
+ if is_derived]
+ TableCell.__init__(self, CELL_TYPE_ATTR, explicit_values,
+ derived_values=derived_values)
+
+
+class TableCellProject(TableCell):
+ """TableCell subclass for showing an artifact's project name."""
+
+ # pylint: disable=unused-argument
+ def __init__(
+ self, art, col, users_by_id, non_col_labels, label_values,
+ _related_issues, _config):
+ TableCell.__init__(
+ self, CELL_TYPE_ATTR, [art.project_name])
+
+
+class TableCellStars(TableCell):
+ """TableCell subclass for showing an artifact's star count."""
+
+ # pylint: disable=unused-argument
+ def __init__(
+ self, art, col, users_by_id, non_col_labels, label_values,
+ _related_issues, _config):
+ TableCell.__init__(
+ self, CELL_TYPE_ATTR, [art.star_count], align='right')
+
+
+class TableCellSummary(TableCell):
+ """TableCell subclass for showing an artifact's summary."""
+
+ # pylint: disable=unused-argument
+ def __init__(
+ self, art, col, users_by_id, non_col_labels, label_values,
+ _related_issues, _config):
+ TableCell.__init__(
+ self, CELL_TYPE_SUMMARY, [art.summary],
+ non_column_labels=non_col_labels)
+
+
+class TableCellCustom(TableCell):
+ """Abstract TableCell subclass specifically for showing custom fields."""
+
+ def __init__(
+ self, art, col, users_by_id, _non_col_labels,
+ _label_values, _related_issues, config):
+ explicit_values = []
+ derived_values = []
+ for fv in art.field_values:
+ # TODO(jrobbins): for cross-project search this could be a list.
+ fd = tracker_bizobj.FindFieldDefByID(fv.field_id, config)
+ if fd.field_name.lower() == col:
+ val = self.ExtractValue(fv, users_by_id)
+ if fv.derived:
+ derived_values.append(val)
+ else:
+ explicit_values.append(val)
+
+ TableCell.__init__(self, CELL_TYPE_ATTR, explicit_values,
+ derived_values=derived_values)
+
+ def ExtractValue(self, fv, _users_by_id):
+ return 'field-id-%d-not-implemented-yet' % fv.field_id
+
+
+class TableCellCustomInt(TableCellCustom):
+ """TableCell subclass specifically for showing custom int fields."""
+
+ def ExtractValue(self, fv, _users_by_id):
+ return fv.int_value
+
+
+class TableCellCustomStr(TableCellCustom):
+ """TableCell subclass specifically for showing custom str fields."""
+
+ def ExtractValue(self, fv, _users_by_id):
+ return fv.str_value
+
+
+class TableCellCustomUser(TableCellCustom):
+ """TableCell subclass specifically for showing custom user fields."""
+
+ def ExtractValue(self, fv, users_by_id):
+ if fv.user_id in users_by_id:
+ return users_by_id[fv.user_id].email
+ return 'USER_%d' % fv.user_id
+
+
+class TableCellCustomDate(TableCellCustom):
+ """TableCell subclass specifically for showing custom date fields."""
+
+ def ExtractValue(self, fv, _users_by_id):
+ # TODO(jrobbins): convert timestamp to formatted date and time
+ return fv.int_value
+
+
+class TableCellCustomBool(TableCellCustom):
+ """TableCell subclass specifically for showing custom int fields."""
+
+ def ExtractValue(self, fv, _users_by_id):
+ return 'Yes' if fv.int_value else 'No'
+
+
+_CUSTOM_FIELD_CELL_FACTORIES = {
+ tracker_pb2.FieldTypes.ENUM_TYPE: TableCellKeyLabels,
+ tracker_pb2.FieldTypes.INT_TYPE: TableCellCustomInt,
+ tracker_pb2.FieldTypes.STR_TYPE: TableCellCustomStr,
+ tracker_pb2.FieldTypes.USER_TYPE: TableCellCustomUser,
+ tracker_pb2.FieldTypes.DATE_TYPE: TableCellCustomDate,
+ tracker_pb2.FieldTypes.BOOL_TYPE: TableCellCustomBool,
+}
+
+
+def ChooseCellFactory(col, cell_factories, config):
+ """Return the CellFactory to use for the given column."""
+ if col in cell_factories:
+ return cell_factories[col]
+
+ if '/' in col:
+ return CompositeTableCell(col.split('/'), cell_factories)
+
+ fd = tracker_bizobj.FindFieldDef(col, config)
+ if fd:
+ return _CUSTOM_FIELD_CELL_FACTORIES[fd.field_type]
+
+ return TableCellKeyLabels
« no previous file with comments | « appengine/monorail/framework/sql.py ('k') | appengine/monorail/framework/template_helpers.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698