blob: 436f9756535bff5e8c7b0b2eb6fa489bf3747b18 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2017 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 for deciding who to notify and why.."""
7from __future__ import print_function
8from __future__ import division
9from __future__ import absolute_import
10
11import collections
12import logging
13
14import settings
15from features import filterrules_helpers
16from features import savedqueries_helpers
17from framework import authdata
18from framework import framework_bizobj
19from framework import framework_constants
20from framework import framework_helpers
21from framework import framework_views
22from framework import permissions
23from proto import tracker_pb2
24from search import query2ast
25from search import searchpipeline
26from tracker import component_helpers
27from tracker import tracker_bizobj
28
29# When sending change notification emails, choose the reply-to header and
30# footer message based on three levels of the recipient's permissions
31# for that issue.
32REPLY_NOT_ALLOWED = 'REPLY_NOT_ALLOWED'
33REPLY_MAY_COMMENT = 'REPLY_MAY_COMMENT'
34REPLY_MAY_UPDATE = 'REPLY_MAY_UPDATE'
35
36# These are strings describing the various reasons that we send notifications.
37REASON_REPORTER = 'You reported this issue'
38REASON_OWNER = 'You are the owner of the issue'
39REASON_OLD_OWNER = 'You were the issue owner before this change'
40REASON_DEFAULT_OWNER = 'A rule made you owner of the issue'
41REASON_CCD = 'You were specifically CC\'d on the issue'
42REASON_DEFAULT_CCD = 'A rule CC\'d you on the issue'
43# TODO(crbug.com/monorail/2857): separate reasons for notification to group
44# members resulting from component and rules derived ccs.
45REASON_GROUP_CCD = (
46 'A group you\'re a member of was specifically CC\'d on the issue')
47REASON_STARRER = 'You starred the issue'
48REASON_SUBSCRIBER = 'Your saved query matched the issue'
49REASON_ALSO_NOTIFY = 'A rule was set up to notify you'
50REASON_ALL_NOTIFICATIONS = (
51 'The project was configured to send all issue notifications '
52 'to this address')
53REASON_LINKED_ACCOUNT = 'Your linked account would have been notified'
54
55# An AddrPerm is how we represent our decision to notify a given
56# email address, which version of the email body to send to them, and
57# whether to offer them the option to reply to the notification. Many
58# of the functions in this file pass around AddrPerm lists (an "APL").
59# is_member is a boolean
60# address is a string email address
61# user is a User PB, including built-in user preference fields.
62# reply_perm is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT,
63# REPLY_MAY_UPDATE.
64# user_prefs is a UserPrefs object with string->string user prefs.
65AddrPerm = collections.namedtuple(
66 'AddrPerm', 'is_member, address, user, reply_perm, user_prefs')
67
68
69
70def ComputeIssueChangeAddressPermList(
71 cnxn, ids_to_consider, project, issue, services, omit_addrs,
72 users_by_id, pref_check_function=lambda u: u.notify_issue_change):
73 """Return a list of user email addresses to notify of an issue change.
74
75 User email addresses are determined by looking up the given user IDs
76 in the given users_by_id dict.
77
78 Args:
79 cnxn: connection to SQL database.
80 ids_to_consider: list of user IDs for users interested in this issue.
81 project: Project PB for the project containing this issue.
82 issue: Issue PB for the issue that was updated.
83 services: Services.
84 omit_addrs: set of strings for email addresses to not notify because
85 they already know.
86 users_by_id: dict {user_id: user_view} user info.
87 pref_check_function: optional function to use to check if a certain
88 User PB has a preference set to receive the email being sent. It
89 defaults to "If I am in the issue's owner or cc field", but it
90 can be set to check "If I starred the issue."
91
92 Returns:
93 A list of AddrPerm objects.
94 """
95 memb_addr_perm_list = []
96 logging.info('Considering %r ', ids_to_consider)
97 all_user_prefs = services.user.GetUsersPrefs(cnxn, ids_to_consider)
98 for user_id in ids_to_consider:
99 if user_id == framework_constants.NO_USER_SPECIFIED:
100 continue
101 user = services.user.GetUser(cnxn, user_id)
102 # Notify people who have a pref set, or if they have no User PB
103 # because the pref defaults to True.
104 if user and not pref_check_function(user):
105 logging.info('Not notifying %r: user preference', user.email)
106 continue
107 # TODO(jrobbins): doing a bulk operation would reduce DB load.
108 auth = authdata.AuthData.FromUserID(cnxn, user_id, services)
109 perms = permissions.GetPermissions(user, auth.effective_ids, project)
110 config = services.config.GetProjectConfig(cnxn, project.project_id)
111 granted_perms = tracker_bizobj.GetGrantedPerms(
112 issue, auth.effective_ids, config)
113
114 if not permissions.CanViewIssue(
115 auth.effective_ids, perms, project, issue,
116 granted_perms=granted_perms):
117 logging.info('Not notifying %r: user cannot view issue', user.email)
118 continue
119
120 addr = users_by_id[user_id].email
121 if addr in omit_addrs:
122 logging.info('Not notifying %r: user already knows', user.email)
123 continue
124
125 recipient_is_member = bool(framework_bizobj.UserIsInProject(
126 project, auth.effective_ids))
127
128 reply_perm = REPLY_NOT_ALLOWED
129 if project.process_inbound_email:
130 if permissions.CanEditIssue(auth.effective_ids, perms, project, issue):
131 reply_perm = REPLY_MAY_UPDATE
132 elif permissions.CanCommentIssue(
133 auth.effective_ids, perms, project, issue):
134 reply_perm = REPLY_MAY_COMMENT
135
136 memb_addr_perm_list.append(
137 AddrPerm(recipient_is_member, addr, user, reply_perm,
138 all_user_prefs[user_id]))
139
140 logging.info('For %s %s, will notify: %r',
141 project.project_name, issue.local_id,
142 [ap.address for ap in memb_addr_perm_list])
143
144 return memb_addr_perm_list
145
146
147def ComputeProjectNotificationAddrList(
148 cnxn, services, project, contributor_could_view, omit_addrs):
149 """Return a list of non-user addresses to notify of an issue change.
150
151 The non-user addresses are specified by email address strings, not
152 user IDs. One such address can be specified in the project PB.
153 It is not assumed to have permission to see all issues.
154
155 Args:
156 cnxn: connection to SQL database.
157 services: A Services object.
158 project: Project PB containing the issue that was updated.
159 contributor_could_view: True if any project contributor should be able to
160 see the notification email, e.g., in a mailing list archive or feed.
161 omit_addrs: set of strings for email addresses to not notify because
162 they already know.
163
164 Returns:
165 A list of tuples: [(False, email_addr, None, reply_permission_level), ...],
166 where reply_permission_level is always REPLY_NOT_ALLOWED for now.
167 """
168 memb_addr_perm_list = []
169 if contributor_could_view:
170 ml_addr = project.issue_notify_address
171 ml_user_prefs = services.user.GetUserPrefsByEmail(cnxn, ml_addr)
172
173 if ml_addr and ml_addr not in omit_addrs:
174 memb_addr_perm_list.append(
175 AddrPerm(False, ml_addr, None, REPLY_NOT_ALLOWED, ml_user_prefs))
176
177 return memb_addr_perm_list
178
179
180def ComputeIssueNotificationAddrList(cnxn, services, issue, omit_addrs):
181 """Return a list of non-user addresses to notify of an issue change.
182
183 The non-user addresses are specified by email address strings, not
184 user IDs. They can be set by filter rules with the "Also notify" action.
185 "Also notify" addresses are assumed to have permission to see any issue,
186 even a restricted one.
187
188 Args:
189 cnxn: connection to SQL database.
190 services: A Services object.
191 issue: Issue PB for the issue that was updated.
192 omit_addrs: set of strings for email addresses to not notify because
193 they already know.
194
195 Returns:
196 A list of tuples: [(False, email_addr, None, reply_permission_level), ...],
197 where reply_permission_level is always REPLY_NOT_ALLOWED for now.
198 """
199 addr_perm_list = []
200 for addr in issue.derived_notify_addrs:
201 if addr not in omit_addrs:
202 notify_user_prefs = services.user.GetUserPrefsByEmail(cnxn, addr)
203 addr_perm_list.append(
204 AddrPerm(False, addr, None, REPLY_NOT_ALLOWED, notify_user_prefs))
205
206 return addr_perm_list
207
208
209def _GetSubscribersAddrPermList(
210 cnxn, services, issue, project, config, omit_addrs, users_by_id):
211 """Lookup subscribers, evaluate their saved queries, and decide to notify."""
212 users_to_queries = GetNonOmittedSubscriptions(
213 cnxn, services, [project.project_id], omit_addrs)
214 # TODO(jrobbins): need to pass through the user_id to use for "me".
215 subscribers_to_notify = EvaluateSubscriptions(
216 cnxn, issue, users_to_queries, services, config)
217 # TODO(jrobbins): expand any subscribers that are user groups.
218 subs_needing_user_views = [
219 uid for uid in subscribers_to_notify if uid not in users_by_id]
220 users_by_id.update(framework_views.MakeAllUserViews(
221 cnxn, services.user, subs_needing_user_views))
222 sub_addr_perm_list = ComputeIssueChangeAddressPermList(
223 cnxn, subscribers_to_notify, project, issue, services, omit_addrs,
224 users_by_id, pref_check_function=lambda *args: True)
225
226 return sub_addr_perm_list
227
228
229def EvaluateSubscriptions(
230 cnxn, issue, users_to_queries, services, config):
231 """Determine subscribers who have subs that match the given issue."""
232 # Note: unlike filter rule, subscriptions see explicit & derived values.
233 lower_labels = [lab.lower() for lab in tracker_bizobj.GetLabels(issue)]
234 label_set = set(lower_labels)
235
236 subscribers_to_notify = []
237 for uid, saved_queries in users_to_queries.items():
238 for sq in saved_queries:
239 if sq.subscription_mode != 'immediate':
240 continue
241 if issue.project_id not in sq.executes_in_project_ids:
242 continue
243 cond = savedqueries_helpers.SavedQueryToCond(sq)
244 # TODO(jrobbins): Support linked accounts me_user_ids.
245 cond, _warnings = searchpipeline.ReplaceKeywordsWithUserIDs([uid], cond)
246 cond_ast = query2ast.ParseUserQuery(
247 cond, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
248
249 if filterrules_helpers.EvalPredicate(
250 cnxn, services, cond_ast, issue, label_set, config,
251 tracker_bizobj.GetOwnerId(issue), tracker_bizobj.GetCcIds(issue),
252 tracker_bizobj.GetStatus(issue)):
253 subscribers_to_notify.append(uid)
254 break # Don't bother looking at the user's other saved quereies.
255
256 return subscribers_to_notify
257
258
259def GetNonOmittedSubscriptions(cnxn, services, project_ids, omit_addrs):
260 """Get a dict of users w/ subscriptions in those projects."""
261 users_to_queries = services.features.GetSubscriptionsInProjects(
262 cnxn, project_ids)
263 user_emails = services.user.LookupUserEmails(
264 cnxn, list(users_to_queries.keys()))
265 for user_id, email in user_emails.items():
266 if email in omit_addrs:
267 del users_to_queries[user_id]
268 return users_to_queries
269
270
271def ComputeCustomFieldAddrPerms(
272 cnxn, config, issue, project, services, omit_addrs, users_by_id):
273 """Check the reasons to notify users named in custom fields."""
274 group_reason_list = []
275 for fd in config.field_defs:
276 (direct_named_ids,
277 transitive_named_ids) = services.usergroup.ExpandAnyGroupEmailRecipients(
278 cnxn, ComputeNamedUserIDsToNotify(issue.field_values, fd))
279 named_user_ids = direct_named_ids + transitive_named_ids
280 if named_user_ids:
281 named_addr_perms = ComputeIssueChangeAddressPermList(
282 cnxn, named_user_ids, project, issue, services, omit_addrs,
283 users_by_id, pref_check_function=lambda u: True)
284 group_reason_list.append(
285 (named_addr_perms, 'You are named in the %s field' % fd.field_name))
286
287 return group_reason_list
288
289
290def ComputeNamedUserIDsToNotify(field_values, fd):
291 """Give a list of user IDs to notify because they're in a field."""
292 if (fd.field_type == tracker_pb2.FieldTypes.USER_TYPE and
293 fd.notify_on == tracker_pb2.NotifyTriggers.ANY_COMMENT):
294 return [fv.user_id for fv in field_values
295 if fv.field_id == fd.field_id]
296
297 return []
298
299
300def ComputeComponentFieldAddrPerms(
301 cnxn, config, issue, project, services, omit_addrs, users_by_id):
302 """Return [(addr_perm_list, reason),...] for users auto-cc'd by components."""
303 component_ids = set(issue.component_ids)
304 group_reason_list = []
305 for cd in config.component_defs:
306 if cd.component_id in component_ids:
307 (direct_ccs,
308 transitive_ccs) = services.usergroup.ExpandAnyGroupEmailRecipients(
309 cnxn, component_helpers.GetCcIDsForComponentAndAncestors(config, cd))
310 cc_ids = direct_ccs + transitive_ccs
311 comp_addr_perms = ComputeIssueChangeAddressPermList(
312 cnxn, cc_ids, project, issue, services, omit_addrs,
313 users_by_id, pref_check_function=lambda u: True)
314 group_reason_list.append(
315 (comp_addr_perms,
316 'You are auto-CC\'d on all issues in component %s' % cd.path))
317
318 return group_reason_list
319
320
321def ComputeGroupReasonList(
322 cnxn, services, project, issue, config, users_by_id, omit_addrs,
323 contributor_could_view, starrer_ids=None, noisy=False,
324 old_owner_id=None, commenter_in_project=True, include_subscribers=True,
325 include_notify_all=True,
326 starrer_pref_check_function=lambda u: u.notify_starred_issue_change):
327 """Return a list [(addr_perm_list, reason),...] of addrs to notify."""
328 # Get the transitive set of owners and Cc'd users, and their UserViews.
329 starrer_ids = starrer_ids or []
330 reporter = [issue.reporter_id] if issue.reporter_id in starrer_ids else []
331 if old_owner_id:
332 old_direct_owners, old_transitive_owners = (
333 services.usergroup.ExpandAnyGroupEmailRecipients(cnxn, [old_owner_id]))
334 else:
335 old_direct_owners, old_transitive_owners = [], []
336
337 direct_owners, transitive_owners = (
338 services.usergroup.ExpandAnyGroupEmailRecipients(cnxn, [issue.owner_id]))
339 der_direct_owners, der_transitive_owners = (
340 services.usergroup.ExpandAnyGroupEmailRecipients(
341 cnxn, [issue.derived_owner_id]))
342 direct_comp, trans_comp = services.usergroup.ExpandAnyGroupEmailRecipients(
343 cnxn, component_helpers.GetComponentCcIDs(issue, config))
344 direct_ccs, transitive_ccs = services.usergroup.ExpandAnyGroupEmailRecipients(
345 cnxn, list(issue.cc_ids))
346 der_direct_ccs, der_transitive_ccs = (
347 services.usergroup.ExpandAnyGroupEmailRecipients(
348 cnxn, list(issue.derived_cc_ids)))
349 # Remove cc's derived from components, which are grouped into their own
350 # notify-reason-group in ComputeComponentFieldAddrPerms().
351 # This means that an exact email cc'd by both a component and a rule will
352 # get an email that says they are only being notified because of the
353 # component.
354 # Note that a user directly cc'd due to a rule who is also part of a
355 # group cc'd due to a component, will get a message saying they're cc'd for
356 # both the rule and the component.
357 der_direct_ccs = list(set(der_direct_ccs).difference(set(direct_comp)))
358 der_transitive_ccs = list(set(der_transitive_ccs).difference(set(trans_comp)))
359
360 users_by_id.update(framework_views.MakeAllUserViews(
361 cnxn, services.user, transitive_owners, der_transitive_owners,
362 direct_comp, trans_comp, transitive_ccs, der_transitive_ccs))
363
364 # Notify interested people according to the reason for their interest:
365 # owners, component auto-cc'd users, cc'd users, starrers, and
366 # other notification addresses.
367 reporter_addr_perm_list = ComputeIssueChangeAddressPermList(
368 cnxn, reporter, project, issue, services, omit_addrs, users_by_id)
369 owner_addr_perm_list = ComputeIssueChangeAddressPermList(
370 cnxn, direct_owners + transitive_owners, project, issue,
371 services, omit_addrs, users_by_id)
372 old_owner_addr_perm_list = ComputeIssueChangeAddressPermList(
373 cnxn, old_direct_owners + old_transitive_owners, project, issue,
374 services, omit_addrs, users_by_id)
375 owner_addr_perm_set = set(owner_addr_perm_list)
376 old_owner_addr_perm_list = [ap for ap in old_owner_addr_perm_list
377 if ap not in owner_addr_perm_set]
378 der_owner_addr_perm_list = ComputeIssueChangeAddressPermList(
379 cnxn, der_direct_owners + der_transitive_owners, project, issue,
380 services, omit_addrs, users_by_id)
381 cc_addr_perm_list = ComputeIssueChangeAddressPermList(
382 cnxn, direct_ccs, project, issue, services, omit_addrs, users_by_id)
383 transitive_cc_addr_perm_list = ComputeIssueChangeAddressPermList(
384 cnxn, transitive_ccs, project, issue, services, omit_addrs, users_by_id)
385 der_cc_addr_perm_list = ComputeIssueChangeAddressPermList(
386 cnxn, der_direct_ccs + der_transitive_ccs, project, issue,
387 services, omit_addrs, users_by_id)
388
389 starrer_addr_perm_list = []
390 sub_addr_perm_list = []
391 if not noisy or commenter_in_project:
392 # Avoid an OOM by only notifying a number of starrers that we can handle.
393 # And, we really should limit the number of emails that we send anyway.
394 max_starrers = settings.max_starrers_to_notify
395 starrer_ids = starrer_ids[-max_starrers:]
396 # Note: starrers can never be user groups.
397 starrer_addr_perm_list = (
398 ComputeIssueChangeAddressPermList(
399 cnxn, starrer_ids, project, issue,
400 services, omit_addrs, users_by_id,
401 pref_check_function=starrer_pref_check_function))
402
403 if include_subscribers:
404 sub_addr_perm_list = _GetSubscribersAddrPermList(
405 cnxn, services, issue, project, config, omit_addrs,
406 users_by_id)
407
408 # Get the list of addresses to notify based on filter rules.
409 issue_notify_addr_list = ComputeIssueNotificationAddrList(
410 cnxn, services, issue, omit_addrs)
411 # Get the list of addresses to notify based on project settings.
412 proj_notify_addr_list = []
413 if include_notify_all:
414 proj_notify_addr_list = ComputeProjectNotificationAddrList(
415 cnxn, services, project, contributor_could_view, omit_addrs)
416
417 group_reason_list = [
418 (reporter_addr_perm_list, REASON_REPORTER),
419 (owner_addr_perm_list, REASON_OWNER),
420 (old_owner_addr_perm_list, REASON_OLD_OWNER),
421 (der_owner_addr_perm_list, REASON_DEFAULT_OWNER),
422 (cc_addr_perm_list, REASON_CCD),
423 (transitive_cc_addr_perm_list, REASON_GROUP_CCD),
424 (der_cc_addr_perm_list, REASON_DEFAULT_CCD),
425 ]
426 group_reason_list.extend(ComputeComponentFieldAddrPerms(
427 cnxn, config, issue, project, services, omit_addrs,
428 users_by_id))
429 group_reason_list.extend(ComputeCustomFieldAddrPerms(
430 cnxn, config, issue, project, services, omit_addrs,
431 users_by_id))
432 group_reason_list.extend([
433 (starrer_addr_perm_list, REASON_STARRER),
434 (sub_addr_perm_list, REASON_SUBSCRIBER),
435 (issue_notify_addr_list, REASON_ALSO_NOTIFY),
436 (proj_notify_addr_list, REASON_ALL_NOTIFICATIONS),
437 ])
438 return group_reason_list