blob: f23f72e5e14873d1a556c8502120b608e74a43f3 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# 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."""
7from __future__ import print_function
8from __future__ import division
9from __future__ import absolute_import
10
11import logging
12import collections
13
14from features import features_constants
15from framework import framework_views
16from framework import framework_helpers
17from framework import sorting
18from framework import table_view_helpers
19from framework import timestr
20from framework import paginate
21from framework import permissions
22from framework import urls
23from tracker import tracker_bizobj
24from tracker import tracker_constants
25from tracker import tracker_helpers
26from tracker import tablecell
27
28
29# Type to hold a HotlistRef
30HotlistRef = collections.namedtuple('HotlistRef', 'user_id, hotlist_name')
31
32
33def 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
126def 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
214def _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
241def 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
278def 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
285def 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
293def 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
305def 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
335def 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
361def 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
374def 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
393def 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
421def 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
442def 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