Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/features/hotlist_helpers.py b/features/hotlist_helpers.py
new file mode 100644
index 0000000..f23f72e
--- /dev/null
+++ b/features/hotlist_helpers.py
@@ -0,0 +1,473 @@
+# 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 and classes used by the hotlist pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import collections
+
+from features import features_constants
+from framework import framework_views
+from framework import framework_helpers
+from framework import sorting
+from framework import table_view_helpers
+from framework import timestr
+from framework import paginate
+from framework import permissions
+from framework import urls
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tablecell
+
+
+# Type to hold a HotlistRef
+HotlistRef = collections.namedtuple('HotlistRef', 'user_id, hotlist_name')
+
+
+def GetSortedHotlistIssues(
+ cnxn, hotlist_items, issues, auth, can, sort_spec, group_by_spec,
+ harmonized_config, services, profiler):
+ # type: (MonorailConnection, List[HotlistItem], List[Issue], AuthData,
+ # ProjectIssueConfig, Services, Profiler) -> (List[Issue], Dict, Dict)
+ """Sorts the given HotlistItems and Issues and filters out Issues that
+ the user cannot view.
+
+ Args:
+ cnxn: MonorailConnection for connection to the SQL database.
+ hotlist_items: list of HotlistItems in the Hotlist we want to sort.
+ issues: list of Issues in the Hotlist we want to sort.
+ auth: AuthData object that identifies the logged in user.
+ can: int "canned query" number to scope the visible issues.
+ sort_spec: string that lists the sort order.
+ group_by_spec: string that lists the grouping order.
+ harmonized_config: ProjectIssueConfig created from all configs of projects
+ with issues in the issues list.
+ services: Services object for connections to backend services.
+ profiler: Profiler object to display and record processes.
+
+ Returns:
+ A tuple of (sorted_issues, hotlist_items_context, issues_users_by_id) where:
+
+ sorted_issues: list of Issues that are sorted and issues the user cannot
+ view are filtered out.
+ hotlist_items_context: a dict of dicts providing HotlistItem values that
+ are associated with each Hotlist Issue. E.g:
+ {issue.issue_id: {'issue_rank': hotlist item rank,
+ 'adder_id': hotlist item adder's user_id,
+ 'date_added': timestamp when this issue was added to the
+ hotlist,
+ 'note': note for this issue in the hotlist,},
+ issue.issue_id: {...}}
+ issues_users_by_id: dict of {user_id: UserView, ...} for all users involved
+ in the hotlist items and issues.
+ """
+ with profiler.Phase('Checking issue permissions and getting ranks'):
+
+ allowed_issues = FilterIssues(cnxn, auth, can, issues, services)
+ allowed_iids = [issue.issue_id for issue in allowed_issues]
+ # The values for issues in a hotlist are specific to the hotlist
+ # (rank, adder, added) without invalidating the keys, an issue will retain
+ # the rank value it has in one hotlist when navigating to another hotlist.
+ sorting.InvalidateArtValuesKeys(
+ cnxn, [issue.issue_id for issue in allowed_issues])
+ sorted_ranks = sorted(
+ [hotlist_item.rank for hotlist_item in hotlist_items if
+ hotlist_item.issue_id in allowed_iids])
+ friendly_ranks = {
+ rank: friendly for friendly, rank in enumerate(sorted_ranks, 1)}
+ issue_adders = framework_views.MakeAllUserViews(
+ cnxn, services.user, [hotlist_item.adder_id for
+ hotlist_item in hotlist_items])
+ hotlist_items_context = {
+ hotlist_item.issue_id: {'issue_rank':
+ friendly_ranks[hotlist_item.rank],
+ 'adder_id': hotlist_item.adder_id,
+ 'date_added': timestr.FormatAbsoluteDate(
+ hotlist_item.date_added),
+ 'note': hotlist_item.note}
+ for hotlist_item in hotlist_items if
+ hotlist_item.issue_id in allowed_iids}
+
+ with profiler.Phase('Making user views'):
+ issues_users_by_id = framework_views.MakeAllUserViews(
+ cnxn, services.user,
+ tracker_bizobj.UsersInvolvedInIssues(allowed_issues or []))
+ issues_users_by_id.update(issue_adders)
+
+ with profiler.Phase('Sorting issues'):
+ sortable_fields = tracker_helpers.SORTABLE_FIELDS.copy()
+ sortable_fields.update(
+ {'rank': lambda issue: hotlist_items_context[
+ issue.issue_id]['issue_rank'],
+ 'adder': lambda issue: hotlist_items_context[
+ issue.issue_id]['adder_id'],
+ 'added': lambda issue: hotlist_items_context[
+ issue.issue_id]['date_added'],
+ 'note': lambda issue: hotlist_items_context[
+ issue.issue_id]['note']})
+ sortable_postproc = tracker_helpers.SORTABLE_FIELDS_POSTPROCESSORS.copy()
+ sortable_postproc.update(
+ {'adder': lambda user_view: user_view.email,
+ })
+
+ sorted_issues = sorting.SortArtifacts(
+ allowed_issues, harmonized_config, sortable_fields,
+ sortable_postproc, group_by_spec, sort_spec,
+ users_by_id=issues_users_by_id, tie_breakers=['rank', 'id'])
+ return sorted_issues, hotlist_items_context, issues_users_by_id
+
+
+def CreateHotlistTableData(mr, hotlist_issues, services):
+ """Creates the table data for the hotlistissues table."""
+ with mr.profiler.Phase('getting stars'):
+ starred_iid_set = set(services.issue_star.LookupStarredItemIDs(
+ mr.cnxn, mr.auth.user_id))
+
+ with mr.profiler.Phase('Computing col_spec'):
+ mr.ComputeColSpec(mr.hotlist)
+
+ issues_list = services.issue.GetIssues(
+ mr.cnxn,
+ [hotlist_issue.issue_id for hotlist_issue in hotlist_issues])
+ with mr.profiler.Phase('Getting config'):
+ hotlist_issues_project_ids = GetAllProjectsOfIssues(
+ [issue for issue in issues_list])
+ is_cross_project = len(hotlist_issues_project_ids) > 1
+ config_list = GetAllConfigsOfProjects(
+ mr.cnxn, hotlist_issues_project_ids, services)
+ harmonized_config = tracker_bizobj.HarmonizeConfigs(config_list)
+
+ # With no sort_spec specified, a hotlist should default to be sorted by
+ # 'rank'. sort_spec needs to be modified because hotlistissues.py
+ # checks for 'rank' in sort_spec to set 'allow_rerank' which determines if
+ # drag and drop reranking should be enabled.
+ if not mr.sort_spec:
+ mr.sort_spec = 'rank'
+ (sorted_issues, hotlist_issues_context,
+ issues_users_by_id) = GetSortedHotlistIssues(
+ mr.cnxn, hotlist_issues, issues_list, mr.auth, mr.can, mr.sort_spec,
+ mr.group_by_spec, harmonized_config, services, mr.profiler)
+
+ with mr.profiler.Phase("getting related issues"):
+ related_iids = set()
+ results_needing_related = sorted_issues
+ lower_cols = mr.col_spec.lower().split()
+ for issue in results_needing_related:
+ if 'blockedon' in lower_cols:
+ related_iids.update(issue.blocked_on_iids)
+ if 'blocking' in lower_cols:
+ related_iids.update(issue.blocking_iids)
+ if 'mergedinto' in lower_cols:
+ related_iids.add(issue.merged_into)
+ related_issues_list = services.issue.GetIssues(
+ mr.cnxn, list(related_iids))
+ related_issues = {issue.issue_id: issue for issue in related_issues_list}
+
+ with mr.profiler.Phase('filtering unviewable issues'):
+ viewable_iids_set = {issue.issue_id
+ for issue in tracker_helpers.GetAllowedIssues(
+ mr, [related_issues.values()], services)[0]}
+
+ with mr.profiler.Phase('building table'):
+ context_for_all_issues = {
+ issue.issue_id: hotlist_issues_context[issue.issue_id]
+ for issue in sorted_issues}
+
+ column_values = table_view_helpers.ExtractUniqueValues(
+ mr.col_spec.lower().split(), sorted_issues, issues_users_by_id,
+ harmonized_config, related_issues,
+ hotlist_context_dict=context_for_all_issues)
+ unshown_columns = table_view_helpers.ComputeUnshownColumns(
+ sorted_issues, mr.col_spec.split(), harmonized_config,
+ features_constants.OTHER_BUILT_IN_COLS)
+ url_params = [(name, mr.GetParam(name)) for name in
+ framework_helpers.RECOGNIZED_PARAMS]
+ # We are passing in None for the project_name because we are not operating
+ # under any project.
+ pagination = paginate.ArtifactPagination(
+ sorted_issues, mr.num, mr.GetPositiveIntParam('start'),
+ None, GetURLOfHotlist(mr.cnxn, mr.hotlist, services.user),
+ total_count=len(sorted_issues), url_params=url_params)
+
+ sort_spec = '%s %s %s' % (
+ mr.group_by_spec, mr.sort_spec, harmonized_config.default_sort_spec)
+
+ table_data = _MakeTableData(
+ pagination.visible_results, starred_iid_set,
+ mr.col_spec.lower().split(), mr.group_by_spec.lower().split(),
+ issues_users_by_id, tablecell.CELL_FACTORIES, related_issues,
+ viewable_iids_set, harmonized_config, context_for_all_issues,
+ mr.hotlist_id, sort_spec)
+
+ table_related_dict = {
+ 'column_values': column_values, 'unshown_columns': unshown_columns,
+ 'pagination': pagination, 'is_cross_project': is_cross_project }
+ return table_data, table_related_dict
+
+
+def _MakeTableData(issues, starred_iid_set, lower_columns,
+ lower_group_by, users_by_id, cell_factories,
+ related_issues, viewable_iids_set, config,
+ context_for_all_issues,
+ hotlist_id, sort_spec):
+ """Returns data from MakeTableData after adding additional information."""
+ table_data = table_view_helpers.MakeTableData(
+ issues, starred_iid_set, lower_columns, lower_group_by,
+ users_by_id, cell_factories, lambda issue: issue.issue_id,
+ related_issues, viewable_iids_set, config, context_for_all_issues)
+
+ for row, art in zip(table_data, issues):
+ row.issue_id = art.issue_id
+ row.local_id = art.local_id
+ row.project_name = art.project_name
+ row.project_url = framework_helpers.FormatURL(
+ None, '/p/%s' % row.project_name)
+ row.issue_ref = '%s:%d' % (art.project_name, art.local_id)
+ row.issue_clean_url = tracker_helpers.FormatRelativeIssueURL(
+ art.project_name, urls.ISSUE_DETAIL, id=art.local_id)
+ row.issue_ctx_url = tracker_helpers.FormatRelativeIssueURL(
+ art.project_name, urls.ISSUE_DETAIL,
+ id=art.local_id, sort=sort_spec, hotlist_id=hotlist_id)
+
+ return table_data
+
+
+def FilterIssues(cnxn, auth, can, issues, services):
+ # (MonorailConnection, AuthData, int, List[Issue], Services) -> List[Issue]
+ """Return a list of issues that the user is allowed to view.
+
+ Args:
+ cnxn: MonorailConnection for connection to the SQL database.
+ auth: AuthData object that identifies the logged in user.
+ can: in "canned_query" number to scope the visible issues.
+ issues: list of Issues to be filtered.
+ services: Services object for connections to backend services.
+
+ Returns:
+ A list of Issues that the user has permissions to view.
+ """
+ allowed_issues = []
+ project_ids = GetAllProjectsOfIssues(issues)
+ issue_projects = services.project.GetProjects(cnxn, project_ids)
+ configs_by_project_id = services.config.GetProjectConfigs(cnxn, project_ids)
+ perms_by_project_id = {
+ pid: permissions.GetPermissions(auth.user_pb, auth.effective_ids, p)
+ for pid, p in issue_projects.items()}
+ for issue in issues:
+ if (can == 1) or not issue.closed_timestamp:
+ issue_project = issue_projects[issue.project_id]
+ config = configs_by_project_id[issue.project_id]
+ perms = perms_by_project_id[issue.project_id]
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, auth.effective_ids, config)
+ permit_view = permissions.CanViewIssue(
+ auth.effective_ids, perms,
+ issue_project, issue, granted_perms=granted_perms)
+ if permit_view:
+ allowed_issues.append(issue)
+
+ return allowed_issues
+
+
+def GetAllConfigsOfProjects(cnxn, project_ids, services):
+ """Returns a list of configs for the given list of projects."""
+ config_dict = services.config.GetProjectConfigs(cnxn, project_ids)
+ config_list = [config_dict[project_id] for project_id in project_ids]
+ return config_list
+
+
+def GetAllProjectsOfIssues(issues):
+ """Returns a list of all projects that the given issues are in."""
+ project_ids = set()
+ for issue in issues:
+ project_ids.add(issue.project_id)
+ return project_ids
+
+
+def MembersWithoutGivenIDs(hotlist, exclude_ids):
+ """Return three lists of member user IDs, with exclude_ids not in them."""
+ owner_ids = [user_id for user_id in hotlist.owner_ids
+ if user_id not in exclude_ids]
+ editor_ids = [user_id for user_id in hotlist.editor_ids
+ if user_id not in exclude_ids]
+ follower_ids = [user_id for user_id in hotlist.follower_ids
+ if user_id not in exclude_ids]
+
+ return owner_ids, editor_ids, follower_ids
+
+
+def MembersWithGivenIDs(hotlist, new_member_ids, role):
+ """Return three lists of member IDs with the new IDs in the right one.
+
+ Args:
+ hotlist: Hotlist PB for the project to get current members from.
+ new_member_ids: set of user IDs for members being added.
+ role: string name of the role that new_member_ids should be granted.
+
+ Returns:
+ Three lists of member IDs with new_member_ids added to the appropriate
+ list and removed from any other role.
+
+ Raises:
+ ValueError: if the role is not one of owner, committer, or contributor.
+ """
+ owner_ids, editor_ids, follower_ids = MembersWithoutGivenIDs(
+ hotlist, new_member_ids)
+
+ if role == 'owner':
+ owner_ids.extend(new_member_ids)
+ elif role == 'editor':
+ editor_ids.extend(new_member_ids)
+ elif role == 'follower':
+ follower_ids.extend(new_member_ids)
+ else:
+ raise ValueError()
+
+ return owner_ids, editor_ids, follower_ids
+
+
+def GetURLOfHotlist(cnxn, hotlist, user_service, url_for_token=False):
+ """Determines the url to be used to access the given hotlist.
+
+ Args:
+ cnxn: connection to SQL database
+ hotlist: the hotlist_pb
+ user_service: interface to user data storage
+ url_for_token: if true, url returned will use user's id
+ regardless of their user settings, for tokenization.
+
+ Returns:
+ The string url to be used when accessing this hotlist.
+ """
+ if not hotlist.owner_ids: # Should never happen.
+ logging.error('Unowned Hotlist: id:%r, name:%r', hotlist.hotlist_id,
+ hotlist.name)
+ return ''
+ owner_id = hotlist.owner_ids[0] # only one owner allowed
+ owner = user_service.GetUser(cnxn, owner_id)
+ if owner.obscure_email or url_for_token:
+ return '/u/%d/hotlists/%s' % (owner_id, hotlist.name)
+ return (
+ '/u/%s/hotlists/%s' % (
+ owner.email, hotlist.name))
+
+
+def RemoveHotlist(cnxn, hotlist_id, services):
+ """Removes the given hotlist from the database.
+ Args:
+ hotlist_id: the id of the hotlist to be removed.
+ services: interfaces to data storage.
+ """
+ services.hotlist_star.ExpungeStars(cnxn, hotlist_id)
+ services.user.ExpungeHotlistsFromHistory(cnxn, [hotlist_id])
+ services.features.DeleteHotlist(cnxn, hotlist_id)
+
+
+# The following are used by issueentry.
+
+def InvalidParsedHotlistRefsNames(parsed_hotlist_refs, user_hotlist_pbs):
+ """Find and return all names without a corresponding hotlist so named.
+
+ Args:
+ parsed_hotlist_refs: a list of ParsedHotlistRef objects
+ user_hotlist_pbs: the hotlist protobuf objects of all hotlists
+ belonging to the user
+
+ Returns:
+ a list of invalid names; if none are found, the empty list
+ """
+ user_hotlist_names = {hotlist.name for hotlist in user_hotlist_pbs}
+ invalid_names = list()
+ for parsed_ref in parsed_hotlist_refs:
+ if parsed_ref.hotlist_name not in user_hotlist_names:
+ invalid_names.append(parsed_ref.hotlist_name)
+ return invalid_names
+
+
+def AmbiguousShortrefHotlistNames(short_refs, user_hotlist_pbs):
+ """Find and return ambiguous hotlist shortrefs' hotlist names.
+
+ A hotlist shortref is ambiguous iff there exists more than
+ hotlist with that name in the user's hotlists.
+
+ Args:
+ short_refs: a list of ParsedHotlistRef object specifying only
+ a hotlist name (user_email being none)
+ user_hotlist_pbs: the hotlist protobuf objects of all hotlists
+ belonging to the user
+
+ Returns:
+ a list of ambiguous hotlist names; if none are found, the empty list
+ """
+ ambiguous_names = set()
+ seen = set()
+ for hotlist in user_hotlist_pbs:
+ if hotlist.name in seen:
+ ambiguous_names.add(hotlist.name)
+ seen.add(hotlist.name)
+ ambiguous_from_refs = list()
+ for ref in short_refs:
+ if ref.hotlist_name in ambiguous_names:
+ ambiguous_from_refs.append(ref.hotlist_name)
+ return ambiguous_from_refs
+
+
+def InvalidParsedHotlistRefsEmails(full_refs, user_hotlist_emails_to_owners):
+ """Find and return invalid e-mails in hotlist full refs.
+
+ Args:
+ full_refs: a list of ParsedHotlistRef object specifying both
+ user_email and hotlist_name
+ user_hotlist_emails_to_owners: a dictionary having for its keys only
+ the e-mails of the owners of the hotlists the user had edit permission
+ over. (Could also be a set containing these e-mails.)
+
+ Returns:
+ A list of invalid e-mails; if none are found, the empty list.
+ """
+ parsed_emails = [pref.user_email for pref in full_refs]
+ invalid_emails = list()
+ for email in parsed_emails:
+ if email not in user_hotlist_emails_to_owners:
+ invalid_emails.append(email)
+ return invalid_emails
+
+
+def GetHotlistsOfParsedHotlistFullRefs(
+ full_refs, user_hotlist_emails_to_owners, user_hotlist_refs_to_pbs):
+ """Check that all full refs are valid.
+
+ A ref is 'invalid' if it doesn't specify one of the user's hotlists.
+
+ Args:
+ full_refs: a list of ParsedHotlistRef object specifying both
+ user_email and hotlist_name
+ user_hotlist_emails_to_owners: a dictionary having for its keys only
+ the e-mails of the owners of the hotlists the user had edit permission
+ over.
+ user_hotlist_refs_to_pbs: a dictionary mapping HotlistRefs
+ (owner_id, hotlist_name) to the corresponding hotlist protobuf object for
+ the user's hotlists
+
+ Returns:
+ A two-tuple: (list of valid refs' corresponding hotlist protobuf objects,
+ list of invalid refs)
+
+ """
+ invalid_refs = list()
+ valid_pbs = list()
+ for parsed_ref in full_refs:
+ hotlist_ref = HotlistRef(
+ user_hotlist_emails_to_owners[parsed_ref.user_email],
+ parsed_ref.hotlist_name)
+ if hotlist_ref not in user_hotlist_refs_to_pbs:
+ invalid_refs.append(parsed_ref)
+ else:
+ valid_pbs.append(user_hotlist_refs_to_pbs[hotlist_ref])
+ return valid_pbs, invalid_refs