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