Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1 | # Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 | # Use of this source code is governed by a BSD-style |
| 3 | # license that can be found in the LICENSE file or at |
| 4 | # https://developers.google.com/open-source/licenses/bsd |
| 5 | |
| 6 | """Helper functions and classes used by the hotlist pages.""" |
| 7 | from __future__ import print_function |
| 8 | from __future__ import division |
| 9 | from __future__ import absolute_import |
| 10 | |
| 11 | import logging |
| 12 | import collections |
| 13 | |
| 14 | from features import features_constants |
| 15 | from framework import framework_views |
| 16 | from framework import framework_helpers |
| 17 | from framework import sorting |
| 18 | from framework import table_view_helpers |
| 19 | from framework import timestr |
| 20 | from framework import paginate |
| 21 | from framework import permissions |
| 22 | from framework import urls |
| 23 | from tracker import tracker_bizobj |
| 24 | from tracker import tracker_constants |
| 25 | from tracker import tracker_helpers |
| 26 | from tracker import tablecell |
| 27 | |
| 28 | |
| 29 | # Type to hold a HotlistRef |
| 30 | HotlistRef = collections.namedtuple('HotlistRef', 'user_id, hotlist_name') |
| 31 | |
| 32 | |
| 33 | def GetSortedHotlistIssues( |
| 34 | cnxn, hotlist_items, issues, auth, can, sort_spec, group_by_spec, |
| 35 | harmonized_config, services, profiler): |
| 36 | # type: (MonorailConnection, List[HotlistItem], List[Issue], AuthData, |
| 37 | # ProjectIssueConfig, Services, Profiler) -> (List[Issue], Dict, Dict) |
| 38 | """Sorts the given HotlistItems and Issues and filters out Issues that |
| 39 | the user cannot view. |
| 40 | |
| 41 | Args: |
| 42 | cnxn: MonorailConnection for connection to the SQL database. |
| 43 | hotlist_items: list of HotlistItems in the Hotlist we want to sort. |
| 44 | issues: list of Issues in the Hotlist we want to sort. |
| 45 | auth: AuthData object that identifies the logged in user. |
| 46 | can: int "canned query" number to scope the visible issues. |
| 47 | sort_spec: string that lists the sort order. |
| 48 | group_by_spec: string that lists the grouping order. |
| 49 | harmonized_config: ProjectIssueConfig created from all configs of projects |
| 50 | with issues in the issues list. |
| 51 | services: Services object for connections to backend services. |
| 52 | profiler: Profiler object to display and record processes. |
| 53 | |
| 54 | Returns: |
| 55 | A tuple of (sorted_issues, hotlist_items_context, issues_users_by_id) where: |
| 56 | |
| 57 | sorted_issues: list of Issues that are sorted and issues the user cannot |
| 58 | view are filtered out. |
| 59 | hotlist_items_context: a dict of dicts providing HotlistItem values that |
| 60 | are associated with each Hotlist Issue. E.g: |
| 61 | {issue.issue_id: {'issue_rank': hotlist item rank, |
| 62 | 'adder_id': hotlist item adder's user_id, |
| 63 | 'date_added': timestamp when this issue was added to the |
| 64 | hotlist, |
| 65 | 'note': note for this issue in the hotlist,}, |
| 66 | issue.issue_id: {...}} |
| 67 | issues_users_by_id: dict of {user_id: UserView, ...} for all users involved |
| 68 | in the hotlist items and issues. |
| 69 | """ |
| 70 | with profiler.Phase('Checking issue permissions and getting ranks'): |
| 71 | |
| 72 | allowed_issues = FilterIssues(cnxn, auth, can, issues, services) |
| 73 | allowed_iids = [issue.issue_id for issue in allowed_issues] |
| 74 | # The values for issues in a hotlist are specific to the hotlist |
| 75 | # (rank, adder, added) without invalidating the keys, an issue will retain |
| 76 | # the rank value it has in one hotlist when navigating to another hotlist. |
| 77 | sorting.InvalidateArtValuesKeys( |
| 78 | cnxn, [issue.issue_id for issue in allowed_issues]) |
| 79 | sorted_ranks = sorted( |
| 80 | [hotlist_item.rank for hotlist_item in hotlist_items if |
| 81 | hotlist_item.issue_id in allowed_iids]) |
| 82 | friendly_ranks = { |
| 83 | rank: friendly for friendly, rank in enumerate(sorted_ranks, 1)} |
| 84 | issue_adders = framework_views.MakeAllUserViews( |
| 85 | cnxn, services.user, [hotlist_item.adder_id for |
| 86 | hotlist_item in hotlist_items]) |
| 87 | hotlist_items_context = { |
| 88 | hotlist_item.issue_id: {'issue_rank': |
| 89 | friendly_ranks[hotlist_item.rank], |
| 90 | 'adder_id': hotlist_item.adder_id, |
| 91 | 'date_added': timestr.FormatAbsoluteDate( |
| 92 | hotlist_item.date_added), |
| 93 | 'note': hotlist_item.note} |
| 94 | for hotlist_item in hotlist_items if |
| 95 | hotlist_item.issue_id in allowed_iids} |
| 96 | |
| 97 | with profiler.Phase('Making user views'): |
| 98 | issues_users_by_id = framework_views.MakeAllUserViews( |
| 99 | cnxn, services.user, |
| 100 | tracker_bizobj.UsersInvolvedInIssues(allowed_issues or [])) |
| 101 | issues_users_by_id.update(issue_adders) |
| 102 | |
| 103 | with profiler.Phase('Sorting issues'): |
| 104 | sortable_fields = tracker_helpers.SORTABLE_FIELDS.copy() |
| 105 | sortable_fields.update( |
| 106 | {'rank': lambda issue: hotlist_items_context[ |
| 107 | issue.issue_id]['issue_rank'], |
| 108 | 'adder': lambda issue: hotlist_items_context[ |
| 109 | issue.issue_id]['adder_id'], |
| 110 | 'added': lambda issue: hotlist_items_context[ |
| 111 | issue.issue_id]['date_added'], |
| 112 | 'note': lambda issue: hotlist_items_context[ |
| 113 | issue.issue_id]['note']}) |
| 114 | sortable_postproc = tracker_helpers.SORTABLE_FIELDS_POSTPROCESSORS.copy() |
| 115 | sortable_postproc.update( |
| 116 | {'adder': lambda user_view: user_view.email, |
| 117 | }) |
| 118 | |
| 119 | sorted_issues = sorting.SortArtifacts( |
| 120 | allowed_issues, harmonized_config, sortable_fields, |
| 121 | sortable_postproc, group_by_spec, sort_spec, |
| 122 | users_by_id=issues_users_by_id, tie_breakers=['rank', 'id']) |
| 123 | return sorted_issues, hotlist_items_context, issues_users_by_id |
| 124 | |
| 125 | |
| 126 | def CreateHotlistTableData(mr, hotlist_issues, services): |
| 127 | """Creates the table data for the hotlistissues table.""" |
| 128 | with mr.profiler.Phase('getting stars'): |
| 129 | starred_iid_set = set(services.issue_star.LookupStarredItemIDs( |
| 130 | mr.cnxn, mr.auth.user_id)) |
| 131 | |
| 132 | with mr.profiler.Phase('Computing col_spec'): |
| 133 | mr.ComputeColSpec(mr.hotlist) |
| 134 | |
| 135 | issues_list = services.issue.GetIssues( |
| 136 | mr.cnxn, |
| 137 | [hotlist_issue.issue_id for hotlist_issue in hotlist_issues]) |
| 138 | with mr.profiler.Phase('Getting config'): |
| 139 | hotlist_issues_project_ids = GetAllProjectsOfIssues( |
| 140 | [issue for issue in issues_list]) |
| 141 | is_cross_project = len(hotlist_issues_project_ids) > 1 |
| 142 | config_list = GetAllConfigsOfProjects( |
| 143 | mr.cnxn, hotlist_issues_project_ids, services) |
| 144 | harmonized_config = tracker_bizobj.HarmonizeConfigs(config_list) |
| 145 | |
| 146 | # With no sort_spec specified, a hotlist should default to be sorted by |
| 147 | # 'rank'. sort_spec needs to be modified because hotlistissues.py |
| 148 | # checks for 'rank' in sort_spec to set 'allow_rerank' which determines if |
| 149 | # drag and drop reranking should be enabled. |
| 150 | if not mr.sort_spec: |
| 151 | mr.sort_spec = 'rank' |
| 152 | (sorted_issues, hotlist_issues_context, |
| 153 | issues_users_by_id) = GetSortedHotlistIssues( |
| 154 | mr.cnxn, hotlist_issues, issues_list, mr.auth, mr.can, mr.sort_spec, |
| 155 | mr.group_by_spec, harmonized_config, services, mr.profiler) |
| 156 | |
| 157 | with mr.profiler.Phase("getting related issues"): |
| 158 | related_iids = set() |
| 159 | results_needing_related = sorted_issues |
| 160 | lower_cols = mr.col_spec.lower().split() |
| 161 | for issue in results_needing_related: |
| 162 | if 'blockedon' in lower_cols: |
| 163 | related_iids.update(issue.blocked_on_iids) |
| 164 | if 'blocking' in lower_cols: |
| 165 | related_iids.update(issue.blocking_iids) |
| 166 | if 'mergedinto' in lower_cols: |
| 167 | related_iids.add(issue.merged_into) |
| 168 | related_issues_list = services.issue.GetIssues( |
| 169 | mr.cnxn, list(related_iids)) |
| 170 | related_issues = {issue.issue_id: issue for issue in related_issues_list} |
| 171 | |
| 172 | with mr.profiler.Phase('filtering unviewable issues'): |
| 173 | viewable_iids_set = {issue.issue_id |
| 174 | for issue in tracker_helpers.GetAllowedIssues( |
| 175 | mr, [related_issues.values()], services)[0]} |
| 176 | |
| 177 | with mr.profiler.Phase('building table'): |
| 178 | context_for_all_issues = { |
| 179 | issue.issue_id: hotlist_issues_context[issue.issue_id] |
| 180 | for issue in sorted_issues} |
| 181 | |
| 182 | column_values = table_view_helpers.ExtractUniqueValues( |
| 183 | mr.col_spec.lower().split(), sorted_issues, issues_users_by_id, |
| 184 | harmonized_config, related_issues, |
| 185 | hotlist_context_dict=context_for_all_issues) |
| 186 | unshown_columns = table_view_helpers.ComputeUnshownColumns( |
| 187 | sorted_issues, mr.col_spec.split(), harmonized_config, |
| 188 | features_constants.OTHER_BUILT_IN_COLS) |
| 189 | url_params = [(name, mr.GetParam(name)) for name in |
| 190 | framework_helpers.RECOGNIZED_PARAMS] |
| 191 | # We are passing in None for the project_name because we are not operating |
| 192 | # under any project. |
| 193 | pagination = paginate.ArtifactPagination( |
| 194 | sorted_issues, mr.num, mr.GetPositiveIntParam('start'), |
| 195 | None, GetURLOfHotlist(mr.cnxn, mr.hotlist, services.user), |
| 196 | total_count=len(sorted_issues), url_params=url_params) |
| 197 | |
| 198 | sort_spec = '%s %s %s' % ( |
| 199 | mr.group_by_spec, mr.sort_spec, harmonized_config.default_sort_spec) |
| 200 | |
| 201 | table_data = _MakeTableData( |
| 202 | pagination.visible_results, starred_iid_set, |
| 203 | mr.col_spec.lower().split(), mr.group_by_spec.lower().split(), |
| 204 | issues_users_by_id, tablecell.CELL_FACTORIES, related_issues, |
| 205 | viewable_iids_set, harmonized_config, context_for_all_issues, |
| 206 | mr.hotlist_id, sort_spec) |
| 207 | |
| 208 | table_related_dict = { |
| 209 | 'column_values': column_values, 'unshown_columns': unshown_columns, |
| 210 | 'pagination': pagination, 'is_cross_project': is_cross_project } |
| 211 | return table_data, table_related_dict |
| 212 | |
| 213 | |
| 214 | def _MakeTableData(issues, starred_iid_set, lower_columns, |
| 215 | lower_group_by, users_by_id, cell_factories, |
| 216 | related_issues, viewable_iids_set, config, |
| 217 | context_for_all_issues, |
| 218 | hotlist_id, sort_spec): |
| 219 | """Returns data from MakeTableData after adding additional information.""" |
| 220 | table_data = table_view_helpers.MakeTableData( |
| 221 | issues, starred_iid_set, lower_columns, lower_group_by, |
| 222 | users_by_id, cell_factories, lambda issue: issue.issue_id, |
| 223 | related_issues, viewable_iids_set, config, context_for_all_issues) |
| 224 | |
| 225 | for row, art in zip(table_data, issues): |
| 226 | row.issue_id = art.issue_id |
| 227 | row.local_id = art.local_id |
| 228 | row.project_name = art.project_name |
| 229 | row.project_url = framework_helpers.FormatURL( |
| 230 | None, '/p/%s' % row.project_name) |
| 231 | row.issue_ref = '%s:%d' % (art.project_name, art.local_id) |
| 232 | row.issue_clean_url = tracker_helpers.FormatRelativeIssueURL( |
| 233 | art.project_name, urls.ISSUE_DETAIL, id=art.local_id) |
| 234 | row.issue_ctx_url = tracker_helpers.FormatRelativeIssueURL( |
| 235 | art.project_name, urls.ISSUE_DETAIL, |
| 236 | id=art.local_id, sort=sort_spec, hotlist_id=hotlist_id) |
| 237 | |
| 238 | return table_data |
| 239 | |
| 240 | |
| 241 | def FilterIssues(cnxn, auth, can, issues, services): |
| 242 | # (MonorailConnection, AuthData, int, List[Issue], Services) -> List[Issue] |
| 243 | """Return a list of issues that the user is allowed to view. |
| 244 | |
| 245 | Args: |
| 246 | cnxn: MonorailConnection for connection to the SQL database. |
| 247 | auth: AuthData object that identifies the logged in user. |
| 248 | can: in "canned_query" number to scope the visible issues. |
| 249 | issues: list of Issues to be filtered. |
| 250 | services: Services object for connections to backend services. |
| 251 | |
| 252 | Returns: |
| 253 | A list of Issues that the user has permissions to view. |
| 254 | """ |
| 255 | allowed_issues = [] |
| 256 | project_ids = GetAllProjectsOfIssues(issues) |
| 257 | issue_projects = services.project.GetProjects(cnxn, project_ids) |
| 258 | configs_by_project_id = services.config.GetProjectConfigs(cnxn, project_ids) |
| 259 | perms_by_project_id = { |
| 260 | pid: permissions.GetPermissions(auth.user_pb, auth.effective_ids, p) |
| 261 | for pid, p in issue_projects.items()} |
| 262 | for issue in issues: |
| 263 | if (can == 1) or not issue.closed_timestamp: |
| 264 | issue_project = issue_projects[issue.project_id] |
| 265 | config = configs_by_project_id[issue.project_id] |
| 266 | perms = perms_by_project_id[issue.project_id] |
| 267 | granted_perms = tracker_bizobj.GetGrantedPerms( |
| 268 | issue, auth.effective_ids, config) |
| 269 | permit_view = permissions.CanViewIssue( |
| 270 | auth.effective_ids, perms, |
| 271 | issue_project, issue, granted_perms=granted_perms) |
| 272 | if permit_view: |
| 273 | allowed_issues.append(issue) |
| 274 | |
| 275 | return allowed_issues |
| 276 | |
| 277 | |
| 278 | def GetAllConfigsOfProjects(cnxn, project_ids, services): |
| 279 | """Returns a list of configs for the given list of projects.""" |
| 280 | config_dict = services.config.GetProjectConfigs(cnxn, project_ids) |
| 281 | config_list = [config_dict[project_id] for project_id in project_ids] |
| 282 | return config_list |
| 283 | |
| 284 | |
| 285 | def GetAllProjectsOfIssues(issues): |
| 286 | """Returns a list of all projects that the given issues are in.""" |
| 287 | project_ids = set() |
| 288 | for issue in issues: |
| 289 | project_ids.add(issue.project_id) |
| 290 | return project_ids |
| 291 | |
| 292 | |
| 293 | def MembersWithoutGivenIDs(hotlist, exclude_ids): |
| 294 | """Return three lists of member user IDs, with exclude_ids not in them.""" |
| 295 | owner_ids = [user_id for user_id in hotlist.owner_ids |
| 296 | if user_id not in exclude_ids] |
| 297 | editor_ids = [user_id for user_id in hotlist.editor_ids |
| 298 | if user_id not in exclude_ids] |
| 299 | follower_ids = [user_id for user_id in hotlist.follower_ids |
| 300 | if user_id not in exclude_ids] |
| 301 | |
| 302 | return owner_ids, editor_ids, follower_ids |
| 303 | |
| 304 | |
| 305 | def MembersWithGivenIDs(hotlist, new_member_ids, role): |
| 306 | """Return three lists of member IDs with the new IDs in the right one. |
| 307 | |
| 308 | Args: |
| 309 | hotlist: Hotlist PB for the project to get current members from. |
| 310 | new_member_ids: set of user IDs for members being added. |
| 311 | role: string name of the role that new_member_ids should be granted. |
| 312 | |
| 313 | Returns: |
| 314 | Three lists of member IDs with new_member_ids added to the appropriate |
| 315 | list and removed from any other role. |
| 316 | |
| 317 | Raises: |
| 318 | ValueError: if the role is not one of owner, committer, or contributor. |
| 319 | """ |
| 320 | owner_ids, editor_ids, follower_ids = MembersWithoutGivenIDs( |
| 321 | hotlist, new_member_ids) |
| 322 | |
| 323 | if role == 'owner': |
| 324 | owner_ids.extend(new_member_ids) |
| 325 | elif role == 'editor': |
| 326 | editor_ids.extend(new_member_ids) |
| 327 | elif role == 'follower': |
| 328 | follower_ids.extend(new_member_ids) |
| 329 | else: |
| 330 | raise ValueError() |
| 331 | |
| 332 | return owner_ids, editor_ids, follower_ids |
| 333 | |
| 334 | |
| 335 | def GetURLOfHotlist(cnxn, hotlist, user_service, url_for_token=False): |
| 336 | """Determines the url to be used to access the given hotlist. |
| 337 | |
| 338 | Args: |
| 339 | cnxn: connection to SQL database |
| 340 | hotlist: the hotlist_pb |
| 341 | user_service: interface to user data storage |
| 342 | url_for_token: if true, url returned will use user's id |
| 343 | regardless of their user settings, for tokenization. |
| 344 | |
| 345 | Returns: |
| 346 | The string url to be used when accessing this hotlist. |
| 347 | """ |
| 348 | if not hotlist.owner_ids: # Should never happen. |
| 349 | logging.error('Unowned Hotlist: id:%r, name:%r', hotlist.hotlist_id, |
| 350 | hotlist.name) |
| 351 | return '' |
| 352 | owner_id = hotlist.owner_ids[0] # only one owner allowed |
| 353 | owner = user_service.GetUser(cnxn, owner_id) |
| 354 | if owner.obscure_email or url_for_token: |
| 355 | return '/u/%d/hotlists/%s' % (owner_id, hotlist.name) |
| 356 | return ( |
| 357 | '/u/%s/hotlists/%s' % ( |
| 358 | owner.email, hotlist.name)) |
| 359 | |
| 360 | |
| 361 | def RemoveHotlist(cnxn, hotlist_id, services): |
| 362 | """Removes the given hotlist from the database. |
| 363 | Args: |
| 364 | hotlist_id: the id of the hotlist to be removed. |
| 365 | services: interfaces to data storage. |
| 366 | """ |
| 367 | services.hotlist_star.ExpungeStars(cnxn, hotlist_id) |
| 368 | services.user.ExpungeHotlistsFromHistory(cnxn, [hotlist_id]) |
| 369 | services.features.DeleteHotlist(cnxn, hotlist_id) |
| 370 | |
| 371 | |
| 372 | # The following are used by issueentry. |
| 373 | |
| 374 | def InvalidParsedHotlistRefsNames(parsed_hotlist_refs, user_hotlist_pbs): |
| 375 | """Find and return all names without a corresponding hotlist so named. |
| 376 | |
| 377 | Args: |
| 378 | parsed_hotlist_refs: a list of ParsedHotlistRef objects |
| 379 | user_hotlist_pbs: the hotlist protobuf objects of all hotlists |
| 380 | belonging to the user |
| 381 | |
| 382 | Returns: |
| 383 | a list of invalid names; if none are found, the empty list |
| 384 | """ |
| 385 | user_hotlist_names = {hotlist.name for hotlist in user_hotlist_pbs} |
| 386 | invalid_names = list() |
| 387 | for parsed_ref in parsed_hotlist_refs: |
| 388 | if parsed_ref.hotlist_name not in user_hotlist_names: |
| 389 | invalid_names.append(parsed_ref.hotlist_name) |
| 390 | return invalid_names |
| 391 | |
| 392 | |
| 393 | def AmbiguousShortrefHotlistNames(short_refs, user_hotlist_pbs): |
| 394 | """Find and return ambiguous hotlist shortrefs' hotlist names. |
| 395 | |
| 396 | A hotlist shortref is ambiguous iff there exists more than |
| 397 | hotlist with that name in the user's hotlists. |
| 398 | |
| 399 | Args: |
| 400 | short_refs: a list of ParsedHotlistRef object specifying only |
| 401 | a hotlist name (user_email being none) |
| 402 | user_hotlist_pbs: the hotlist protobuf objects of all hotlists |
| 403 | belonging to the user |
| 404 | |
| 405 | Returns: |
| 406 | a list of ambiguous hotlist names; if none are found, the empty list |
| 407 | """ |
| 408 | ambiguous_names = set() |
| 409 | seen = set() |
| 410 | for hotlist in user_hotlist_pbs: |
| 411 | if hotlist.name in seen: |
| 412 | ambiguous_names.add(hotlist.name) |
| 413 | seen.add(hotlist.name) |
| 414 | ambiguous_from_refs = list() |
| 415 | for ref in short_refs: |
| 416 | if ref.hotlist_name in ambiguous_names: |
| 417 | ambiguous_from_refs.append(ref.hotlist_name) |
| 418 | return ambiguous_from_refs |
| 419 | |
| 420 | |
| 421 | def InvalidParsedHotlistRefsEmails(full_refs, user_hotlist_emails_to_owners): |
| 422 | """Find and return invalid e-mails in hotlist full refs. |
| 423 | |
| 424 | Args: |
| 425 | full_refs: a list of ParsedHotlistRef object specifying both |
| 426 | user_email and hotlist_name |
| 427 | user_hotlist_emails_to_owners: a dictionary having for its keys only |
| 428 | the e-mails of the owners of the hotlists the user had edit permission |
| 429 | over. (Could also be a set containing these e-mails.) |
| 430 | |
| 431 | Returns: |
| 432 | A list of invalid e-mails; if none are found, the empty list. |
| 433 | """ |
| 434 | parsed_emails = [pref.user_email for pref in full_refs] |
| 435 | invalid_emails = list() |
| 436 | for email in parsed_emails: |
| 437 | if email not in user_hotlist_emails_to_owners: |
| 438 | invalid_emails.append(email) |
| 439 | return invalid_emails |
| 440 | |
| 441 | |
| 442 | def GetHotlistsOfParsedHotlistFullRefs( |
| 443 | full_refs, user_hotlist_emails_to_owners, user_hotlist_refs_to_pbs): |
| 444 | """Check that all full refs are valid. |
| 445 | |
| 446 | A ref is 'invalid' if it doesn't specify one of the user's hotlists. |
| 447 | |
| 448 | Args: |
| 449 | full_refs: a list of ParsedHotlistRef object specifying both |
| 450 | user_email and hotlist_name |
| 451 | user_hotlist_emails_to_owners: a dictionary having for its keys only |
| 452 | the e-mails of the owners of the hotlists the user had edit permission |
| 453 | over. |
| 454 | user_hotlist_refs_to_pbs: a dictionary mapping HotlistRefs |
| 455 | (owner_id, hotlist_name) to the corresponding hotlist protobuf object for |
| 456 | the user's hotlists |
| 457 | |
| 458 | Returns: |
| 459 | A two-tuple: (list of valid refs' corresponding hotlist protobuf objects, |
| 460 | list of invalid refs) |
| 461 | |
| 462 | """ |
| 463 | invalid_refs = list() |
| 464 | valid_pbs = list() |
| 465 | for parsed_ref in full_refs: |
| 466 | hotlist_ref = HotlistRef( |
| 467 | user_hotlist_emails_to_owners[parsed_ref.user_email], |
| 468 | parsed_ref.hotlist_name) |
| 469 | if hotlist_ref not in user_hotlist_refs_to_pbs: |
| 470 | invalid_refs.append(parsed_ref) |
| 471 | else: |
| 472 | valid_pbs.append(user_hotlist_refs_to_pbs[hotlist_ref]) |
| 473 | return valid_pbs, invalid_refs |