blob: 1a739184bedf02d491d068d668bd0a8060c81923 [file] [log] [blame]
# Copyright 2017 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""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 mrproto 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)
old_owner_addr_perm_list = [
ap for ap in old_owner_addr_perm_list if ap not in owner_addr_perm_list
]
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