| # Copyright 2016 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """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 |