| Index: appengine/monorail/features/notify.py
|
| diff --git a/appengine/monorail/features/notify.py b/appengine/monorail/features/notify.py
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..b6382b6ee5daad42eaf5981e1e74e5765fa21787
|
| --- /dev/null
|
| +++ b/appengine/monorail/features/notify.py
|
| @@ -0,0 +1,928 @@
|
| +# 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
|
| +
|
| +"""Task handlers for email notifications of issue changes.
|
| +
|
| +Email notificatons are sent when an issue changes, an issue that is blocking
|
| +another issue changes, or a bulk edit is done. The users notified include
|
| +the project-wide mailing list, issue owners, cc'd users, starrers,
|
| +also-notify addresses, and users who have saved queries with email notification
|
| +set.
|
| +"""
|
| +
|
| +import collections
|
| +import logging
|
| +
|
| +from third_party import ezt
|
| +
|
| +from google.appengine.api import mail
|
| +from google.appengine.api import taskqueue
|
| +
|
| +import settings
|
| +from features import autolink
|
| +from features import notify_helpers
|
| +from framework import emailfmt
|
| +from framework import framework_bizobj
|
| +from framework import framework_constants
|
| +from framework import framework_helpers
|
| +from framework import framework_views
|
| +from framework import jsonfeed
|
| +from framework import monorailrequest
|
| +from framework import permissions
|
| +from framework import template_helpers
|
| +from framework import urls
|
| +from tracker import component_helpers
|
| +from tracker import tracker_bizobj
|
| +from tracker import tracker_helpers
|
| +from tracker import tracker_views
|
| +
|
| +
|
| +TEMPLATE_PATH = framework_constants.TEMPLATE_PATH
|
| +
|
| +
|
| +def PrepareAndSendIssueChangeNotification(
|
| + project_id, local_id, hostport, commenter_id, seq_num, send_email=True,
|
| + old_owner_id=framework_constants.NO_USER_SPECIFIED):
|
| + """Create a task to notify users that an issue has changed.
|
| +
|
| + Args:
|
| + project_id: int ID of the project containing the changed issue.
|
| + local_id: Issue number for the issue that was updated and saved.
|
| + hostport: string domain name and port number from the HTTP request.
|
| + commenter_id: int user ID of the user who made the comment.
|
| + seq_num: int index into the comments of the new comment.
|
| + send_email: True if email notifications should be sent.
|
| + old_owner_id: optional user ID of owner before the current change took
|
| + effect. He/she will also be notified.
|
| +
|
| + Returns nothing.
|
| + """
|
| + params = dict(
|
| + project_id=project_id, id=local_id, commenter_id=commenter_id,
|
| + seq=seq_num, hostport=hostport,
|
| + old_owner_id=old_owner_id, send_email=int(send_email))
|
| + logging.info('adding notify task with params %r', params)
|
| + taskqueue.add(url=urls.NOTIFY_ISSUE_CHANGE_TASK + '.do', params=params)
|
| +
|
| +
|
| +def PrepareAndSendIssueBlockingNotification(
|
| + project_id, hostport, local_id, delta_blocker_iids,
|
| + commenter_id, send_email=True):
|
| + """Create a task to follow up on an issue blocked_on change."""
|
| + if not delta_blocker_iids:
|
| + return # No notification is needed
|
| +
|
| + params = dict(
|
| + project_id=project_id, id=local_id, commenter_id=commenter_id,
|
| + hostport=hostport, send_email=int(send_email),
|
| + delta_blocker_iids=','.join(str(iid) for iid in delta_blocker_iids))
|
| +
|
| + logging.info('adding blocking task with params %r', params)
|
| + taskqueue.add(url=urls.NOTIFY_BLOCKING_CHANGE_TASK + '.do', params=params)
|
| +
|
| +
|
| +def SendIssueBulkChangeNotification(
|
| + hostport, project_id, local_ids, old_owner_ids,
|
| + comment_text, commenter_id, amendments, send_email, users_by_id):
|
| + """Create a task to follow up on an issue blocked_on change."""
|
| + amendment_lines = []
|
| + for up in amendments:
|
| + line = ' %s: %s' % (
|
| + tracker_bizobj.GetAmendmentFieldName(up),
|
| + tracker_bizobj.AmendmentString(up, users_by_id))
|
| + if line not in amendment_lines:
|
| + amendment_lines.append(line)
|
| +
|
| + params = dict(
|
| + project_id=project_id, commenter_id=commenter_id,
|
| + hostport=hostport, send_email=int(send_email),
|
| + ids=','.join(str(lid) for lid in local_ids),
|
| + old_owner_ids=','.join(str(uid) for uid in old_owner_ids),
|
| + comment_text=comment_text, amendments='\n'.join(amendment_lines))
|
| +
|
| + logging.info('adding bulk task with params %r', params)
|
| + taskqueue.add(url=urls.NOTIFY_BULK_CHANGE_TASK + '.do', params=params)
|
| +
|
| +
|
| +def _EnqueueOutboundEmail(message_dict):
|
| + """Create a task to send one email message, all fields are in the dict.
|
| +
|
| + We use a separate task for each outbound email to isolate errors.
|
| +
|
| + Args:
|
| + message_dict: dict with all needed info for the task.
|
| + """
|
| + logging.info('Queuing an email task with params %r', message_dict)
|
| + taskqueue.add(
|
| + url=urls.OUTBOUND_EMAIL_TASK + '.do', params=message_dict,
|
| + queue_name='outboundemail')
|
| +
|
| +
|
| +def AddAllEmailTasks(tasks):
|
| + """Add one GAE task for each email to be sent."""
|
| + notified = []
|
| + for task in tasks:
|
| + _EnqueueOutboundEmail(task)
|
| + notified.append(task['to'])
|
| +
|
| + return notified
|
| +
|
| +
|
| +class NotifyTaskBase(jsonfeed.InternalTask):
|
| + """Abstract base class for notification task handler."""
|
| +
|
| + _EMAIL_TEMPLATE = None # Subclasses must override this.
|
| +
|
| + CHECK_SECURITY_TOKEN = False
|
| +
|
| + def __init__(self, *args, **kwargs):
|
| + super(NotifyTaskBase, self).__init__(*args, **kwargs)
|
| +
|
| + if not self._EMAIL_TEMPLATE:
|
| + raise Exception('Subclasses must override _EMAIL_TEMPLATE.'
|
| + ' This class must not be called directly.')
|
| + # We use FORMAT_RAW for emails because they are plain text, not HTML.
|
| + # TODO(jrobbins): consider sending HTML formatted emails someday.
|
| + self.email_template = template_helpers.MonorailTemplate(
|
| + TEMPLATE_PATH + self._EMAIL_TEMPLATE,
|
| + compress_whitespace=False, base_format=ezt.FORMAT_RAW)
|
| +
|
| +
|
| +class NotifyIssueChangeTask(NotifyTaskBase):
|
| + """JSON servlet that notifies appropriate users after an issue change."""
|
| +
|
| + _EMAIL_TEMPLATE = 'tracker/issue-change-notification-email.ezt'
|
| +
|
| + def HandleRequest(self, mr):
|
| + """Process the task to notify users after an issue change.
|
| +
|
| + Args:
|
| + mr: common information parsed from the HTTP request.
|
| +
|
| + Returns:
|
| + Results dictionary in JSON format which is useful just for debugging.
|
| + The main goal is the side-effect of sending emails.
|
| + """
|
| + project_id = mr.specified_project_id
|
| + if project_id is None:
|
| + return {
|
| + 'params': {},
|
| + 'notified': [],
|
| + 'message': 'Cannot proceed without a valid project ID.',
|
| + }
|
| + commenter_id = mr.GetPositiveIntParam('commenter_id')
|
| + seq_num = mr.seq
|
| + omit_ids = [commenter_id]
|
| + hostport = mr.GetParam('hostport')
|
| + old_owner_id = mr.GetPositiveIntParam('old_owner_id')
|
| + send_email = bool(mr.GetIntParam('send_email'))
|
| + params = dict(
|
| + project_id=project_id, local_id=mr.local_id, commenter_id=commenter_id,
|
| + seq_num=seq_num, hostport=hostport, old_owner_id=old_owner_id,
|
| + omit_ids=omit_ids, send_email=send_email)
|
| +
|
| + logging.info('issue change params are %r', params)
|
| + project = self.services.project.GetProject(mr.cnxn, project_id)
|
| + config = self.services.config.GetProjectConfig(mr.cnxn, project_id)
|
| + issue = self.services.issue.GetIssueByLocalID(
|
| + mr.cnxn, project_id, mr.local_id)
|
| +
|
| + if issue.is_spam:
|
| + # Don't send email for spam issues.
|
| + return {
|
| + 'params': params,
|
| + 'notified': [],
|
| + }
|
| +
|
| + all_comments = self.services.issue.GetCommentsForIssue(
|
| + mr.cnxn, issue.issue_id)
|
| + comment = all_comments[seq_num]
|
| +
|
| + # Only issues that any contributor could view sent to mailing lists.
|
| + contributor_could_view = permissions.CanViewIssue(
|
| + set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
|
| + project, issue)
|
| + starrer_ids = self.services.issue_star.LookupItemStarrers(
|
| + mr.cnxn, issue.issue_id)
|
| + users_by_id = framework_views.MakeAllUserViews(
|
| + mr.cnxn, self.services.user,
|
| + tracker_bizobj.UsersInvolvedInIssues([issue]), [old_owner_id],
|
| + tracker_bizobj.UsersInvolvedInComment(comment),
|
| + issue.cc_ids, issue.derived_cc_ids, starrer_ids, omit_ids)
|
| +
|
| + # Make followup tasks to send emails
|
| + tasks = []
|
| + if send_email:
|
| + tasks = self._MakeEmailTasks(
|
| + mr.cnxn, project, issue, config, old_owner_id, users_by_id,
|
| + all_comments, comment, starrer_ids, contributor_could_view,
|
| + hostport, omit_ids)
|
| +
|
| + notified = AddAllEmailTasks(tasks)
|
| +
|
| + return {
|
| + 'params': params,
|
| + 'notified': notified,
|
| + }
|
| +
|
| + def _MakeEmailTasks(
|
| + self, cnxn, project, issue, config, old_owner_id,
|
| + users_by_id, all_comments, comment, starrer_ids,
|
| + contributor_could_view, hostport, omit_ids):
|
| + """Formulate emails to be sent."""
|
| + detail_url = framework_helpers.IssueCommentURL(
|
| + hostport, project, issue.local_id, seq_num=comment.sequence)
|
| +
|
| + # TODO(jrobbins): avoid the need to make a MonorailRequest object.
|
| + mr = monorailrequest.MonorailRequest()
|
| + mr.project_name = project.project_name
|
| + mr.project = project
|
| +
|
| + # We do not autolink in the emails, so just use an empty
|
| + # registry of autolink rules.
|
| + # TODO(jrobbins): offer users an HTML email option w/ autolinks.
|
| + autolinker = autolink.Autolink()
|
| +
|
| + email_data = {
|
| + # Pass open_related and closed_related into this method and to
|
| + # the issue view so that we can show it on new issue email.
|
| + 'issue': tracker_views.IssueView(issue, users_by_id, config),
|
| + 'summary': issue.summary,
|
| + 'comment': tracker_views.IssueCommentView(
|
| + project.project_name, comment, users_by_id,
|
| + autolinker, {}, mr, issue),
|
| + 'comment_text': comment.content,
|
| + 'detail_url': detail_url,
|
| + }
|
| +
|
| + # Generate two versions of email body: members version has all
|
| + # full email addresses exposed.
|
| + body_for_non_members = self.email_template.GetResponse(email_data)
|
| + framework_views.RevealAllEmails(users_by_id)
|
| + email_data['comment'] = tracker_views.IssueCommentView(
|
| + project.project_name, comment, users_by_id,
|
| + autolinker, {}, mr, issue)
|
| + body_for_members = self.email_template.GetResponse(email_data)
|
| +
|
| + subject = 'Issue %d in %s: %s' % (
|
| + issue.local_id, project.project_name, issue.summary)
|
| +
|
| + commenter_email = users_by_id[comment.user_id].email
|
| + omit_addrs = set([commenter_email] +
|
| + [users_by_id[omit_id].email for omit_id in omit_ids])
|
| +
|
| + auth = monorailrequest.AuthData.FromUserID(
|
| + cnxn, comment.user_id, self.services)
|
| + commenter_in_project = framework_bizobj.UserIsInProject(
|
| + project, auth.effective_ids)
|
| + noisy = tracker_helpers.IsNoisy(len(all_comments) - 1, len(starrer_ids))
|
| +
|
| + # Get the transitive set of owners and Cc'd users, and their proxies.
|
| + reporter = [issue.reporter_id] if issue.reporter_id in starrer_ids else []
|
| + old_direct_owners, old_transitive_owners = (
|
| + self.services.usergroup.ExpandAnyUserGroups(cnxn, [old_owner_id]))
|
| + direct_owners, transitive_owners = (
|
| + self.services.usergroup.ExpandAnyUserGroups(cnxn, [issue.owner_id]))
|
| + der_direct_owners, der_transitive_owners = (
|
| + self.services.usergroup.ExpandAnyUserGroups(
|
| + cnxn, [issue.derived_owner_id]))
|
| + direct_comp, trans_comp = self.services.usergroup.ExpandAnyUserGroups(
|
| + cnxn, component_helpers.GetComponentCcIDs(issue, config))
|
| + direct_ccs, transitive_ccs = self.services.usergroup.ExpandAnyUserGroups(
|
| + cnxn, list(issue.cc_ids))
|
| + # TODO(jrobbins): This will say that the user was cc'd by a rule when it
|
| + # was really added to the derived_cc_ids by a component.
|
| + der_direct_ccs, der_transitive_ccs = (
|
| + self.services.usergroup.ExpandAnyUserGroups(
|
| + cnxn, list(issue.derived_cc_ids)))
|
| + users_by_id.update(framework_views.MakeAllUserViews(
|
| + cnxn, self.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 = notify_helpers.ComputeIssueChangeAddressPermList(
|
| + cnxn, reporter, project, issue, self.services, omit_addrs, users_by_id)
|
| + owner_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList(
|
| + cnxn, direct_owners + transitive_owners, project, issue,
|
| + self.services, omit_addrs, users_by_id)
|
| + old_owner_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList(
|
| + cnxn, old_direct_owners + old_transitive_owners, project, issue,
|
| + self.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 = notify_helpers.ComputeIssueChangeAddressPermList(
|
| + cnxn, der_direct_owners + der_transitive_owners, project, issue,
|
| + self.services, omit_addrs, users_by_id)
|
| + cc_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList(
|
| + cnxn, direct_ccs + transitive_ccs, project, issue,
|
| + self.services, omit_addrs, users_by_id)
|
| + der_cc_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList(
|
| + cnxn, der_direct_ccs + der_transitive_ccs, project, issue,
|
| + self.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 = (
|
| + notify_helpers.ComputeIssueChangeAddressPermList(
|
| + cnxn, starrer_ids, project, issue,
|
| + self.services, omit_addrs, users_by_id,
|
| + pref_check_function=lambda u: u.notify_starred_issue_change))
|
| +
|
| + sub_addr_perm_list = _GetSubscribersAddrPermList(
|
| + cnxn, self.services, issue, project, config, omit_addrs,
|
| + users_by_id)
|
| +
|
| + # Get the list of addresses to notify based on filter rules.
|
| + issue_notify_addr_list = notify_helpers.ComputeIssueNotificationAddrList(
|
| + issue, omit_addrs)
|
| + # Get the list of addresses to notify based on project settings.
|
| + proj_notify_addr_list = notify_helpers.ComputeProjectNotificationAddrList(
|
| + project, contributor_could_view, omit_addrs)
|
| +
|
| + # Give each user a bullet-list of all the reasons that apply for that user.
|
| + group_reason_list = [
|
| + (reporter_addr_perm_list, 'You reported this issue'),
|
| + (owner_addr_perm_list, 'You are the owner of the issue'),
|
| + (old_owner_addr_perm_list,
|
| + 'You were the issue owner before this change'),
|
| + (der_owner_addr_perm_list, 'A rule made you owner of the issue'),
|
| + (cc_addr_perm_list, 'You were specifically CC\'d on the issue'),
|
| + (der_cc_addr_perm_list, 'A rule CC\'d you on the issue'),
|
| + ]
|
| + group_reason_list.extend(notify_helpers.ComputeComponentFieldAddrPerms(
|
| + cnxn, config, issue, project, self.services, omit_addrs,
|
| + users_by_id))
|
| + group_reason_list.extend(notify_helpers.ComputeCustomFieldAddrPerms(
|
| + cnxn, config, issue, project, self.services, omit_addrs,
|
| + users_by_id))
|
| + group_reason_list.extend([
|
| + (starrer_addr_perm_list, 'You starred the issue'),
|
| + (sub_addr_perm_list, 'Your saved query matched the issue'),
|
| + (issue_notify_addr_list,
|
| + 'A rule was set up to notify you'),
|
| + (proj_notify_addr_list,
|
| + 'The project was configured to send all issue notifications '
|
| + 'to this address'),
|
| + ])
|
| + commenter_view = users_by_id[comment.user_id]
|
| + detail_url = framework_helpers.FormatAbsoluteURLForDomain(
|
| + hostport, issue.project_name, urls.ISSUE_DETAIL,
|
| + id=issue.local_id)
|
| + email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
|
| + group_reason_list, subject, body_for_non_members, body_for_members,
|
| + project, hostport, commenter_view, seq_num=comment.sequence,
|
| + detail_url=detail_url)
|
| +
|
| + return email_tasks
|
| +
|
| +
|
| +class NotifyBlockingChangeTask(NotifyTaskBase):
|
| + """JSON servlet that notifies appropriate users after a blocking change."""
|
| +
|
| + _EMAIL_TEMPLATE = 'tracker/issue-blocking-change-notification-email.ezt'
|
| +
|
| + def HandleRequest(self, mr):
|
| + """Process the task to notify users after an issue blocking change.
|
| +
|
| + Args:
|
| + mr: common information parsed from the HTTP request.
|
| +
|
| + Returns:
|
| + Results dictionary in JSON format which is useful just for debugging.
|
| + The main goal is the side-effect of sending emails.
|
| + """
|
| + project_id = mr.specified_project_id
|
| + if project_id is None:
|
| + return {
|
| + 'params': {},
|
| + 'notified': [],
|
| + 'message': 'Cannot proceed without a valid project ID.',
|
| + }
|
| + commenter_id = mr.GetPositiveIntParam('commenter_id')
|
| + omit_ids = [commenter_id]
|
| + hostport = mr.GetParam('hostport')
|
| + delta_blocker_iids = mr.GetIntListParam('delta_blocker_iids')
|
| + send_email = bool(mr.GetIntParam('send_email'))
|
| + params = dict(
|
| + project_id=project_id, local_id=mr.local_id, commenter_id=commenter_id,
|
| + hostport=hostport, delta_blocker_iids=delta_blocker_iids,
|
| + omit_ids=omit_ids, send_email=send_email)
|
| +
|
| + logging.info('blocking change params are %r', params)
|
| + issue = self.services.issue.GetIssueByLocalID(
|
| + mr.cnxn, project_id, mr.local_id)
|
| + if issue.is_spam:
|
| + return {
|
| + 'params': params,
|
| + 'notified': [],
|
| + }
|
| +
|
| + upstream_issues = self.services.issue.GetIssues(
|
| + mr.cnxn, delta_blocker_iids)
|
| + logging.info('updating ids %r', [up.local_id for up in upstream_issues])
|
| + upstream_projects = tracker_helpers.GetAllIssueProjects(
|
| + mr.cnxn, upstream_issues, self.services.project)
|
| + upstream_configs = self.services.config.GetProjectConfigs(
|
| + mr.cnxn, upstream_projects.keys())
|
| +
|
| + users_by_id = framework_views.MakeAllUserViews(
|
| + mr.cnxn, self.services.user, [commenter_id])
|
| + commenter_view = users_by_id[commenter_id]
|
| +
|
| + tasks = []
|
| + if send_email:
|
| + for upstream_issue in upstream_issues:
|
| + one_issue_email_tasks = self._ProcessUpstreamIssue(
|
| + mr.cnxn, upstream_issue,
|
| + upstream_projects[upstream_issue.project_id],
|
| + upstream_configs[upstream_issue.project_id],
|
| + issue, omit_ids, hostport, commenter_view)
|
| + tasks.extend(one_issue_email_tasks)
|
| +
|
| + notified = AddAllEmailTasks(tasks)
|
| +
|
| + return {
|
| + 'params': params,
|
| + 'notified': notified,
|
| + }
|
| +
|
| + def _ProcessUpstreamIssue(
|
| + self, cnxn, upstream_issue, upstream_project, upstream_config,
|
| + issue, omit_ids, hostport, commenter_view):
|
| + """Compute notifications for one upstream issue that is now blocking."""
|
| + upstream_detail_url = framework_helpers.FormatAbsoluteURLForDomain(
|
| + hostport, upstream_issue.project_name, urls.ISSUE_DETAIL,
|
| + id=upstream_issue.local_id)
|
| + logging.info('upstream_detail_url = %r', upstream_detail_url)
|
| + detail_url = framework_helpers.FormatAbsoluteURLForDomain(
|
| + hostport, issue.project_name, urls.ISSUE_DETAIL,
|
| + id=issue.local_id)
|
| +
|
| + # Only issues that any contributor could view are sent to mailing lists.
|
| + contributor_could_view = permissions.CanViewIssue(
|
| + set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
|
| + upstream_project, upstream_issue)
|
| +
|
| + # Now construct the e-mail to send
|
| +
|
| + # Note: we purposely do not notify users who starred an issue
|
| + # about changes in blocking.
|
| + users_by_id = framework_views.MakeAllUserViews(
|
| + cnxn, self.services.user,
|
| + tracker_bizobj.UsersInvolvedInIssues([upstream_issue]), omit_ids)
|
| +
|
| + is_blocking = upstream_issue.issue_id in issue.blocked_on_iids
|
| +
|
| + email_data = {
|
| + 'issue': tracker_views.IssueView(
|
| + upstream_issue, users_by_id, upstream_config),
|
| + 'summary': upstream_issue.summary,
|
| + 'detail_url': upstream_detail_url,
|
| + 'is_blocking': ezt.boolean(is_blocking),
|
| + 'downstream_issue_ref': tracker_bizobj.FormatIssueRef(
|
| + (None, issue.local_id)),
|
| + 'downstream_issue_url': detail_url,
|
| + }
|
| +
|
| + # TODO(jrobbins): Generate two versions of email body: members
|
| + # vesion has other member full email addresses exposed. But, don't
|
| + # expose too many as we iterate through upstream projects.
|
| + body = self.email_template.GetResponse(email_data)
|
| +
|
| + # Just use "Re:", not Message-Id and References because a blocking
|
| + # notification is not a comment on the issue.
|
| + subject = 'Re: Issue %d in %s: %s' % (
|
| + upstream_issue.local_id, upstream_issue.project_name,
|
| + upstream_issue.summary)
|
| +
|
| + omit_addrs = {users_by_id[omit_id].email for omit_id in omit_ids}
|
| +
|
| + # Get the transitive set of owners and Cc'd users, and their UserView's.
|
| + direct_owners, trans_owners = self.services.usergroup.ExpandAnyUserGroups(
|
| + cnxn, [tracker_bizobj.GetOwnerId(upstream_issue)])
|
| + direct_ccs, trans_ccs = self.services.usergroup.ExpandAnyUserGroups(
|
| + cnxn, list(upstream_issue.cc_ids))
|
| + # TODO(jrobbins): This will say that the user was cc'd by a rule when it
|
| + # was really added to the derived_cc_ids by a component.
|
| + der_direct_ccs, der_transitive_ccs = (
|
| + self.services.usergroup.ExpandAnyUserGroups(
|
| + cnxn, list(upstream_issue.derived_cc_ids)))
|
| + # direct owners and Ccs are already in users_by_id
|
| + users_by_id.update(framework_views.MakeAllUserViews(
|
| + cnxn, self.services.user, trans_owners, trans_ccs, der_transitive_ccs))
|
| +
|
| + owner_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList(
|
| + cnxn, direct_owners + trans_owners, upstream_project, upstream_issue,
|
| + self.services, omit_addrs, users_by_id)
|
| + cc_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList(
|
| + cnxn, direct_ccs + trans_ccs, upstream_project, upstream_issue,
|
| + self.services, omit_addrs, users_by_id)
|
| + der_cc_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList(
|
| + cnxn, der_direct_ccs + der_transitive_ccs, upstream_project,
|
| + upstream_issue, self.services, omit_addrs, users_by_id)
|
| + sub_addr_perm_list = _GetSubscribersAddrPermList(
|
| + cnxn, self.services, upstream_issue, upstream_project, upstream_config,
|
| + omit_addrs, users_by_id)
|
| +
|
| + issue_notify_addr_list = notify_helpers.ComputeIssueNotificationAddrList(
|
| + upstream_issue, omit_addrs)
|
| + proj_notify_addr_list = notify_helpers.ComputeProjectNotificationAddrList(
|
| + upstream_project, contributor_could_view, omit_addrs)
|
| +
|
| + # Give each user a bullet-list of all the reasons that apply for that user.
|
| + group_reason_list = [
|
| + (owner_addr_perm_list, 'You are the owner of the issue'),
|
| + (cc_addr_perm_list, 'You were specifically CC\'d on the issue'),
|
| + (der_cc_addr_perm_list, 'A rule CC\'d you on the issue'),
|
| + ]
|
| + group_reason_list.extend(notify_helpers.ComputeComponentFieldAddrPerms(
|
| + cnxn, upstream_config, upstream_issue, upstream_project, self.services,
|
| + omit_addrs, users_by_id))
|
| + group_reason_list.extend(notify_helpers.ComputeCustomFieldAddrPerms(
|
| + cnxn, upstream_config, upstream_issue, upstream_project, self.services,
|
| + omit_addrs, users_by_id))
|
| + group_reason_list.extend([
|
| + # Starrers are not notified of blocking changes to reduce noise.
|
| + (sub_addr_perm_list, 'Your saved query matched the issue'),
|
| + (issue_notify_addr_list,
|
| + 'Project filter rules were setup to notify you'),
|
| + (proj_notify_addr_list,
|
| + 'The project was configured to send all issue notifications '
|
| + 'to this address'),
|
| + ])
|
| +
|
| + one_issue_email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
|
| + group_reason_list, subject, body, body, upstream_project, hostport,
|
| + commenter_view, detail_url=detail_url)
|
| +
|
| + return one_issue_email_tasks
|
| +
|
| +
|
| +class NotifyBulkChangeTask(NotifyTaskBase):
|
| + """JSON servlet that notifies appropriate users after a bulk edit."""
|
| +
|
| + _EMAIL_TEMPLATE = 'tracker/issue-bulk-change-notification-email.ezt'
|
| +
|
| + def HandleRequest(self, mr):
|
| + """Process the task to notify users after an issue blocking change.
|
| +
|
| + Args:
|
| + mr: common information parsed from the HTTP request.
|
| +
|
| + Returns:
|
| + Results dictionary in JSON format which is useful just for debugging.
|
| + The main goal is the side-effect of sending emails.
|
| + """
|
| + hostport = mr.GetParam('hostport')
|
| + project_id = mr.specified_project_id
|
| + if project_id is None:
|
| + return {
|
| + 'params': {},
|
| + 'notified': [],
|
| + 'message': 'Cannot proceed without a valid project ID.',
|
| + }
|
| +
|
| + local_ids = mr.local_id_list
|
| + old_owner_ids = mr.GetIntListParam('old_owner_ids')
|
| + comment_text = mr.GetParam('comment_text')
|
| + commenter_id = mr.GetPositiveIntParam('commenter_id')
|
| + amendments = mr.GetParam('amendments')
|
| + send_email = bool(mr.GetIntParam('send_email'))
|
| + params = dict(
|
| + project_id=project_id, local_ids=mr.local_id_list,
|
| + commenter_id=commenter_id, hostport=hostport,
|
| + old_owner_ids=old_owner_ids, comment_text=comment_text,
|
| + send_email=send_email, amendments=amendments)
|
| +
|
| + logging.info('bulk edit params are %r', params)
|
| + # TODO(jrobbins): For cross-project bulk edits, prefetch all relevant
|
| + # projects and configs and pass a dict of them to subroutines.
|
| + project = self.services.project.GetProject(mr.cnxn, project_id)
|
| + config = self.services.config.GetProjectConfig(mr.cnxn, project_id)
|
| + issues = self.services.issue.GetIssuesByLocalIDs(
|
| + mr.cnxn, project_id, local_ids)
|
| + issues = [issue for issue in issues if not issue.is_spam]
|
| + anon_perms = permissions.GetPermissions(None, set(), project)
|
| +
|
| + users_by_id = framework_views.MakeAllUserViews(
|
| + mr.cnxn, self.services.user, [commenter_id])
|
| + ids_in_issues = {}
|
| + starrers = {}
|
| +
|
| + non_private_issues = []
|
| + for issue, old_owner_id in zip(issues, old_owner_ids):
|
| + # TODO(jrobbins): use issue_id consistently rather than local_id.
|
| + starrers[issue.local_id] = self.services.issue_star.LookupItemStarrers(
|
| + mr.cnxn, issue.issue_id)
|
| + named_ids = set() # users named in user-value fields that notify.
|
| + for fd in config.field_defs:
|
| + named_ids.update(notify_helpers.ComputeNamedUserIDsToNotify(issue, fd))
|
| + direct, indirect = self.services.usergroup.ExpandAnyUserGroups(
|
| + mr.cnxn, list(issue.cc_ids) + list(issue.derived_cc_ids) +
|
| + [issue.owner_id, old_owner_id, issue.derived_owner_id] +
|
| + list(named_ids))
|
| + ids_in_issues[issue.local_id] = set(starrers[issue.local_id])
|
| + ids_in_issues[issue.local_id].update(direct)
|
| + ids_in_issues[issue.local_id].update(indirect)
|
| + ids_in_issue_needing_views = (
|
| + ids_in_issues[issue.local_id] |
|
| + tracker_bizobj.UsersInvolvedInIssues([issue]))
|
| + new_ids_in_issue = [user_id for user_id in ids_in_issue_needing_views
|
| + if user_id not in users_by_id]
|
| + users_by_id.update(
|
| + framework_views.MakeAllUserViews(
|
| + mr.cnxn, self.services.user, new_ids_in_issue))
|
| +
|
| + anon_can_view = permissions.CanViewIssue(
|
| + set(), anon_perms, project, issue)
|
| + if anon_can_view:
|
| + non_private_issues.append(issue)
|
| +
|
| + commenter_view = users_by_id[commenter_id]
|
| + omit_addrs = {commenter_view.email}
|
| +
|
| + tasks = []
|
| + if send_email:
|
| + email_tasks = self._BulkEditEmailTasks(
|
| + mr.cnxn, issues, old_owner_ids, omit_addrs, project,
|
| + non_private_issues, users_by_id, ids_in_issues, starrers,
|
| + commenter_view, hostport, comment_text, amendments, config)
|
| + tasks = email_tasks
|
| +
|
| + notified = AddAllEmailTasks(tasks)
|
| + return {
|
| + 'params': params,
|
| + 'notified': notified,
|
| + }
|
| +
|
| + def _BulkEditEmailTasks(
|
| + self, cnxn, issues, old_owner_ids, omit_addrs, project,
|
| + non_private_issues, users_by_id, ids_in_issues, starrers,
|
| + commenter_view, hostport, comment_text, amendments, config):
|
| + """Generate Email PBs to notify interested users after a bulk edit."""
|
| + # 1. Get the user IDs of everyone who could be notified,
|
| + # and make all their user proxies. Also, build a dictionary
|
| + # of all the users to notify and the issues that they are
|
| + # interested in. Also, build a dictionary of additional email
|
| + # addresses to notify and the issues to notify them of.
|
| + users_by_id = {}
|
| + ids_to_notify_of_issue = {}
|
| + additional_addrs_to_notify_of_issue = collections.defaultdict(list)
|
| +
|
| + users_to_queries = notify_helpers.GetNonOmittedSubscriptions(
|
| + cnxn, self.services, [project.project_id], {})
|
| + config = self.services.config.GetProjectConfig(
|
| + cnxn, project.project_id)
|
| + for issue, old_owner_id in zip(issues, old_owner_ids):
|
| + issue_participants = set(
|
| + [tracker_bizobj.GetOwnerId(issue), old_owner_id] +
|
| + tracker_bizobj.GetCcIds(issue))
|
| + # users named in user-value fields that notify.
|
| + for fd in config.field_defs:
|
| + issue_participants.update(
|
| + notify_helpers.ComputeNamedUserIDsToNotify(issue, fd))
|
| + for user_id in ids_in_issues[issue.local_id]:
|
| + # TODO(jrobbins): implement batch GetUser() for speed.
|
| + if not user_id:
|
| + continue
|
| + auth = monorailrequest.AuthData.FromUserID(
|
| + cnxn, user_id, self.services)
|
| + if (auth.user_pb.notify_issue_change and
|
| + not auth.effective_ids.isdisjoint(issue_participants)):
|
| + ids_to_notify_of_issue.setdefault(user_id, []).append(issue)
|
| + elif (auth.user_pb.notify_starred_issue_change and
|
| + user_id in starrers[issue.local_id]):
|
| + # Skip users who have starred issues that they can no longer view.
|
| + starrer_perms = permissions.GetPermissions(
|
| + auth.user_pb, auth.effective_ids, project)
|
| + granted_perms = tracker_bizobj.GetGrantedPerms(
|
| + issue, auth.effective_ids, config)
|
| + starrer_can_view = permissions.CanViewIssue(
|
| + auth.effective_ids, starrer_perms, project, issue,
|
| + granted_perms=granted_perms)
|
| + if starrer_can_view:
|
| + ids_to_notify_of_issue.setdefault(user_id, []).append(issue)
|
| + logging.info(
|
| + 'ids_to_notify_of_issue[%s] = %s',
|
| + user_id,
|
| + [i.local_id for i in ids_to_notify_of_issue.get(user_id, [])])
|
| +
|
| + # Find all subscribers that should be notified.
|
| + subscribers_to_consider = notify_helpers.EvaluateSubscriptions(
|
| + cnxn, issue, users_to_queries, self.services, config)
|
| + for sub_id in subscribers_to_consider:
|
| + auth = monorailrequest.AuthData.FromUserID(cnxn, sub_id, self.services)
|
| + sub_perms = permissions.GetPermissions(
|
| + auth.user_pb, auth.effective_ids, project)
|
| + granted_perms = tracker_bizobj.GetGrantedPerms(
|
| + issue, auth.effective_ids, config)
|
| + sub_can_view = permissions.CanViewIssue(
|
| + auth.effective_ids, sub_perms, project, issue,
|
| + granted_perms=granted_perms)
|
| + if sub_can_view:
|
| + ids_to_notify_of_issue.setdefault(sub_id, []).append(issue)
|
| +
|
| + if issue in non_private_issues:
|
| + for notify_addr in issue.derived_notify_addrs:
|
| + additional_addrs_to_notify_of_issue[notify_addr].append(issue)
|
| +
|
| + # 2. Compose an email specifically for each user.
|
| + email_tasks = []
|
| + needed_user_view_ids = [uid for uid in ids_to_notify_of_issue
|
| + if uid not in users_by_id]
|
| + users_by_id.update(framework_views.MakeAllUserViews(
|
| + cnxn, self.services.user, needed_user_view_ids))
|
| + for user_id in ids_to_notify_of_issue:
|
| + if not user_id:
|
| + continue # Don't try to notify NO_USER_SPECIFIED
|
| + if users_by_id[user_id].email in omit_addrs:
|
| + logging.info('Omitting %s', user_id)
|
| + continue
|
| + user_issues = ids_to_notify_of_issue[user_id]
|
| + if not user_issues:
|
| + continue # user's prefs indicate they don't want these notifications
|
| + email = self._FormatBulkIssuesEmail(
|
| + users_by_id[user_id].email, user_issues, users_by_id,
|
| + commenter_view, hostport, comment_text, amendments, config, project)
|
| + email_tasks.append(email)
|
| + omit_addrs.add(users_by_id[user_id].email)
|
| + logging.info('about to bulk notify %s (%s) of %s',
|
| + users_by_id[user_id].email, user_id,
|
| + [issue.local_id for issue in user_issues])
|
| +
|
| + # 3. Compose one email to each notify_addr with all the issues that it
|
| + # is supossed to be notified about.
|
| + for addr, addr_issues in additional_addrs_to_notify_of_issue.iteritems():
|
| + email = self._FormatBulkIssuesEmail(
|
| + addr, addr_issues, users_by_id, commenter_view, hostport,
|
| + comment_text, amendments, config, project)
|
| + email_tasks.append(email)
|
| + omit_addrs.add(addr)
|
| + logging.info('about to bulk notify additional addr %s of %s',
|
| + addr, [addr_issue.local_id for addr_issue in addr_issues])
|
| +
|
| + # 4. Add in the project's issue_notify_address. This happens even if it
|
| + # is the same as the commenter's email address (which would be an unusual
|
| + # but valid project configuration). Only issues that any contributor could
|
| + # view are included in emails to the all-issue-activity mailing lists.
|
| + if (project.issue_notify_address
|
| + and project.issue_notify_address not in omit_addrs):
|
| + non_private_issues_live = []
|
| + for issue in issues:
|
| + contributor_could_view = permissions.CanViewIssue(
|
| + set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
|
| + project, issue)
|
| + if contributor_could_view:
|
| + non_private_issues_live.append(issue)
|
| +
|
| + if non_private_issues_live:
|
| + email = self._FormatBulkIssuesEmail(
|
| + project.issue_notify_address, non_private_issues_live,
|
| + users_by_id, commenter_view, hostport, comment_text, amendments,
|
| + config, project)
|
| + email_tasks.append(email)
|
| + omit_addrs.add(project.issue_notify_address)
|
| + logging.info('about to bulk notify all-issues %s of %s',
|
| + project.issue_notify_address,
|
| + [issue.local_id for issue in non_private_issues])
|
| +
|
| + return email_tasks
|
| +
|
| + def _FormatBulkIssuesEmail(
|
| + self, dest_email, issues, users_by_id, commenter_view,
|
| + hostport, comment_text, amendments, config, _project):
|
| + """Format an email to one user listing many issues."""
|
| + # TODO(jrobbins): Generate two versions of email body: members
|
| + # vesion has full email addresses exposed. And, use the full
|
| + # commenter email address in the From: line when sending to
|
| + # a member.
|
| + subject, body = self._FormatBulkIssues(
|
| + issues, users_by_id, commenter_view, hostport, comment_text,
|
| + amendments, config)
|
| +
|
| + from_addr = emailfmt.NoReplyAddress(commenter_view=commenter_view)
|
| + return dict(from_addr=from_addr, to=dest_email, subject=subject, body=body)
|
| +
|
| + def _FormatBulkIssues(
|
| + self, issues, users_by_id, commenter_view, hostport, comment_text,
|
| + amendments, config, body_type='email'):
|
| + """Format a subject and body for a bulk issue edit."""
|
| + assert body_type in ('email', 'feed')
|
| + project_name = issues[0].project_name
|
| +
|
| + issue_views = []
|
| + for issue in issues:
|
| + # TODO(jrobbins): choose config from dict of prefetched configs.
|
| + issue_views.append(tracker_views.IssueView(issue, users_by_id, config))
|
| +
|
| + email_data = {
|
| + 'hostport': hostport,
|
| + 'num_issues': len(issues),
|
| + 'issues': issue_views,
|
| + 'comment_text': comment_text,
|
| + 'commenter': commenter_view,
|
| + 'amendments': amendments,
|
| + 'body_type': body_type,
|
| + }
|
| +
|
| + if len(issues) == 1:
|
| + subject = 'issue %s in %s: %s' % (
|
| + issues[0].local_id, project_name, issues[0].summary)
|
| + # TODO(jrobbins): Look up the sequence number instead and treat this
|
| + # more like an individual change for email threading. For now, just
|
| + # add "Re:" because bulk edits are always replies.
|
| + subject = 'Re: ' + subject
|
| + else:
|
| + subject = '%d issues changed in %s' % (len(issues), project_name)
|
| +
|
| + body = self.email_template.GetResponse(email_data)
|
| +
|
| + return subject, body
|
| +
|
| +
|
| +class OutboundEmailTask(jsonfeed.InternalTask):
|
| + """JSON servlet that sends one email."""
|
| +
|
| + def HandleRequest(self, mr):
|
| + """Process the task to send one email message.
|
| +
|
| + Args:
|
| + mr: common information parsed from the HTTP request.
|
| +
|
| + Returns:
|
| + Results dictionary in JSON format which is useful just for debugging.
|
| + The main goal is the side-effect of sending emails.
|
| + """
|
| + # If running on a GAFYD domain, you must define an app alias on the
|
| + # Application Settings admin web page.
|
| + sender = mr.GetParam('from_addr')
|
| + reply_to = mr.GetParam('reply_to')
|
| + to = mr.GetParam('to')
|
| + if not to:
|
| + # Cannot proceed if we cannot create a valid EmailMessage.
|
| + return
|
| + references = mr.GetParam('references')
|
| + subject = mr.GetParam('subject')
|
| + body = mr.GetParam('body')
|
| + html_body = mr.GetParam('html_body')
|
| +
|
| + if settings.dev_mode:
|
| + to_format = settings.send_dev_email_to
|
| + else:
|
| + to_format = settings.send_all_email_to
|
| +
|
| + if to_format:
|
| + to_user, to_domain = to.split('@')
|
| + to = to_format % {'user': to_user, 'domain': to_domain}
|
| +
|
| + logging.info(
|
| + 'Email:\n sender: %s\n reply_to: %s\n to: %s\n references: %s\n '
|
| + 'subject: %s\n body: %s\n html body: %s',
|
| + sender, reply_to, to, references, subject, body, html_body)
|
| + message = mail.EmailMessage(
|
| + sender=sender, to=to, subject=subject, body=body)
|
| + if html_body:
|
| + message.html = html_body
|
| + if reply_to:
|
| + message.reply_to = reply_to
|
| + if references:
|
| + message.headers = {'References': references}
|
| + if settings.unit_test_mode:
|
| + logging.info('Sending message "%s" in test mode.', message.subject)
|
| + else:
|
| + message.send()
|
| +
|
| + return dict(
|
| + sender=sender, to=to, subject=subject, body=body, html_body=html_body,
|
| + reply_to=reply_to, references=references)
|
| +
|
| +
|
| +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 = notify_helpers.GetNonOmittedSubscriptions(
|
| + cnxn, services, [project.project_id], omit_addrs)
|
| + # TODO(jrobbins): need to pass through the user_id to use for "me".
|
| + subscribers_to_notify = notify_helpers.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 = notify_helpers.ComputeIssueChangeAddressPermList(
|
| + cnxn, subscribers_to_notify, project, issue, services, omit_addrs,
|
| + users_by_id, pref_check_function=lambda *args: True)
|
| +
|
| + return sub_addr_perm_list
|
|
|