Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/features/notify_reasons.py b/features/notify_reasons.py
new file mode 100644
index 0000000..436f975
--- /dev/null
+++ b/features/notify_reasons.py
@@ -0,0 +1,438 @@
+# Copyright 2017 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions for deciding who to notify and why.."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+
+import settings
+from features import filterrules_helpers
+from features import savedqueries_helpers
+from framework import authdata
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from proto import tracker_pb2
+from search import query2ast
+from search import searchpipeline
+from tracker import component_helpers
+from tracker import tracker_bizobj
+
+# When sending change notification emails, choose the reply-to header and
+# footer message based on three levels of the recipient's permissions
+# for that issue.
+REPLY_NOT_ALLOWED = 'REPLY_NOT_ALLOWED'
+REPLY_MAY_COMMENT = 'REPLY_MAY_COMMENT'
+REPLY_MAY_UPDATE = 'REPLY_MAY_UPDATE'
+
+# These are strings describing the various reasons that we send notifications.
+REASON_REPORTER = 'You reported this issue'
+REASON_OWNER = 'You are the owner of the issue'
+REASON_OLD_OWNER = 'You were the issue owner before this change'
+REASON_DEFAULT_OWNER = 'A rule made you owner of the issue'
+REASON_CCD = 'You were specifically CC\'d on the issue'
+REASON_DEFAULT_CCD = 'A rule CC\'d you on the issue'
+# TODO(crbug.com/monorail/2857): separate reasons for notification to group
+# members resulting from component and rules derived ccs.
+REASON_GROUP_CCD = (
+ 'A group you\'re a member of was specifically CC\'d on the issue')
+REASON_STARRER = 'You starred the issue'
+REASON_SUBSCRIBER = 'Your saved query matched the issue'
+REASON_ALSO_NOTIFY = 'A rule was set up to notify you'
+REASON_ALL_NOTIFICATIONS = (
+ 'The project was configured to send all issue notifications '
+ 'to this address')
+REASON_LINKED_ACCOUNT = 'Your linked account would have been notified'
+
+# An AddrPerm is how we represent our decision to notify a given
+# email address, which version of the email body to send to them, and
+# whether to offer them the option to reply to the notification. Many
+# of the functions in this file pass around AddrPerm lists (an "APL").
+# is_member is a boolean
+# address is a string email address
+# user is a User PB, including built-in user preference fields.
+# reply_perm is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT,
+# REPLY_MAY_UPDATE.
+# user_prefs is a UserPrefs object with string->string user prefs.
+AddrPerm = collections.namedtuple(
+ 'AddrPerm', 'is_member, address, user, reply_perm, user_prefs')
+
+
+
+def ComputeIssueChangeAddressPermList(
+ cnxn, ids_to_consider, project, issue, services, omit_addrs,
+ users_by_id, pref_check_function=lambda u: u.notify_issue_change):
+ """Return a list of user email addresses to notify of an issue change.
+
+ User email addresses are determined by looking up the given user IDs
+ in the given users_by_id dict.
+
+ Args:
+ cnxn: connection to SQL database.
+ ids_to_consider: list of user IDs for users interested in this issue.
+ project: Project PB for the project containing this issue.
+ issue: Issue PB for the issue that was updated.
+ services: Services.
+ omit_addrs: set of strings for email addresses to not notify because
+ they already know.
+ users_by_id: dict {user_id: user_view} user info.
+ pref_check_function: optional function to use to check if a certain
+ User PB has a preference set to receive the email being sent. It
+ defaults to "If I am in the issue's owner or cc field", but it
+ can be set to check "If I starred the issue."
+
+ Returns:
+ A list of AddrPerm objects.
+ """
+ memb_addr_perm_list = []
+ logging.info('Considering %r ', ids_to_consider)
+ all_user_prefs = services.user.GetUsersPrefs(cnxn, ids_to_consider)
+ for user_id in ids_to_consider:
+ if user_id == framework_constants.NO_USER_SPECIFIED:
+ continue
+ user = services.user.GetUser(cnxn, user_id)
+ # Notify people who have a pref set, or if they have no User PB
+ # because the pref defaults to True.
+ if user and not pref_check_function(user):
+ logging.info('Not notifying %r: user preference', user.email)
+ continue
+ # TODO(jrobbins): doing a bulk operation would reduce DB load.
+ auth = authdata.AuthData.FromUserID(cnxn, user_id, services)
+ perms = permissions.GetPermissions(user, auth.effective_ids, project)
+ config = services.config.GetProjectConfig(cnxn, project.project_id)
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, auth.effective_ids, config)
+
+ if not permissions.CanViewIssue(
+ auth.effective_ids, perms, project, issue,
+ granted_perms=granted_perms):
+ logging.info('Not notifying %r: user cannot view issue', user.email)
+ continue
+
+ addr = users_by_id[user_id].email
+ if addr in omit_addrs:
+ logging.info('Not notifying %r: user already knows', user.email)
+ continue
+
+ recipient_is_member = bool(framework_bizobj.UserIsInProject(
+ project, auth.effective_ids))
+
+ reply_perm = REPLY_NOT_ALLOWED
+ if project.process_inbound_email:
+ if permissions.CanEditIssue(auth.effective_ids, perms, project, issue):
+ reply_perm = REPLY_MAY_UPDATE
+ elif permissions.CanCommentIssue(
+ auth.effective_ids, perms, project, issue):
+ reply_perm = REPLY_MAY_COMMENT
+
+ memb_addr_perm_list.append(
+ AddrPerm(recipient_is_member, addr, user, reply_perm,
+ all_user_prefs[user_id]))
+
+ logging.info('For %s %s, will notify: %r',
+ project.project_name, issue.local_id,
+ [ap.address for ap in memb_addr_perm_list])
+
+ return memb_addr_perm_list
+
+
+def ComputeProjectNotificationAddrList(
+ cnxn, services, project, contributor_could_view, omit_addrs):
+ """Return a list of non-user addresses to notify of an issue change.
+
+ The non-user addresses are specified by email address strings, not
+ user IDs. One such address can be specified in the project PB.
+ It is not assumed to have permission to see all issues.
+
+ Args:
+ cnxn: connection to SQL database.
+ services: A Services object.
+ project: Project PB containing the issue that was updated.
+ contributor_could_view: True if any project contributor should be able to
+ see the notification email, e.g., in a mailing list archive or feed.
+ omit_addrs: set of strings for email addresses to not notify because
+ they already know.
+
+ Returns:
+ A list of tuples: [(False, email_addr, None, reply_permission_level), ...],
+ where reply_permission_level is always REPLY_NOT_ALLOWED for now.
+ """
+ memb_addr_perm_list = []
+ if contributor_could_view:
+ ml_addr = project.issue_notify_address
+ ml_user_prefs = services.user.GetUserPrefsByEmail(cnxn, ml_addr)
+
+ if ml_addr and ml_addr not in omit_addrs:
+ memb_addr_perm_list.append(
+ AddrPerm(False, ml_addr, None, REPLY_NOT_ALLOWED, ml_user_prefs))
+
+ return memb_addr_perm_list
+
+
+def ComputeIssueNotificationAddrList(cnxn, services, issue, omit_addrs):
+ """Return a list of non-user addresses to notify of an issue change.
+
+ The non-user addresses are specified by email address strings, not
+ user IDs. They can be set by filter rules with the "Also notify" action.
+ "Also notify" addresses are assumed to have permission to see any issue,
+ even a restricted one.
+
+ Args:
+ cnxn: connection to SQL database.
+ services: A Services object.
+ issue: Issue PB for the issue that was updated.
+ omit_addrs: set of strings for email addresses to not notify because
+ they already know.
+
+ Returns:
+ A list of tuples: [(False, email_addr, None, reply_permission_level), ...],
+ where reply_permission_level is always REPLY_NOT_ALLOWED for now.
+ """
+ addr_perm_list = []
+ for addr in issue.derived_notify_addrs:
+ if addr not in omit_addrs:
+ notify_user_prefs = services.user.GetUserPrefsByEmail(cnxn, addr)
+ addr_perm_list.append(
+ AddrPerm(False, addr, None, REPLY_NOT_ALLOWED, notify_user_prefs))
+
+ return addr_perm_list
+
+
+def _GetSubscribersAddrPermList(
+ cnxn, services, issue, project, config, omit_addrs, users_by_id):
+ """Lookup subscribers, evaluate their saved queries, and decide to notify."""
+ users_to_queries = GetNonOmittedSubscriptions(
+ cnxn, services, [project.project_id], omit_addrs)
+ # TODO(jrobbins): need to pass through the user_id to use for "me".
+ subscribers_to_notify = EvaluateSubscriptions(
+ cnxn, issue, users_to_queries, services, config)
+ # TODO(jrobbins): expand any subscribers that are user groups.
+ subs_needing_user_views = [
+ uid for uid in subscribers_to_notify if uid not in users_by_id]
+ users_by_id.update(framework_views.MakeAllUserViews(
+ cnxn, services.user, subs_needing_user_views))
+ sub_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, subscribers_to_notify, project, issue, services, omit_addrs,
+ users_by_id, pref_check_function=lambda *args: True)
+
+ return sub_addr_perm_list
+
+
+def EvaluateSubscriptions(
+ cnxn, issue, users_to_queries, services, config):
+ """Determine subscribers who have subs that match the given issue."""
+ # Note: unlike filter rule, subscriptions see explicit & derived values.
+ lower_labels = [lab.lower() for lab in tracker_bizobj.GetLabels(issue)]
+ label_set = set(lower_labels)
+
+ subscribers_to_notify = []
+ for uid, saved_queries in users_to_queries.items():
+ for sq in saved_queries:
+ if sq.subscription_mode != 'immediate':
+ continue
+ if issue.project_id not in sq.executes_in_project_ids:
+ continue
+ cond = savedqueries_helpers.SavedQueryToCond(sq)
+ # TODO(jrobbins): Support linked accounts me_user_ids.
+ cond, _warnings = searchpipeline.ReplaceKeywordsWithUserIDs([uid], cond)
+ cond_ast = query2ast.ParseUserQuery(
+ cond, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+
+ if filterrules_helpers.EvalPredicate(
+ cnxn, services, cond_ast, issue, label_set, config,
+ tracker_bizobj.GetOwnerId(issue), tracker_bizobj.GetCcIds(issue),
+ tracker_bizobj.GetStatus(issue)):
+ subscribers_to_notify.append(uid)
+ break # Don't bother looking at the user's other saved quereies.
+
+ return subscribers_to_notify
+
+
+def GetNonOmittedSubscriptions(cnxn, services, project_ids, omit_addrs):
+ """Get a dict of users w/ subscriptions in those projects."""
+ users_to_queries = services.features.GetSubscriptionsInProjects(
+ cnxn, project_ids)
+ user_emails = services.user.LookupUserEmails(
+ cnxn, list(users_to_queries.keys()))
+ for user_id, email in user_emails.items():
+ if email in omit_addrs:
+ del users_to_queries[user_id]
+ return users_to_queries
+
+
+def ComputeCustomFieldAddrPerms(
+ cnxn, config, issue, project, services, omit_addrs, users_by_id):
+ """Check the reasons to notify users named in custom fields."""
+ group_reason_list = []
+ for fd in config.field_defs:
+ (direct_named_ids,
+ transitive_named_ids) = services.usergroup.ExpandAnyGroupEmailRecipients(
+ cnxn, ComputeNamedUserIDsToNotify(issue.field_values, fd))
+ named_user_ids = direct_named_ids + transitive_named_ids
+ if named_user_ids:
+ named_addr_perms = ComputeIssueChangeAddressPermList(
+ cnxn, named_user_ids, project, issue, services, omit_addrs,
+ users_by_id, pref_check_function=lambda u: True)
+ group_reason_list.append(
+ (named_addr_perms, 'You are named in the %s field' % fd.field_name))
+
+ return group_reason_list
+
+
+def ComputeNamedUserIDsToNotify(field_values, fd):
+ """Give a list of user IDs to notify because they're in a field."""
+ if (fd.field_type == tracker_pb2.FieldTypes.USER_TYPE and
+ fd.notify_on == tracker_pb2.NotifyTriggers.ANY_COMMENT):
+ return [fv.user_id for fv in field_values
+ if fv.field_id == fd.field_id]
+
+ return []
+
+
+def ComputeComponentFieldAddrPerms(
+ cnxn, config, issue, project, services, omit_addrs, users_by_id):
+ """Return [(addr_perm_list, reason),...] for users auto-cc'd by components."""
+ component_ids = set(issue.component_ids)
+ group_reason_list = []
+ for cd in config.component_defs:
+ if cd.component_id in component_ids:
+ (direct_ccs,
+ transitive_ccs) = services.usergroup.ExpandAnyGroupEmailRecipients(
+ cnxn, component_helpers.GetCcIDsForComponentAndAncestors(config, cd))
+ cc_ids = direct_ccs + transitive_ccs
+ comp_addr_perms = ComputeIssueChangeAddressPermList(
+ cnxn, cc_ids, project, issue, services, omit_addrs,
+ users_by_id, pref_check_function=lambda u: True)
+ group_reason_list.append(
+ (comp_addr_perms,
+ 'You are auto-CC\'d on all issues in component %s' % cd.path))
+
+ return group_reason_list
+
+
+def ComputeGroupReasonList(
+ cnxn, services, project, issue, config, users_by_id, omit_addrs,
+ contributor_could_view, starrer_ids=None, noisy=False,
+ old_owner_id=None, commenter_in_project=True, include_subscribers=True,
+ include_notify_all=True,
+ starrer_pref_check_function=lambda u: u.notify_starred_issue_change):
+ """Return a list [(addr_perm_list, reason),...] of addrs to notify."""
+ # Get the transitive set of owners and Cc'd users, and their UserViews.
+ starrer_ids = starrer_ids or []
+ reporter = [issue.reporter_id] if issue.reporter_id in starrer_ids else []
+ if old_owner_id:
+ old_direct_owners, old_transitive_owners = (
+ services.usergroup.ExpandAnyGroupEmailRecipients(cnxn, [old_owner_id]))
+ else:
+ old_direct_owners, old_transitive_owners = [], []
+
+ direct_owners, transitive_owners = (
+ services.usergroup.ExpandAnyGroupEmailRecipients(cnxn, [issue.owner_id]))
+ der_direct_owners, der_transitive_owners = (
+ services.usergroup.ExpandAnyGroupEmailRecipients(
+ cnxn, [issue.derived_owner_id]))
+ direct_comp, trans_comp = services.usergroup.ExpandAnyGroupEmailRecipients(
+ cnxn, component_helpers.GetComponentCcIDs(issue, config))
+ direct_ccs, transitive_ccs = services.usergroup.ExpandAnyGroupEmailRecipients(
+ cnxn, list(issue.cc_ids))
+ der_direct_ccs, der_transitive_ccs = (
+ services.usergroup.ExpandAnyGroupEmailRecipients(
+ cnxn, list(issue.derived_cc_ids)))
+ # Remove cc's derived from components, which are grouped into their own
+ # notify-reason-group in ComputeComponentFieldAddrPerms().
+ # This means that an exact email cc'd by both a component and a rule will
+ # get an email that says they are only being notified because of the
+ # component.
+ # Note that a user directly cc'd due to a rule who is also part of a
+ # group cc'd due to a component, will get a message saying they're cc'd for
+ # both the rule and the component.
+ der_direct_ccs = list(set(der_direct_ccs).difference(set(direct_comp)))
+ der_transitive_ccs = list(set(der_transitive_ccs).difference(set(trans_comp)))
+
+ users_by_id.update(framework_views.MakeAllUserViews(
+ cnxn, services.user, transitive_owners, der_transitive_owners,
+ direct_comp, trans_comp, transitive_ccs, der_transitive_ccs))
+
+ # Notify interested people according to the reason for their interest:
+ # owners, component auto-cc'd users, cc'd users, starrers, and
+ # other notification addresses.
+ reporter_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, reporter, project, issue, services, omit_addrs, users_by_id)
+ owner_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, direct_owners + transitive_owners, project, issue,
+ services, omit_addrs, users_by_id)
+ old_owner_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, old_direct_owners + old_transitive_owners, project, issue,
+ services, omit_addrs, users_by_id)
+ owner_addr_perm_set = set(owner_addr_perm_list)
+ old_owner_addr_perm_list = [ap for ap in old_owner_addr_perm_list
+ if ap not in owner_addr_perm_set]
+ der_owner_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, der_direct_owners + der_transitive_owners, project, issue,
+ services, omit_addrs, users_by_id)
+ cc_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, direct_ccs, project, issue, services, omit_addrs, users_by_id)
+ transitive_cc_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, transitive_ccs, project, issue, services, omit_addrs, users_by_id)
+ der_cc_addr_perm_list = ComputeIssueChangeAddressPermList(
+ cnxn, der_direct_ccs + der_transitive_ccs, project, issue,
+ services, omit_addrs, users_by_id)
+
+ starrer_addr_perm_list = []
+ sub_addr_perm_list = []
+ if not noisy or commenter_in_project:
+ # Avoid an OOM by only notifying a number of starrers that we can handle.
+ # And, we really should limit the number of emails that we send anyway.
+ max_starrers = settings.max_starrers_to_notify
+ starrer_ids = starrer_ids[-max_starrers:]
+ # Note: starrers can never be user groups.
+ starrer_addr_perm_list = (
+ ComputeIssueChangeAddressPermList(
+ cnxn, starrer_ids, project, issue,
+ services, omit_addrs, users_by_id,
+ pref_check_function=starrer_pref_check_function))
+
+ if include_subscribers:
+ sub_addr_perm_list = _GetSubscribersAddrPermList(
+ cnxn, services, issue, project, config, omit_addrs,
+ users_by_id)
+
+ # Get the list of addresses to notify based on filter rules.
+ issue_notify_addr_list = ComputeIssueNotificationAddrList(
+ cnxn, services, issue, omit_addrs)
+ # Get the list of addresses to notify based on project settings.
+ proj_notify_addr_list = []
+ if include_notify_all:
+ proj_notify_addr_list = ComputeProjectNotificationAddrList(
+ cnxn, services, project, contributor_could_view, omit_addrs)
+
+ group_reason_list = [
+ (reporter_addr_perm_list, REASON_REPORTER),
+ (owner_addr_perm_list, REASON_OWNER),
+ (old_owner_addr_perm_list, REASON_OLD_OWNER),
+ (der_owner_addr_perm_list, REASON_DEFAULT_OWNER),
+ (cc_addr_perm_list, REASON_CCD),
+ (transitive_cc_addr_perm_list, REASON_GROUP_CCD),
+ (der_cc_addr_perm_list, REASON_DEFAULT_CCD),
+ ]
+ group_reason_list.extend(ComputeComponentFieldAddrPerms(
+ cnxn, config, issue, project, services, omit_addrs,
+ users_by_id))
+ group_reason_list.extend(ComputeCustomFieldAddrPerms(
+ cnxn, config, issue, project, services, omit_addrs,
+ users_by_id))
+ group_reason_list.extend([
+ (starrer_addr_perm_list, REASON_STARRER),
+ (sub_addr_perm_list, REASON_SUBSCRIBER),
+ (issue_notify_addr_list, REASON_ALSO_NOTIFY),
+ (proj_notify_addr_list, REASON_ALL_NOTIFICATIONS),
+ ])
+ return group_reason_list