| # 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 |
| |
| """Helper functions for sorting lists of project artifacts. |
| |
| This module exports the SortArtifacts function that does sorting of |
| Monorail business objects (e.g., an issue). The sorting is done by |
| extracting relevant values from the PB using a dictionary of |
| accessor functions. |
| |
| The desired sorting directives are specified in part of the user's |
| HTTP request. This sort spec consists of the names of the columns |
| with optional minus signs to indicate descending sort order. |
| |
| The tool configuration object also affects sorting. When sorting by |
| key-value labels, the well-known labels are considered to come |
| before any non-well-known labels, and those well-known labels sort in |
| the order in which they are defined in the tool config PB. |
| """ |
| from __future__ import print_function |
| from __future__ import division |
| from __future__ import absolute_import |
| |
| from functools import total_ordering |
| |
| import settings |
| from proto import tracker_pb2 |
| from services import caches |
| from tracker import tracker_bizobj |
| from tracker import tracker_constants |
| |
| |
| @total_ordering |
| class DescendingValue(object): |
| """A wrapper which reverses the sort order of values.""" |
| |
| @classmethod |
| def MakeDescendingValue(cls, obj): |
| """Make a value that sorts in the reverse order as obj.""" |
| if isinstance(obj, int): |
| return -obj |
| if obj == MAX_STRING: |
| return MIN_STRING |
| if obj == MIN_STRING: |
| return MAX_STRING |
| if isinstance(obj, list): |
| return [cls.MakeDescendingValue(item) for item in reversed(obj)] |
| return DescendingValue(obj) |
| |
| def __init__(self, val): |
| self.val = val |
| |
| def __eq__(self, other): |
| if isinstance(other, DescendingValue): |
| return self.val == other.val |
| return self.val == other |
| |
| def __ne__(self, other): |
| if isinstance(other, DescendingValue): |
| return self.val != other.val |
| return self.val != other |
| |
| def __lt__(self, other): |
| if isinstance(other, DescendingValue): |
| return other.val < self.val |
| return other < self.val |
| |
| def __repr__(self): |
| return 'DescendingValue(%r)' % self.val |
| |
| |
| # A string that sorts after every other string, and one that sorts before them. |
| MAX_STRING = '~~~' |
| MIN_STRING = DescendingValue(MAX_STRING) |
| |
| |
| # RAMCache {issue_id: {column_name: sort_key, ...}, ...} |
| art_values_cache = None |
| |
| |
| def InitializeArtValues(services): |
| global art_values_cache |
| art_values_cache = caches.RamCache( |
| services.cache_manager, 'issue', max_size=settings.issue_cache_max_size) |
| |
| |
| def InvalidateArtValuesKeys(cnxn, keys): |
| art_values_cache.InvalidateKeys(cnxn, keys) |
| |
| |
| def SortArtifacts( |
| artifacts, config, accessors, postprocessors, group_by_spec, sort_spec, |
| users_by_id=None, tie_breakers=None): |
| """Return a list of artifacts sorted by the user's sort specification. |
| |
| In the following, an "accessor" is a function(art) -> [field_value, ...]. |
| |
| Args: |
| artifacts: an unsorted list of project artifact PBs. |
| config: Project config PB instance that defines the sort order for |
| labels and statuses in this project. |
| accessors: dict {column_name: accessor} to get values from the artifacts. |
| postprocessors: dict {column_name: postprocessor} to get user emails |
| and timestamps. |
| group_by_spec: string that lists the grouping order |
| sort_spec: string that lists the sort order |
| users_by_id: optional dictionary {user_id: user_view,...} for all users |
| who participate in the list of artifacts. |
| tie_breakers: list of column names to add to the end of the sort |
| spec if they are not already somewhere in the sort spec. |
| |
| Returns: |
| A sorted list of artifacts. |
| |
| Note: if username_cols is supplied, then users_by_id should be too. |
| |
| The approach to sorting is to construct a comprehensive sort key for |
| each artifact. To create the sort key, we (a) build lists with a |
| variable number of fields to sort on, and (b) allow individual |
| fields to be sorted in descending order. Even with the time taken |
| to build the sort keys, calling sorted() with the key seems to be |
| faster overall than doing multiple stable-sorts or doing one sort |
| using a multi-field comparison function. |
| """ |
| sort_directives = ComputeSortDirectives( |
| config, group_by_spec, sort_spec, tie_breakers=tie_breakers) |
| |
| # Build a list of accessors that will extract sort keys from the issues. |
| accessor_pairs = [ |
| (sd, _MakeCombinedSortKeyAccessor( |
| sd, config, accessors, postprocessors, users_by_id)) |
| for sd in sort_directives] |
| |
| def SortKey(art): |
| """Make a sort_key for the given artifact, used by sorted() below.""" |
| if art_values_cache.HasItem(art.issue_id): |
| art_values = art_values_cache.GetItem(art.issue_id) |
| else: |
| art_values = {} |
| |
| sort_key = [] |
| for sd, accessor in accessor_pairs: |
| if sd not in art_values: |
| art_values[sd] = accessor(art) |
| sort_key.append(art_values[sd]) |
| |
| art_values_cache.CacheItem(art.issue_id, art_values) |
| return sort_key |
| |
| return sorted(artifacts, key=SortKey) |
| |
| |
| def ComputeSortDirectives(config, group_by_spec, sort_spec, tie_breakers=None): |
| """Return a list with sort directives to be used in sorting. |
| |
| Args: |
| config: Project config PB instance that defines the sort order for |
| labels and statuses in this project. |
| group_by_spec: string that lists the grouping order |
| sort_spec: string that lists the sort order |
| tie_breakers: list of column names to add to the end of the sort |
| spec if they are not already somewhere in the sort spec. |
| |
| Returns: |
| A list of lower-case column names, each one may have a leading |
| minus-sign. |
| """ |
| # Prepend the end-user's sort spec to any project default sort spec. |
| if tie_breakers is None: |
| tie_breakers = ['id'] |
| sort_spec = '%s %s %s' % ( |
| group_by_spec, sort_spec, config.default_sort_spec) |
| # Sort specs can have interfering sort orders, so remove any duplicates. |
| field_names = set() |
| sort_directives = [] |
| for sort_directive in sort_spec.lower().split(): |
| field_name = sort_directive.lstrip('-') |
| if field_name not in field_names: |
| sort_directives.append(sort_directive) |
| field_names.add(field_name) |
| |
| # Add in the project name so that the overall ordering is completely |
| # defined in cross-project search. Otherwise, issues jump up and |
| # down on each reload of the same query, and prev/next links get |
| # messed up. It's a no-op in single projects. |
| if 'project' not in sort_directives: |
| sort_directives.append('project') |
| |
| for tie_breaker in tie_breakers: |
| if tie_breaker not in sort_directives: |
| sort_directives.append(tie_breaker) |
| |
| return sort_directives |
| |
| |
| def _MakeCombinedSortKeyAccessor( |
| sort_directive, config, accessors, postprocessors, users_by_id): |
| """Return an accessor that extracts a sort key for a UI table column. |
| |
| Args: |
| sort_directive: string with column name and optional leading minus sign, |
| for combined columns, it may have slashes, e.g., "-priority/pri". |
| config: ProjectIssueConfig instance that defines the sort order for |
| labels and statuses in this project. |
| accessors: dictionary of (column_name -> accessor) to get values |
| from the artifacts. |
| postprocessors: dict {column_name: postprocessor} to get user emails |
| and timestamps. |
| users_by_id: dictionary {user_id: user_view,...} for all users |
| who participate in the list of artifacts (e.g., owners, reporters, cc). |
| |
| Returns: |
| A list of accessor functions that can be applied to an issue to extract |
| the relevant sort key value. |
| |
| The strings for status and labels are converted to lower case in |
| this method so that they sort like case-insensitive enumerations. |
| Any component-specific field of the artifact is sorted according to the |
| value returned by the accessors defined in that component. Those |
| accessor functions should lower case string values for fields where |
| case-insensitive sorting is desired. |
| """ |
| if sort_directive.startswith('-'): |
| combined_col_name = sort_directive[1:] |
| descending = True |
| else: |
| combined_col_name = sort_directive |
| descending = False |
| |
| wk_labels = [wkl.label for wkl in config.well_known_labels] |
| accessors = [ |
| _MakeSingleSortKeyAccessor( |
| col_name, config, accessors, postprocessors, users_by_id, wk_labels) |
| for col_name in combined_col_name.split('/')] |
| |
| # The most common case is that we sort on a single column, like "priority". |
| if len(accessors) == 1: |
| return _MaybeMakeDescending(accessors[0], descending) |
| |
| # Less commonly, we are sorting on a combined column like "priority/pri". |
| def CombinedAccessor(art): |
| """Flatten and sort the values for each column in a combined column.""" |
| key_part = [] |
| for single_accessor in accessors: |
| value = single_accessor(art) |
| if isinstance(value, list): |
| key_part.extend(value) |
| else: |
| key_part.append(value) |
| return sorted(key_part) |
| |
| return _MaybeMakeDescending(CombinedAccessor, descending) |
| |
| |
| def _MaybeMakeDescending(accessor, descending): |
| """If descending is True, return a new function that reverses accessor.""" |
| if not descending: |
| return accessor |
| |
| def DescendingAccessor(art): |
| asc_value = accessor(art) |
| return DescendingValue.MakeDescendingValue(asc_value) |
| |
| return DescendingAccessor |
| |
| |
| def _MakeSingleSortKeyAccessor( |
| col_name, config, accessors, postprocessors, users_by_id, wk_labels): |
| """Return an accessor function for a single simple UI column.""" |
| # Case 1. Handle built-in fields: status, component. |
| if col_name == 'status': |
| wk_statuses = [wks.status for wks in config.well_known_statuses] |
| return _IndexOrLexical(wk_statuses, accessors[col_name]) |
| |
| if col_name == 'component': |
| comp_defs = sorted(config.component_defs, key=lambda cd: cd.path.lower()) |
| comp_ids = [cd.component_id for cd in comp_defs] |
| return _IndexListAccessor(comp_ids, accessors[col_name]) |
| |
| # Case 2. Any other defined accessor functions. |
| if col_name in accessors: |
| if postprocessors and col_name in postprocessors: |
| # sort users by email address or timestamp rather than user ids. |
| return _MakeAccessorWithPostProcessor( |
| users_by_id, accessors[col_name], postprocessors[col_name]) |
| else: |
| return accessors[col_name] |
| |
| # Case 3. Anything else is assumed to be a label prefix or custom field. |
| return _IndexOrLexicalList( |
| wk_labels, config.field_defs, col_name, users_by_id) |
| |
| |
| IGNORABLE_INDICATOR = -1 |
| |
| |
| def _PrecomputeSortIndexes(values, col_name): |
| """Precompute indexes of strings in the values list for fast lookup later.""" |
| # Make a dictionary that immediately gives us the index of any value |
| # in the list, and also add the same values in all-lower letters. In |
| # the case where two values differ only by case, the later value wins, |
| # which is fine. |
| indexes = {} |
| if col_name: |
| prefix = col_name + '-' |
| else: |
| prefix = '' |
| for idx, val in enumerate(values): |
| if val.lower().startswith(prefix): |
| indexes[val] = idx |
| indexes[val.lower()] = idx |
| else: |
| indexes[val] = IGNORABLE_INDICATOR |
| indexes[val.lower()] = IGNORABLE_INDICATOR |
| |
| return indexes |
| |
| |
| def _MakeAccessorWithPostProcessor(users_by_id, base_accessor, postprocessor): |
| """Make an accessor that returns a list of user_view properties for sorting. |
| |
| Args: |
| users_by_id: dictionary {user_id: user_view, ...} for all participants |
| in the entire list of artifacts. |
| base_accessor: an accessor function f(artifact) -> user_id. |
| postprocessor: function f(user_view) -> single sortable value. |
| |
| Returns: |
| An accessor f(artifact) -> value that can be used in sorting |
| the decorated list. |
| """ |
| |
| def Accessor(art): |
| """Return a user edit name for the given artifact's base_accessor.""" |
| id_or_id_list = base_accessor(art) |
| if isinstance(id_or_id_list, list): |
| values = [postprocessor(users_by_id[user_id]) |
| for user_id in id_or_id_list] |
| else: |
| values = [postprocessor(users_by_id[id_or_id_list])] |
| |
| return sorted(values) or MAX_STRING |
| |
| return Accessor |
| |
| |
| def _MakeColumnAccessor(col_name): |
| """Make an accessor for an issue's labels that have col_name as a prefix. |
| |
| Args: |
| col_name: string column name. |
| |
| Returns: |
| An accessor that can be applied to an artifact to return a list of |
| labels that have col_name as a prefix. |
| |
| For example, _MakeColumnAccessor('priority')(issue) could result in |
| [], or ['priority-high'], or a longer list for multi-valued labels. |
| """ |
| prefix = col_name + '-' |
| |
| def Accessor(art): |
| """Return a list of label values on the given artifact.""" |
| result = [label.lower() for label in tracker_bizobj.GetLabels(art) |
| if label.lower().startswith(prefix)] |
| return result |
| |
| return Accessor |
| |
| |
| def _IndexOrLexical(wk_values, base_accessor): |
| """Return an accessor to score an artifact based on a user-defined ordering. |
| |
| Args: |
| wk_values: a list of well-known status values from the config. |
| base_accessor: function that gets a field from a given issue. |
| |
| Returns: |
| An accessor that can be applied to an issue to return a suitable |
| sort key. |
| |
| For example, when used to sort issue statuses, these accessors return an |
| integer for well-known statuses, a string for odd-ball statuses, and an |
| extreme value key for issues with no status. That causes issues to appear |
| in the expected order with odd-ball issues sorted lexicographically after |
| the ones with well-known status values, and issues with no defined status at |
| the very end. |
| """ |
| well_known_value_indexes = _PrecomputeSortIndexes(wk_values, '') |
| |
| def Accessor(art): |
| """Custom-made function to return a specific value of any issue.""" |
| value = base_accessor(art) |
| if not value: |
| # Undefined values sort last. |
| return MAX_STRING |
| |
| try: |
| # Well-known values sort by index. Ascending sorting has positive ints |
| # in well_known_value_indexes. |
| return well_known_value_indexes[value] |
| except KeyError: |
| # Odd-ball values after well-known and lexicographically. |
| return value.lower() |
| |
| return Accessor |
| |
| |
| def _IndexListAccessor(wk_values, base_accessor): |
| """Return an accessor to score an artifact based on a user-defined ordering. |
| |
| Args: |
| wk_values: a list of well-known values from the config. |
| base_accessor: function that gets a field from a given issue. |
| |
| Returns: |
| An accessor that can be applied to an issue to return a suitable |
| sort key. |
| """ |
| well_known_value_indexes = { |
| val: idx for idx, val in enumerate(wk_values)} |
| |
| def Accessor(art): |
| """Custom-made function to return a specific value of any issue.""" |
| values = base_accessor(art) |
| if not values: |
| # Undefined values sort last. |
| return MAX_STRING |
| |
| indexes = [well_known_value_indexes.get(val, MAX_STRING) for val in values] |
| return sorted(indexes) |
| |
| return Accessor |
| |
| |
| def _IndexOrLexicalList(wk_values, full_fd_list, col_name, users_by_id): |
| """Return an accessor to score an artifact based on a user-defined ordering. |
| |
| Args: |
| wk_values: A list of well-known labels from the config. |
| full_fd_list: list of FieldDef PBs that belong to the config. |
| col_name: lowercase string name of the column that will be sorted on. |
| users_by_id: A dictionary {user_id: user_view}. |
| |
| Returns: |
| An accessor that can be applied to an issue to return a suitable |
| sort key. |
| """ |
| well_known_value_indexes = _PrecomputeSortIndexes(wk_values, col_name) |
| |
| if col_name.endswith(tracker_constants.APPROVER_COL_SUFFIX): |
| # Custom field names cannot end with the APPROVER_COL_SUFFIX. So the only |
| # possible relevant values are approvers for an APPROVAL_TYPE named |
| # field_name and any values from labels with the key 'field_name-approvers'. |
| field_name = col_name[:-len(tracker_constants.APPROVER_COL_SUFFIX)] |
| approval_fds = [fd for fd in full_fd_list |
| if (fd.field_name.lower() == field_name and |
| fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE)] |
| |
| def ApproverAccessor(art): |
| """Custom-made function to return a sort value or an issue's approvers.""" |
| idx_or_lex_list = ( |
| _SortableApprovalApproverValues(art, approval_fds, users_by_id) + |
| _SortableLabelValues(art, col_name, well_known_value_indexes)) |
| if not idx_or_lex_list: |
| return MAX_STRING # issues with no value sort to the end of the list. |
| return sorted(idx_or_lex_list) |
| |
| return ApproverAccessor |
| |
| # Column name does not end with APPROVER_COL_SUFFIX, so relevant values |
| # are Approval statuses or Field Values for fields named col_name and |
| # values from labels with the key equal to col_name. |
| field_name = col_name |
| phase_name = None |
| if '.' in col_name: |
| phase_name, field_name = col_name.split('.', 1) |
| |
| fd_list = [fd for fd in full_fd_list |
| if (fd.field_name.lower() == field_name and |
| fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE and |
| bool(phase_name) == fd.is_phase_field)] |
| approval_fds = [] |
| if not phase_name: |
| approval_fds = [fd for fd in fd_list if |
| fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE] |
| |
| def Accessor(art): |
| """Custom-made function to return a sort value for any issue.""" |
| idx_or_lex_list = ( |
| _SortableApprovalStatusValues(art, approval_fds) + |
| _SortableFieldValues(art, fd_list, users_by_id, phase_name) + |
| _SortableLabelValues(art, col_name, well_known_value_indexes)) |
| if not idx_or_lex_list: |
| return MAX_STRING # issues with no value sort to the end of the list. |
| return sorted(idx_or_lex_list) |
| |
| return Accessor |
| |
| |
| def _SortableApprovalStatusValues(art, fd_list): |
| """Return a list of approval statuses relevant to one UI table column.""" |
| sortable_value_list = [] |
| for fd in fd_list: |
| for av in art.approval_values: |
| if av.approval_id == fd.field_id: |
| # Order approval statuses by life cycle. |
| # NOT_SET == 8 but should be before all other statuses. |
| sortable_value_list.append( |
| 0 if av.status.number == 8 else av.status.number) |
| |
| return sortable_value_list |
| |
| |
| def _SortableApprovalApproverValues(art, fd_list, users_by_id): |
| """Return a list of approval approvers relevant to one UI table column.""" |
| sortable_value_list = [] |
| for fd in fd_list: |
| for av in art.approval_values: |
| if av.approval_id == fd.field_id: |
| sortable_value_list.extend( |
| [users_by_id.get(approver_id).email |
| for approver_id in av.approver_ids |
| if users_by_id.get(approver_id)]) |
| |
| return sortable_value_list |
| |
| |
| def _SortableFieldValues(art, fd_list, users_by_id, phase_name): |
| """Return a list of field values relevant to one UI table column.""" |
| phase_id = None |
| if phase_name: |
| phase_id = next(( |
| phase.phase_id for phase in art.phases |
| if phase.name.lower() == phase_name), None) |
| sortable_value_list = [] |
| for fd in fd_list: |
| for fv in art.field_values: |
| if fv.field_id == fd.field_id and fv.phase_id == phase_id: |
| sortable_value_list.append( |
| tracker_bizobj.GetFieldValue(fv, users_by_id)) |
| |
| return sortable_value_list |
| |
| |
| def _SortableLabelValues(art, col_name, well_known_value_indexes): |
| """Return a list of ints and strings for labels relevant to one UI column.""" |
| col_name_dash = col_name + '-' |
| sortable_value_list = [] |
| for label in tracker_bizobj.GetLabels(art): |
| idx_or_lex = well_known_value_indexes.get(label) |
| if idx_or_lex == IGNORABLE_INDICATOR: |
| continue # Label is known to not have the desired prefix. |
| if idx_or_lex is None: |
| if '-' not in label: |
| # Skip an irrelevant OneWord label and remember to ignore it later. |
| well_known_value_indexes[label] = IGNORABLE_INDICATOR |
| continue |
| label_lower = label.lower() |
| if label_lower.startswith(col_name_dash): |
| # Label is a key-value label with an odd-ball value, remember it |
| value = label_lower[len(col_name_dash):] |
| idx_or_lex = value |
| well_known_value_indexes[label] = value |
| else: |
| # Label was a key-value label that is not relevant to this column. |
| # Remember to ignore it later. |
| well_known_value_indexes[label] = IGNORABLE_INDICATOR |
| continue |
| |
| sortable_value_list.append(idx_or_lex) |
| |
| return sortable_value_list |