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