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