| Index: appengine/monorail/features/notify_helpers.py
|
| diff --git a/appengine/monorail/features/notify_helpers.py b/appengine/monorail/features/notify_helpers.py
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..ff37190419329899d56b3316ac67cbf5f6fefb42
|
| --- /dev/null
|
| +++ b/appengine/monorail/features/notify_helpers.py
|
| @@ -0,0 +1,414 @@
|
| +# Copyright 2016 The Chromium Authors. All rights reserved.
|
| +# Use of this source code is govered 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 email notifications of issue changes."""
|
| +
|
| +import cgi
|
| +import logging
|
| +import re
|
| +
|
| +from django.utils.html import urlize
|
| +
|
| +from features import filterrules_helpers
|
| +from features import savedqueries_helpers
|
| +from framework import emailfmt
|
| +from framework import framework_bizobj
|
| +from framework import framework_constants
|
| +from framework import framework_helpers
|
| +from framework import monorailrequest
|
| +from framework import permissions
|
| +from framework import urls
|
| +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 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'
|
| +
|
| +# This HTML template adds mark up which enables Gmail/Inbox to display a
|
| +# convenient link that takes users to the CL directly from the inbox without
|
| +# having to click on the email.
|
| +# Documentation for this schema.org markup is here:
|
| +# https://developers.google.com/gmail/markup/reference/go-to-action
|
| +HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE = """
|
| +<html>
|
| +<body>
|
| +<script type="application/ld+json">
|
| +{
|
| + "@context": "http://schema.org",
|
| + "@type": "EmailMessage",
|
| + "potentialAction": {
|
| + "@type": "ViewAction",
|
| + "name": "View Issue",
|
| + "url": "%s"
|
| + },
|
| + "description": ""
|
| +}
|
| +</script>
|
| +
|
| +<div style="font-family: arial, sans-serif">%s</div>
|
| +</body>
|
| +</html>
|
| +"""
|
| +
|
| +
|
| +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 contianing 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 tuples: [(recipient_is_member, address, reply_perm), ...] where
|
| + reply_perm is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT,
|
| + REPLY_MAY_UPDATE.
|
| + """
|
| + memb_addr_perm_list = []
|
| + 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):
|
| + continue
|
| + # TODO(jrobbins): doing a bulk operation would reduce DB load.
|
| + auth = monorailrequest.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):
|
| + continue
|
| +
|
| + addr = users_by_id[user_id].email
|
| + if addr in omit_addrs:
|
| + 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((recipient_is_member, addr, reply_perm))
|
| +
|
| + logging.info('For %s %s, will notify: %r',
|
| + project.project_name, issue.local_id, memb_addr_perm_list)
|
| +
|
| + return memb_addr_perm_list
|
| +
|
| +
|
| +def ComputeProjectNotificationAddrList(
|
| + 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:
|
| + 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_address, 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
|
| + if ml_addr and ml_addr not in omit_addrs:
|
| + memb_addr_perm_list.append((False, ml_addr, REPLY_NOT_ALLOWED))
|
| +
|
| + return memb_addr_perm_list
|
| +
|
| +
|
| +def ComputeIssueNotificationAddrList(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:
|
| + 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_address, 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:
|
| + addr_perm_list.append((False, addr, REPLY_NOT_ALLOWED))
|
| +
|
| + return addr_perm_list
|
| +
|
| +
|
| +def MakeBulletedEmailWorkItems(
|
| + group_reason_list, subject, body_for_non_members, body_for_members,
|
| + project, hostport, commenter_view, seq_num=None, detail_url=None):
|
| + """Make a list of dicts describing email-sending tasks to notify users.
|
| +
|
| + Args:
|
| + group_reason_list: list of (is_memb, addr_perm, reason) tuples.
|
| + subject: string email subject line.
|
| + body_for_non_members: string body of email to send to non-members.
|
| + body_for_members: string body of email to send to members.
|
| + project: Project that contains the issue.
|
| + hostport: string hostname and port number for links to the site.
|
| + commenter_view: UserView for the user who made the comment.
|
| + seq_num: optional int sequence number of the comment.
|
| + detail_url: optional str direct link to the issue.
|
| +
|
| + Returns:
|
| + A list of dictionaries, each with all needed info to send an individual
|
| + email to one user. Each email contains a footer that lists all the
|
| + reasons why that user received the email.
|
| + """
|
| + logging.info('group_reason_list is %r', group_reason_list)
|
| + addr_reasons_dict = {}
|
| + for group, reason in group_reason_list:
|
| + for memb_addr_perm in group:
|
| + addr_reasons_dict.setdefault(memb_addr_perm, []).append(reason)
|
| +
|
| + email_tasks = []
|
| + for memb_addr_perm, reasons in addr_reasons_dict.iteritems():
|
| + email_tasks.append(_MakeEmailWorkItem(
|
| + memb_addr_perm, reasons, subject, body_for_non_members,
|
| + body_for_members, project, hostport, commenter_view, seq_num=seq_num,
|
| + detail_url=detail_url))
|
| +
|
| + return email_tasks
|
| +
|
| +
|
| +def _MakeEmailWorkItem(
|
| + (recipient_is_member, to_addr, reply_perm), reasons, subject,
|
| + body_for_non_members, body_for_members, project, hostport, commenter_view,
|
| + seq_num=None, detail_url=None):
|
| + """Make one email task dict for one user, includes a detailed reason."""
|
| + footer = _MakeNotificationFooter(reasons, reply_perm, hostport)
|
| + if isinstance(footer, unicode):
|
| + footer = footer.encode('utf-8')
|
| + if recipient_is_member:
|
| + logging.info('got member %r', to_addr)
|
| + body = body_for_members
|
| + else:
|
| + logging.info('got non-member %r', to_addr)
|
| + body = body_for_non_members
|
| +
|
| + logging.info('sending body + footer: %r', body + footer)
|
| + can_reply_to = (
|
| + reply_perm != REPLY_NOT_ALLOWED and project.process_inbound_email)
|
| + from_addr = emailfmt.FormatFromAddr(
|
| + project, commenter_view=commenter_view, reveal_addr=recipient_is_member,
|
| + can_reply_to=can_reply_to)
|
| + if can_reply_to:
|
| + reply_to = '%s@%s' % (project.project_name, emailfmt.MailDomain())
|
| + else:
|
| + reply_to = emailfmt.NoReplyAddress()
|
| + refs = emailfmt.GetReferences(
|
| + to_addr, subject, seq_num,
|
| + '%s@%s' % (project.project_name, emailfmt.MailDomain()))
|
| + # If detail_url is specified then we can use markup to display a convenient
|
| + # link that takes users directly to the issue without clicking on the email.
|
| + html_body = None
|
| + if detail_url:
|
| + # cgi.escape the body and additionally escape single quotes which are
|
| + # occassionally used to contain HTML attributes and event handler
|
| + # definitions.
|
| + html_escaped_body = cgi.escape(body + footer, quote=1).replace("'", ''')
|
| + html_body = HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % (
|
| + detail_url,
|
| + _AddHTMLTags(html_escaped_body.decode('utf-8')))
|
| + return dict(to=to_addr, subject=subject, body=body + footer,
|
| + html_body=html_body, from_addr=from_addr, reply_to=reply_to,
|
| + references=refs)
|
| +
|
| +
|
| +def _AddHTMLTags(body):
|
| + """Adds HMTL tags in the specified email body.
|
| +
|
| + Specifically does the following:
|
| + * Detects links and adds <a href>s around the links.
|
| + * Substitutes <br/> for all occurrences of "\n".
|
| +
|
| + See crbug.com/582463 for context.
|
| + """
|
| + # Convert all URLs into clickable links.
|
| + body = urlize(body)
|
| + # The above step converts
|
| + # '<link.com>' into '<<a href="link.com>">link.com></a>;' and
|
| + # '<x@y.com>' into '<<a href="mailto:x@y.com>">x@y.com></a>;'
|
| + # The below regex fixes this specific problem. See
|
| + # https://bugs.chromium.org/p/monorail/issues/detail?id=1007 for more details.
|
| + body = re.sub(r'<<a href="(|mailto:)(.*?)>">(.*?)></a>;',
|
| + r'<a href="\1\2"><\3></a>', body)
|
| +
|
| + # Convert all "\n"s into "<br/>"s.
|
| + body = body.replace("\n", "<br/>")
|
| + return body
|
| +
|
| +
|
| +def _MakeNotificationFooter(reasons, reply_perm, hostport):
|
| + """Make an informative footer for a notification email.
|
| +
|
| + Args:
|
| + reasons: a list of strings to be used as the explanation. Empty if no
|
| + reason is to be given.
|
| + reply_perm: string which is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT,
|
| + REPLY_MAY_UPDATE.
|
| + hostport: string with domain_name:port_number to be used in linking to
|
| + the user preferences page.
|
| +
|
| + Returns:
|
| + A string to be used as the email footer.
|
| + """
|
| + if not reasons:
|
| + return ''
|
| +
|
| + domain_port = hostport.split(':')
|
| + domain_port[0] = framework_helpers.GetPreferredDomain(domain_port[0])
|
| + hostport = ':'.join(domain_port)
|
| +
|
| + prefs_url = 'https://%s%s' % (hostport, urls.USER_SETTINGS)
|
| + lines = ['-- ']
|
| + lines.append('You received this message because:')
|
| + lines.extend(' %d. %s' % (idx + 1, reason)
|
| + for idx, reason in enumerate(reasons))
|
| +
|
| + lines.extend(['', 'You may adjust your notification preferences at:',
|
| + prefs_url])
|
| +
|
| + if reply_perm == REPLY_MAY_COMMENT:
|
| + lines.extend(['', 'Reply to this email to add a comment.'])
|
| + elif reply_perm == REPLY_MAY_UPDATE:
|
| + lines.extend(['', 'Reply to this email to add a comment or make updates.'])
|
| +
|
| + return '\n'.join(lines)
|
| +
|
| +
|
| +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, users_to_queries.keys())
|
| + for user_id, email in user_emails.iteritems():
|
| + if email in omit_addrs:
|
| + del users_to_queries[user_id]
|
| +
|
| + return users_to_queries
|
| +
|
| +
|
| +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.iteritems():
|
| + 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)
|
| + logging.info('evaluating query %s: %r', sq.name, cond)
|
| + cond = searchpipeline.ReplaceKeywordsWithUserID(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 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:
|
| + named_user_ids = ComputeNamedUserIDsToNotify(issue, fd)
|
| + 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(issue, 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 issue.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, 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:
|
| + cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(config, cd)
|
| + 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
|
|
|