| # 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 |