OLD | NEW |
(Empty) | |
| 1 # Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 # Use of this source code is govered by a BSD-style |
| 3 # license that can be found in the LICENSE file or at |
| 4 # https://developers.google.com/open-source/licenses/bsd |
| 5 |
| 6 """Task handlers for email notifications of issue changes. |
| 7 |
| 8 Email notificatons are sent when an issue changes, an issue that is blocking |
| 9 another issue changes, or a bulk edit is done. The users notified include |
| 10 the project-wide mailing list, issue owners, cc'd users, starrers, |
| 11 also-notify addresses, and users who have saved queries with email notification |
| 12 set. |
| 13 """ |
| 14 |
| 15 import collections |
| 16 import logging |
| 17 |
| 18 from third_party import ezt |
| 19 |
| 20 from google.appengine.api import mail |
| 21 from google.appengine.api import taskqueue |
| 22 |
| 23 import settings |
| 24 from features import autolink |
| 25 from features import notify_helpers |
| 26 from framework import emailfmt |
| 27 from framework import framework_bizobj |
| 28 from framework import framework_constants |
| 29 from framework import framework_helpers |
| 30 from framework import framework_views |
| 31 from framework import jsonfeed |
| 32 from framework import monorailrequest |
| 33 from framework import permissions |
| 34 from framework import template_helpers |
| 35 from framework import urls |
| 36 from tracker import component_helpers |
| 37 from tracker import tracker_bizobj |
| 38 from tracker import tracker_helpers |
| 39 from tracker import tracker_views |
| 40 |
| 41 |
| 42 TEMPLATE_PATH = framework_constants.TEMPLATE_PATH |
| 43 |
| 44 |
| 45 def PrepareAndSendIssueChangeNotification( |
| 46 project_id, local_id, hostport, commenter_id, seq_num, send_email=True, |
| 47 old_owner_id=framework_constants.NO_USER_SPECIFIED): |
| 48 """Create a task to notify users that an issue has changed. |
| 49 |
| 50 Args: |
| 51 project_id: int ID of the project containing the changed issue. |
| 52 local_id: Issue number for the issue that was updated and saved. |
| 53 hostport: string domain name and port number from the HTTP request. |
| 54 commenter_id: int user ID of the user who made the comment. |
| 55 seq_num: int index into the comments of the new comment. |
| 56 send_email: True if email notifications should be sent. |
| 57 old_owner_id: optional user ID of owner before the current change took |
| 58 effect. He/she will also be notified. |
| 59 |
| 60 Returns nothing. |
| 61 """ |
| 62 params = dict( |
| 63 project_id=project_id, id=local_id, commenter_id=commenter_id, |
| 64 seq=seq_num, hostport=hostport, |
| 65 old_owner_id=old_owner_id, send_email=int(send_email)) |
| 66 logging.info('adding notify task with params %r', params) |
| 67 taskqueue.add(url=urls.NOTIFY_ISSUE_CHANGE_TASK + '.do', params=params) |
| 68 |
| 69 |
| 70 def PrepareAndSendIssueBlockingNotification( |
| 71 project_id, hostport, local_id, delta_blocker_iids, |
| 72 commenter_id, send_email=True): |
| 73 """Create a task to follow up on an issue blocked_on change.""" |
| 74 if not delta_blocker_iids: |
| 75 return # No notification is needed |
| 76 |
| 77 params = dict( |
| 78 project_id=project_id, id=local_id, commenter_id=commenter_id, |
| 79 hostport=hostport, send_email=int(send_email), |
| 80 delta_blocker_iids=','.join(str(iid) for iid in delta_blocker_iids)) |
| 81 |
| 82 logging.info('adding blocking task with params %r', params) |
| 83 taskqueue.add(url=urls.NOTIFY_BLOCKING_CHANGE_TASK + '.do', params=params) |
| 84 |
| 85 |
| 86 def SendIssueBulkChangeNotification( |
| 87 hostport, project_id, local_ids, old_owner_ids, |
| 88 comment_text, commenter_id, amendments, send_email, users_by_id): |
| 89 """Create a task to follow up on an issue blocked_on change.""" |
| 90 amendment_lines = [] |
| 91 for up in amendments: |
| 92 line = ' %s: %s' % ( |
| 93 tracker_bizobj.GetAmendmentFieldName(up), |
| 94 tracker_bizobj.AmendmentString(up, users_by_id)) |
| 95 if line not in amendment_lines: |
| 96 amendment_lines.append(line) |
| 97 |
| 98 params = dict( |
| 99 project_id=project_id, commenter_id=commenter_id, |
| 100 hostport=hostport, send_email=int(send_email), |
| 101 ids=','.join(str(lid) for lid in local_ids), |
| 102 old_owner_ids=','.join(str(uid) for uid in old_owner_ids), |
| 103 comment_text=comment_text, amendments='\n'.join(amendment_lines)) |
| 104 |
| 105 logging.info('adding bulk task with params %r', params) |
| 106 taskqueue.add(url=urls.NOTIFY_BULK_CHANGE_TASK + '.do', params=params) |
| 107 |
| 108 |
| 109 def _EnqueueOutboundEmail(message_dict): |
| 110 """Create a task to send one email message, all fields are in the dict. |
| 111 |
| 112 We use a separate task for each outbound email to isolate errors. |
| 113 |
| 114 Args: |
| 115 message_dict: dict with all needed info for the task. |
| 116 """ |
| 117 logging.info('Queuing an email task with params %r', message_dict) |
| 118 taskqueue.add( |
| 119 url=urls.OUTBOUND_EMAIL_TASK + '.do', params=message_dict, |
| 120 queue_name='outboundemail') |
| 121 |
| 122 |
| 123 def AddAllEmailTasks(tasks): |
| 124 """Add one GAE task for each email to be sent.""" |
| 125 notified = [] |
| 126 for task in tasks: |
| 127 _EnqueueOutboundEmail(task) |
| 128 notified.append(task['to']) |
| 129 |
| 130 return notified |
| 131 |
| 132 |
| 133 class NotifyTaskBase(jsonfeed.InternalTask): |
| 134 """Abstract base class for notification task handler.""" |
| 135 |
| 136 _EMAIL_TEMPLATE = None # Subclasses must override this. |
| 137 |
| 138 CHECK_SECURITY_TOKEN = False |
| 139 |
| 140 def __init__(self, *args, **kwargs): |
| 141 super(NotifyTaskBase, self).__init__(*args, **kwargs) |
| 142 |
| 143 if not self._EMAIL_TEMPLATE: |
| 144 raise Exception('Subclasses must override _EMAIL_TEMPLATE.' |
| 145 ' This class must not be called directly.') |
| 146 # We use FORMAT_RAW for emails because they are plain text, not HTML. |
| 147 # TODO(jrobbins): consider sending HTML formatted emails someday. |
| 148 self.email_template = template_helpers.MonorailTemplate( |
| 149 TEMPLATE_PATH + self._EMAIL_TEMPLATE, |
| 150 compress_whitespace=False, base_format=ezt.FORMAT_RAW) |
| 151 |
| 152 |
| 153 class NotifyIssueChangeTask(NotifyTaskBase): |
| 154 """JSON servlet that notifies appropriate users after an issue change.""" |
| 155 |
| 156 _EMAIL_TEMPLATE = 'tracker/issue-change-notification-email.ezt' |
| 157 |
| 158 def HandleRequest(self, mr): |
| 159 """Process the task to notify users after an issue change. |
| 160 |
| 161 Args: |
| 162 mr: common information parsed from the HTTP request. |
| 163 |
| 164 Returns: |
| 165 Results dictionary in JSON format which is useful just for debugging. |
| 166 The main goal is the side-effect of sending emails. |
| 167 """ |
| 168 project_id = mr.specified_project_id |
| 169 if project_id is None: |
| 170 return { |
| 171 'params': {}, |
| 172 'notified': [], |
| 173 'message': 'Cannot proceed without a valid project ID.', |
| 174 } |
| 175 commenter_id = mr.GetPositiveIntParam('commenter_id') |
| 176 seq_num = mr.seq |
| 177 omit_ids = [commenter_id] |
| 178 hostport = mr.GetParam('hostport') |
| 179 old_owner_id = mr.GetPositiveIntParam('old_owner_id') |
| 180 send_email = bool(mr.GetIntParam('send_email')) |
| 181 params = dict( |
| 182 project_id=project_id, local_id=mr.local_id, commenter_id=commenter_id, |
| 183 seq_num=seq_num, hostport=hostport, old_owner_id=old_owner_id, |
| 184 omit_ids=omit_ids, send_email=send_email) |
| 185 |
| 186 logging.info('issue change params are %r', params) |
| 187 project = self.services.project.GetProject(mr.cnxn, project_id) |
| 188 config = self.services.config.GetProjectConfig(mr.cnxn, project_id) |
| 189 issue = self.services.issue.GetIssueByLocalID( |
| 190 mr.cnxn, project_id, mr.local_id) |
| 191 |
| 192 if issue.is_spam: |
| 193 # Don't send email for spam issues. |
| 194 return { |
| 195 'params': params, |
| 196 'notified': [], |
| 197 } |
| 198 |
| 199 all_comments = self.services.issue.GetCommentsForIssue( |
| 200 mr.cnxn, issue.issue_id) |
| 201 comment = all_comments[seq_num] |
| 202 |
| 203 # Only issues that any contributor could view sent to mailing lists. |
| 204 contributor_could_view = permissions.CanViewIssue( |
| 205 set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET, |
| 206 project, issue) |
| 207 starrer_ids = self.services.issue_star.LookupItemStarrers( |
| 208 mr.cnxn, issue.issue_id) |
| 209 users_by_id = framework_views.MakeAllUserViews( |
| 210 mr.cnxn, self.services.user, |
| 211 tracker_bizobj.UsersInvolvedInIssues([issue]), [old_owner_id], |
| 212 tracker_bizobj.UsersInvolvedInComment(comment), |
| 213 issue.cc_ids, issue.derived_cc_ids, starrer_ids, omit_ids) |
| 214 |
| 215 # Make followup tasks to send emails |
| 216 tasks = [] |
| 217 if send_email: |
| 218 tasks = self._MakeEmailTasks( |
| 219 mr.cnxn, project, issue, config, old_owner_id, users_by_id, |
| 220 all_comments, comment, starrer_ids, contributor_could_view, |
| 221 hostport, omit_ids) |
| 222 |
| 223 notified = AddAllEmailTasks(tasks) |
| 224 |
| 225 return { |
| 226 'params': params, |
| 227 'notified': notified, |
| 228 } |
| 229 |
| 230 def _MakeEmailTasks( |
| 231 self, cnxn, project, issue, config, old_owner_id, |
| 232 users_by_id, all_comments, comment, starrer_ids, |
| 233 contributor_could_view, hostport, omit_ids): |
| 234 """Formulate emails to be sent.""" |
| 235 detail_url = framework_helpers.IssueCommentURL( |
| 236 hostport, project, issue.local_id, seq_num=comment.sequence) |
| 237 |
| 238 # TODO(jrobbins): avoid the need to make a MonorailRequest object. |
| 239 mr = monorailrequest.MonorailRequest() |
| 240 mr.project_name = project.project_name |
| 241 mr.project = project |
| 242 |
| 243 # We do not autolink in the emails, so just use an empty |
| 244 # registry of autolink rules. |
| 245 # TODO(jrobbins): offer users an HTML email option w/ autolinks. |
| 246 autolinker = autolink.Autolink() |
| 247 |
| 248 email_data = { |
| 249 # Pass open_related and closed_related into this method and to |
| 250 # the issue view so that we can show it on new issue email. |
| 251 'issue': tracker_views.IssueView(issue, users_by_id, config), |
| 252 'summary': issue.summary, |
| 253 'comment': tracker_views.IssueCommentView( |
| 254 project.project_name, comment, users_by_id, |
| 255 autolinker, {}, mr, issue), |
| 256 'comment_text': comment.content, |
| 257 'detail_url': detail_url, |
| 258 } |
| 259 |
| 260 # Generate two versions of email body: members version has all |
| 261 # full email addresses exposed. |
| 262 body_for_non_members = self.email_template.GetResponse(email_data) |
| 263 framework_views.RevealAllEmails(users_by_id) |
| 264 email_data['comment'] = tracker_views.IssueCommentView( |
| 265 project.project_name, comment, users_by_id, |
| 266 autolinker, {}, mr, issue) |
| 267 body_for_members = self.email_template.GetResponse(email_data) |
| 268 |
| 269 subject = 'Issue %d in %s: %s' % ( |
| 270 issue.local_id, project.project_name, issue.summary) |
| 271 |
| 272 commenter_email = users_by_id[comment.user_id].email |
| 273 omit_addrs = set([commenter_email] + |
| 274 [users_by_id[omit_id].email for omit_id in omit_ids]) |
| 275 |
| 276 auth = monorailrequest.AuthData.FromUserID( |
| 277 cnxn, comment.user_id, self.services) |
| 278 commenter_in_project = framework_bizobj.UserIsInProject( |
| 279 project, auth.effective_ids) |
| 280 noisy = tracker_helpers.IsNoisy(len(all_comments) - 1, len(starrer_ids)) |
| 281 |
| 282 # Get the transitive set of owners and Cc'd users, and their proxies. |
| 283 reporter = [issue.reporter_id] if issue.reporter_id in starrer_ids else [] |
| 284 old_direct_owners, old_transitive_owners = ( |
| 285 self.services.usergroup.ExpandAnyUserGroups(cnxn, [old_owner_id])) |
| 286 direct_owners, transitive_owners = ( |
| 287 self.services.usergroup.ExpandAnyUserGroups(cnxn, [issue.owner_id])) |
| 288 der_direct_owners, der_transitive_owners = ( |
| 289 self.services.usergroup.ExpandAnyUserGroups( |
| 290 cnxn, [issue.derived_owner_id])) |
| 291 direct_comp, trans_comp = self.services.usergroup.ExpandAnyUserGroups( |
| 292 cnxn, component_helpers.GetComponentCcIDs(issue, config)) |
| 293 direct_ccs, transitive_ccs = self.services.usergroup.ExpandAnyUserGroups( |
| 294 cnxn, list(issue.cc_ids)) |
| 295 # TODO(jrobbins): This will say that the user was cc'd by a rule when it |
| 296 # was really added to the derived_cc_ids by a component. |
| 297 der_direct_ccs, der_transitive_ccs = ( |
| 298 self.services.usergroup.ExpandAnyUserGroups( |
| 299 cnxn, list(issue.derived_cc_ids))) |
| 300 users_by_id.update(framework_views.MakeAllUserViews( |
| 301 cnxn, self.services.user, transitive_owners, der_transitive_owners, |
| 302 direct_comp, trans_comp, transitive_ccs, der_transitive_ccs)) |
| 303 |
| 304 # Notify interested people according to the reason for their interest: |
| 305 # owners, component auto-cc'd users, cc'd users, starrers, and |
| 306 # other notification addresses. |
| 307 reporter_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList( |
| 308 cnxn, reporter, project, issue, self.services, omit_addrs, users_by_id) |
| 309 owner_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList( |
| 310 cnxn, direct_owners + transitive_owners, project, issue, |
| 311 self.services, omit_addrs, users_by_id) |
| 312 old_owner_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList( |
| 313 cnxn, old_direct_owners + old_transitive_owners, project, issue, |
| 314 self.services, omit_addrs, users_by_id) |
| 315 owner_addr_perm_set = set(owner_addr_perm_list) |
| 316 old_owner_addr_perm_list = [ap for ap in old_owner_addr_perm_list |
| 317 if ap not in owner_addr_perm_set] |
| 318 der_owner_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList( |
| 319 cnxn, der_direct_owners + der_transitive_owners, project, issue, |
| 320 self.services, omit_addrs, users_by_id) |
| 321 cc_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList( |
| 322 cnxn, direct_ccs + transitive_ccs, project, issue, |
| 323 self.services, omit_addrs, users_by_id) |
| 324 der_cc_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList( |
| 325 cnxn, der_direct_ccs + der_transitive_ccs, project, issue, |
| 326 self.services, omit_addrs, users_by_id) |
| 327 |
| 328 starrer_addr_perm_list = [] |
| 329 sub_addr_perm_list = [] |
| 330 if not noisy or commenter_in_project: |
| 331 # Avoid an OOM by only notifying a number of starrers that we can handle. |
| 332 # And, we really should limit the number of emails that we send anyway. |
| 333 max_starrers = settings.max_starrers_to_notify |
| 334 starrer_ids = starrer_ids[-max_starrers:] |
| 335 # Note: starrers can never be user groups. |
| 336 starrer_addr_perm_list = ( |
| 337 notify_helpers.ComputeIssueChangeAddressPermList( |
| 338 cnxn, starrer_ids, project, issue, |
| 339 self.services, omit_addrs, users_by_id, |
| 340 pref_check_function=lambda u: u.notify_starred_issue_change)) |
| 341 |
| 342 sub_addr_perm_list = _GetSubscribersAddrPermList( |
| 343 cnxn, self.services, issue, project, config, omit_addrs, |
| 344 users_by_id) |
| 345 |
| 346 # Get the list of addresses to notify based on filter rules. |
| 347 issue_notify_addr_list = notify_helpers.ComputeIssueNotificationAddrList( |
| 348 issue, omit_addrs) |
| 349 # Get the list of addresses to notify based on project settings. |
| 350 proj_notify_addr_list = notify_helpers.ComputeProjectNotificationAddrList( |
| 351 project, contributor_could_view, omit_addrs) |
| 352 |
| 353 # Give each user a bullet-list of all the reasons that apply for that user. |
| 354 group_reason_list = [ |
| 355 (reporter_addr_perm_list, 'You reported this issue'), |
| 356 (owner_addr_perm_list, 'You are the owner of the issue'), |
| 357 (old_owner_addr_perm_list, |
| 358 'You were the issue owner before this change'), |
| 359 (der_owner_addr_perm_list, 'A rule made you owner of the issue'), |
| 360 (cc_addr_perm_list, 'You were specifically CC\'d on the issue'), |
| 361 (der_cc_addr_perm_list, 'A rule CC\'d you on the issue'), |
| 362 ] |
| 363 group_reason_list.extend(notify_helpers.ComputeComponentFieldAddrPerms( |
| 364 cnxn, config, issue, project, self.services, omit_addrs, |
| 365 users_by_id)) |
| 366 group_reason_list.extend(notify_helpers.ComputeCustomFieldAddrPerms( |
| 367 cnxn, config, issue, project, self.services, omit_addrs, |
| 368 users_by_id)) |
| 369 group_reason_list.extend([ |
| 370 (starrer_addr_perm_list, 'You starred the issue'), |
| 371 (sub_addr_perm_list, 'Your saved query matched the issue'), |
| 372 (issue_notify_addr_list, |
| 373 'A rule was set up to notify you'), |
| 374 (proj_notify_addr_list, |
| 375 'The project was configured to send all issue notifications ' |
| 376 'to this address'), |
| 377 ]) |
| 378 commenter_view = users_by_id[comment.user_id] |
| 379 detail_url = framework_helpers.FormatAbsoluteURLForDomain( |
| 380 hostport, issue.project_name, urls.ISSUE_DETAIL, |
| 381 id=issue.local_id) |
| 382 email_tasks = notify_helpers.MakeBulletedEmailWorkItems( |
| 383 group_reason_list, subject, body_for_non_members, body_for_members, |
| 384 project, hostport, commenter_view, seq_num=comment.sequence, |
| 385 detail_url=detail_url) |
| 386 |
| 387 return email_tasks |
| 388 |
| 389 |
| 390 class NotifyBlockingChangeTask(NotifyTaskBase): |
| 391 """JSON servlet that notifies appropriate users after a blocking change.""" |
| 392 |
| 393 _EMAIL_TEMPLATE = 'tracker/issue-blocking-change-notification-email.ezt' |
| 394 |
| 395 def HandleRequest(self, mr): |
| 396 """Process the task to notify users after an issue blocking change. |
| 397 |
| 398 Args: |
| 399 mr: common information parsed from the HTTP request. |
| 400 |
| 401 Returns: |
| 402 Results dictionary in JSON format which is useful just for debugging. |
| 403 The main goal is the side-effect of sending emails. |
| 404 """ |
| 405 project_id = mr.specified_project_id |
| 406 if project_id is None: |
| 407 return { |
| 408 'params': {}, |
| 409 'notified': [], |
| 410 'message': 'Cannot proceed without a valid project ID.', |
| 411 } |
| 412 commenter_id = mr.GetPositiveIntParam('commenter_id') |
| 413 omit_ids = [commenter_id] |
| 414 hostport = mr.GetParam('hostport') |
| 415 delta_blocker_iids = mr.GetIntListParam('delta_blocker_iids') |
| 416 send_email = bool(mr.GetIntParam('send_email')) |
| 417 params = dict( |
| 418 project_id=project_id, local_id=mr.local_id, commenter_id=commenter_id, |
| 419 hostport=hostport, delta_blocker_iids=delta_blocker_iids, |
| 420 omit_ids=omit_ids, send_email=send_email) |
| 421 |
| 422 logging.info('blocking change params are %r', params) |
| 423 issue = self.services.issue.GetIssueByLocalID( |
| 424 mr.cnxn, project_id, mr.local_id) |
| 425 if issue.is_spam: |
| 426 return { |
| 427 'params': params, |
| 428 'notified': [], |
| 429 } |
| 430 |
| 431 upstream_issues = self.services.issue.GetIssues( |
| 432 mr.cnxn, delta_blocker_iids) |
| 433 logging.info('updating ids %r', [up.local_id for up in upstream_issues]) |
| 434 upstream_projects = tracker_helpers.GetAllIssueProjects( |
| 435 mr.cnxn, upstream_issues, self.services.project) |
| 436 upstream_configs = self.services.config.GetProjectConfigs( |
| 437 mr.cnxn, upstream_projects.keys()) |
| 438 |
| 439 users_by_id = framework_views.MakeAllUserViews( |
| 440 mr.cnxn, self.services.user, [commenter_id]) |
| 441 commenter_view = users_by_id[commenter_id] |
| 442 |
| 443 tasks = [] |
| 444 if send_email: |
| 445 for upstream_issue in upstream_issues: |
| 446 one_issue_email_tasks = self._ProcessUpstreamIssue( |
| 447 mr.cnxn, upstream_issue, |
| 448 upstream_projects[upstream_issue.project_id], |
| 449 upstream_configs[upstream_issue.project_id], |
| 450 issue, omit_ids, hostport, commenter_view) |
| 451 tasks.extend(one_issue_email_tasks) |
| 452 |
| 453 notified = AddAllEmailTasks(tasks) |
| 454 |
| 455 return { |
| 456 'params': params, |
| 457 'notified': notified, |
| 458 } |
| 459 |
| 460 def _ProcessUpstreamIssue( |
| 461 self, cnxn, upstream_issue, upstream_project, upstream_config, |
| 462 issue, omit_ids, hostport, commenter_view): |
| 463 """Compute notifications for one upstream issue that is now blocking.""" |
| 464 upstream_detail_url = framework_helpers.FormatAbsoluteURLForDomain( |
| 465 hostport, upstream_issue.project_name, urls.ISSUE_DETAIL, |
| 466 id=upstream_issue.local_id) |
| 467 logging.info('upstream_detail_url = %r', upstream_detail_url) |
| 468 detail_url = framework_helpers.FormatAbsoluteURLForDomain( |
| 469 hostport, issue.project_name, urls.ISSUE_DETAIL, |
| 470 id=issue.local_id) |
| 471 |
| 472 # Only issues that any contributor could view are sent to mailing lists. |
| 473 contributor_could_view = permissions.CanViewIssue( |
| 474 set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET, |
| 475 upstream_project, upstream_issue) |
| 476 |
| 477 # Now construct the e-mail to send |
| 478 |
| 479 # Note: we purposely do not notify users who starred an issue |
| 480 # about changes in blocking. |
| 481 users_by_id = framework_views.MakeAllUserViews( |
| 482 cnxn, self.services.user, |
| 483 tracker_bizobj.UsersInvolvedInIssues([upstream_issue]), omit_ids) |
| 484 |
| 485 is_blocking = upstream_issue.issue_id in issue.blocked_on_iids |
| 486 |
| 487 email_data = { |
| 488 'issue': tracker_views.IssueView( |
| 489 upstream_issue, users_by_id, upstream_config), |
| 490 'summary': upstream_issue.summary, |
| 491 'detail_url': upstream_detail_url, |
| 492 'is_blocking': ezt.boolean(is_blocking), |
| 493 'downstream_issue_ref': tracker_bizobj.FormatIssueRef( |
| 494 (None, issue.local_id)), |
| 495 'downstream_issue_url': detail_url, |
| 496 } |
| 497 |
| 498 # TODO(jrobbins): Generate two versions of email body: members |
| 499 # vesion has other member full email addresses exposed. But, don't |
| 500 # expose too many as we iterate through upstream projects. |
| 501 body = self.email_template.GetResponse(email_data) |
| 502 |
| 503 # Just use "Re:", not Message-Id and References because a blocking |
| 504 # notification is not a comment on the issue. |
| 505 subject = 'Re: Issue %d in %s: %s' % ( |
| 506 upstream_issue.local_id, upstream_issue.project_name, |
| 507 upstream_issue.summary) |
| 508 |
| 509 omit_addrs = {users_by_id[omit_id].email for omit_id in omit_ids} |
| 510 |
| 511 # Get the transitive set of owners and Cc'd users, and their UserView's. |
| 512 direct_owners, trans_owners = self.services.usergroup.ExpandAnyUserGroups( |
| 513 cnxn, [tracker_bizobj.GetOwnerId(upstream_issue)]) |
| 514 direct_ccs, trans_ccs = self.services.usergroup.ExpandAnyUserGroups( |
| 515 cnxn, list(upstream_issue.cc_ids)) |
| 516 # TODO(jrobbins): This will say that the user was cc'd by a rule when it |
| 517 # was really added to the derived_cc_ids by a component. |
| 518 der_direct_ccs, der_transitive_ccs = ( |
| 519 self.services.usergroup.ExpandAnyUserGroups( |
| 520 cnxn, list(upstream_issue.derived_cc_ids))) |
| 521 # direct owners and Ccs are already in users_by_id |
| 522 users_by_id.update(framework_views.MakeAllUserViews( |
| 523 cnxn, self.services.user, trans_owners, trans_ccs, der_transitive_ccs)) |
| 524 |
| 525 owner_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList( |
| 526 cnxn, direct_owners + trans_owners, upstream_project, upstream_issue, |
| 527 self.services, omit_addrs, users_by_id) |
| 528 cc_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList( |
| 529 cnxn, direct_ccs + trans_ccs, upstream_project, upstream_issue, |
| 530 self.services, omit_addrs, users_by_id) |
| 531 der_cc_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList( |
| 532 cnxn, der_direct_ccs + der_transitive_ccs, upstream_project, |
| 533 upstream_issue, self.services, omit_addrs, users_by_id) |
| 534 sub_addr_perm_list = _GetSubscribersAddrPermList( |
| 535 cnxn, self.services, upstream_issue, upstream_project, upstream_config, |
| 536 omit_addrs, users_by_id) |
| 537 |
| 538 issue_notify_addr_list = notify_helpers.ComputeIssueNotificationAddrList( |
| 539 upstream_issue, omit_addrs) |
| 540 proj_notify_addr_list = notify_helpers.ComputeProjectNotificationAddrList( |
| 541 upstream_project, contributor_could_view, omit_addrs) |
| 542 |
| 543 # Give each user a bullet-list of all the reasons that apply for that user. |
| 544 group_reason_list = [ |
| 545 (owner_addr_perm_list, 'You are the owner of the issue'), |
| 546 (cc_addr_perm_list, 'You were specifically CC\'d on the issue'), |
| 547 (der_cc_addr_perm_list, 'A rule CC\'d you on the issue'), |
| 548 ] |
| 549 group_reason_list.extend(notify_helpers.ComputeComponentFieldAddrPerms( |
| 550 cnxn, upstream_config, upstream_issue, upstream_project, self.services, |
| 551 omit_addrs, users_by_id)) |
| 552 group_reason_list.extend(notify_helpers.ComputeCustomFieldAddrPerms( |
| 553 cnxn, upstream_config, upstream_issue, upstream_project, self.services, |
| 554 omit_addrs, users_by_id)) |
| 555 group_reason_list.extend([ |
| 556 # Starrers are not notified of blocking changes to reduce noise. |
| 557 (sub_addr_perm_list, 'Your saved query matched the issue'), |
| 558 (issue_notify_addr_list, |
| 559 'Project filter rules were setup to notify you'), |
| 560 (proj_notify_addr_list, |
| 561 'The project was configured to send all issue notifications ' |
| 562 'to this address'), |
| 563 ]) |
| 564 |
| 565 one_issue_email_tasks = notify_helpers.MakeBulletedEmailWorkItems( |
| 566 group_reason_list, subject, body, body, upstream_project, hostport, |
| 567 commenter_view, detail_url=detail_url) |
| 568 |
| 569 return one_issue_email_tasks |
| 570 |
| 571 |
| 572 class NotifyBulkChangeTask(NotifyTaskBase): |
| 573 """JSON servlet that notifies appropriate users after a bulk edit.""" |
| 574 |
| 575 _EMAIL_TEMPLATE = 'tracker/issue-bulk-change-notification-email.ezt' |
| 576 |
| 577 def HandleRequest(self, mr): |
| 578 """Process the task to notify users after an issue blocking change. |
| 579 |
| 580 Args: |
| 581 mr: common information parsed from the HTTP request. |
| 582 |
| 583 Returns: |
| 584 Results dictionary in JSON format which is useful just for debugging. |
| 585 The main goal is the side-effect of sending emails. |
| 586 """ |
| 587 hostport = mr.GetParam('hostport') |
| 588 project_id = mr.specified_project_id |
| 589 if project_id is None: |
| 590 return { |
| 591 'params': {}, |
| 592 'notified': [], |
| 593 'message': 'Cannot proceed without a valid project ID.', |
| 594 } |
| 595 |
| 596 local_ids = mr.local_id_list |
| 597 old_owner_ids = mr.GetIntListParam('old_owner_ids') |
| 598 comment_text = mr.GetParam('comment_text') |
| 599 commenter_id = mr.GetPositiveIntParam('commenter_id') |
| 600 amendments = mr.GetParam('amendments') |
| 601 send_email = bool(mr.GetIntParam('send_email')) |
| 602 params = dict( |
| 603 project_id=project_id, local_ids=mr.local_id_list, |
| 604 commenter_id=commenter_id, hostport=hostport, |
| 605 old_owner_ids=old_owner_ids, comment_text=comment_text, |
| 606 send_email=send_email, amendments=amendments) |
| 607 |
| 608 logging.info('bulk edit params are %r', params) |
| 609 # TODO(jrobbins): For cross-project bulk edits, prefetch all relevant |
| 610 # projects and configs and pass a dict of them to subroutines. |
| 611 project = self.services.project.GetProject(mr.cnxn, project_id) |
| 612 config = self.services.config.GetProjectConfig(mr.cnxn, project_id) |
| 613 issues = self.services.issue.GetIssuesByLocalIDs( |
| 614 mr.cnxn, project_id, local_ids) |
| 615 issues = [issue for issue in issues if not issue.is_spam] |
| 616 anon_perms = permissions.GetPermissions(None, set(), project) |
| 617 |
| 618 users_by_id = framework_views.MakeAllUserViews( |
| 619 mr.cnxn, self.services.user, [commenter_id]) |
| 620 ids_in_issues = {} |
| 621 starrers = {} |
| 622 |
| 623 non_private_issues = [] |
| 624 for issue, old_owner_id in zip(issues, old_owner_ids): |
| 625 # TODO(jrobbins): use issue_id consistently rather than local_id. |
| 626 starrers[issue.local_id] = self.services.issue_star.LookupItemStarrers( |
| 627 mr.cnxn, issue.issue_id) |
| 628 named_ids = set() # users named in user-value fields that notify. |
| 629 for fd in config.field_defs: |
| 630 named_ids.update(notify_helpers.ComputeNamedUserIDsToNotify(issue, fd)) |
| 631 direct, indirect = self.services.usergroup.ExpandAnyUserGroups( |
| 632 mr.cnxn, list(issue.cc_ids) + list(issue.derived_cc_ids) + |
| 633 [issue.owner_id, old_owner_id, issue.derived_owner_id] + |
| 634 list(named_ids)) |
| 635 ids_in_issues[issue.local_id] = set(starrers[issue.local_id]) |
| 636 ids_in_issues[issue.local_id].update(direct) |
| 637 ids_in_issues[issue.local_id].update(indirect) |
| 638 ids_in_issue_needing_views = ( |
| 639 ids_in_issues[issue.local_id] | |
| 640 tracker_bizobj.UsersInvolvedInIssues([issue])) |
| 641 new_ids_in_issue = [user_id for user_id in ids_in_issue_needing_views |
| 642 if user_id not in users_by_id] |
| 643 users_by_id.update( |
| 644 framework_views.MakeAllUserViews( |
| 645 mr.cnxn, self.services.user, new_ids_in_issue)) |
| 646 |
| 647 anon_can_view = permissions.CanViewIssue( |
| 648 set(), anon_perms, project, issue) |
| 649 if anon_can_view: |
| 650 non_private_issues.append(issue) |
| 651 |
| 652 commenter_view = users_by_id[commenter_id] |
| 653 omit_addrs = {commenter_view.email} |
| 654 |
| 655 tasks = [] |
| 656 if send_email: |
| 657 email_tasks = self._BulkEditEmailTasks( |
| 658 mr.cnxn, issues, old_owner_ids, omit_addrs, project, |
| 659 non_private_issues, users_by_id, ids_in_issues, starrers, |
| 660 commenter_view, hostport, comment_text, amendments, config) |
| 661 tasks = email_tasks |
| 662 |
| 663 notified = AddAllEmailTasks(tasks) |
| 664 return { |
| 665 'params': params, |
| 666 'notified': notified, |
| 667 } |
| 668 |
| 669 def _BulkEditEmailTasks( |
| 670 self, cnxn, issues, old_owner_ids, omit_addrs, project, |
| 671 non_private_issues, users_by_id, ids_in_issues, starrers, |
| 672 commenter_view, hostport, comment_text, amendments, config): |
| 673 """Generate Email PBs to notify interested users after a bulk edit.""" |
| 674 # 1. Get the user IDs of everyone who could be notified, |
| 675 # and make all their user proxies. Also, build a dictionary |
| 676 # of all the users to notify and the issues that they are |
| 677 # interested in. Also, build a dictionary of additional email |
| 678 # addresses to notify and the issues to notify them of. |
| 679 users_by_id = {} |
| 680 ids_to_notify_of_issue = {} |
| 681 additional_addrs_to_notify_of_issue = collections.defaultdict(list) |
| 682 |
| 683 users_to_queries = notify_helpers.GetNonOmittedSubscriptions( |
| 684 cnxn, self.services, [project.project_id], {}) |
| 685 config = self.services.config.GetProjectConfig( |
| 686 cnxn, project.project_id) |
| 687 for issue, old_owner_id in zip(issues, old_owner_ids): |
| 688 issue_participants = set( |
| 689 [tracker_bizobj.GetOwnerId(issue), old_owner_id] + |
| 690 tracker_bizobj.GetCcIds(issue)) |
| 691 # users named in user-value fields that notify. |
| 692 for fd in config.field_defs: |
| 693 issue_participants.update( |
| 694 notify_helpers.ComputeNamedUserIDsToNotify(issue, fd)) |
| 695 for user_id in ids_in_issues[issue.local_id]: |
| 696 # TODO(jrobbins): implement batch GetUser() for speed. |
| 697 if not user_id: |
| 698 continue |
| 699 auth = monorailrequest.AuthData.FromUserID( |
| 700 cnxn, user_id, self.services) |
| 701 if (auth.user_pb.notify_issue_change and |
| 702 not auth.effective_ids.isdisjoint(issue_participants)): |
| 703 ids_to_notify_of_issue.setdefault(user_id, []).append(issue) |
| 704 elif (auth.user_pb.notify_starred_issue_change and |
| 705 user_id in starrers[issue.local_id]): |
| 706 # Skip users who have starred issues that they can no longer view. |
| 707 starrer_perms = permissions.GetPermissions( |
| 708 auth.user_pb, auth.effective_ids, project) |
| 709 granted_perms = tracker_bizobj.GetGrantedPerms( |
| 710 issue, auth.effective_ids, config) |
| 711 starrer_can_view = permissions.CanViewIssue( |
| 712 auth.effective_ids, starrer_perms, project, issue, |
| 713 granted_perms=granted_perms) |
| 714 if starrer_can_view: |
| 715 ids_to_notify_of_issue.setdefault(user_id, []).append(issue) |
| 716 logging.info( |
| 717 'ids_to_notify_of_issue[%s] = %s', |
| 718 user_id, |
| 719 [i.local_id for i in ids_to_notify_of_issue.get(user_id, [])]) |
| 720 |
| 721 # Find all subscribers that should be notified. |
| 722 subscribers_to_consider = notify_helpers.EvaluateSubscriptions( |
| 723 cnxn, issue, users_to_queries, self.services, config) |
| 724 for sub_id in subscribers_to_consider: |
| 725 auth = monorailrequest.AuthData.FromUserID(cnxn, sub_id, self.services) |
| 726 sub_perms = permissions.GetPermissions( |
| 727 auth.user_pb, auth.effective_ids, project) |
| 728 granted_perms = tracker_bizobj.GetGrantedPerms( |
| 729 issue, auth.effective_ids, config) |
| 730 sub_can_view = permissions.CanViewIssue( |
| 731 auth.effective_ids, sub_perms, project, issue, |
| 732 granted_perms=granted_perms) |
| 733 if sub_can_view: |
| 734 ids_to_notify_of_issue.setdefault(sub_id, []).append(issue) |
| 735 |
| 736 if issue in non_private_issues: |
| 737 for notify_addr in issue.derived_notify_addrs: |
| 738 additional_addrs_to_notify_of_issue[notify_addr].append(issue) |
| 739 |
| 740 # 2. Compose an email specifically for each user. |
| 741 email_tasks = [] |
| 742 needed_user_view_ids = [uid for uid in ids_to_notify_of_issue |
| 743 if uid not in users_by_id] |
| 744 users_by_id.update(framework_views.MakeAllUserViews( |
| 745 cnxn, self.services.user, needed_user_view_ids)) |
| 746 for user_id in ids_to_notify_of_issue: |
| 747 if not user_id: |
| 748 continue # Don't try to notify NO_USER_SPECIFIED |
| 749 if users_by_id[user_id].email in omit_addrs: |
| 750 logging.info('Omitting %s', user_id) |
| 751 continue |
| 752 user_issues = ids_to_notify_of_issue[user_id] |
| 753 if not user_issues: |
| 754 continue # user's prefs indicate they don't want these notifications |
| 755 email = self._FormatBulkIssuesEmail( |
| 756 users_by_id[user_id].email, user_issues, users_by_id, |
| 757 commenter_view, hostport, comment_text, amendments, config, project) |
| 758 email_tasks.append(email) |
| 759 omit_addrs.add(users_by_id[user_id].email) |
| 760 logging.info('about to bulk notify %s (%s) of %s', |
| 761 users_by_id[user_id].email, user_id, |
| 762 [issue.local_id for issue in user_issues]) |
| 763 |
| 764 # 3. Compose one email to each notify_addr with all the issues that it |
| 765 # is supossed to be notified about. |
| 766 for addr, addr_issues in additional_addrs_to_notify_of_issue.iteritems(): |
| 767 email = self._FormatBulkIssuesEmail( |
| 768 addr, addr_issues, users_by_id, commenter_view, hostport, |
| 769 comment_text, amendments, config, project) |
| 770 email_tasks.append(email) |
| 771 omit_addrs.add(addr) |
| 772 logging.info('about to bulk notify additional addr %s of %s', |
| 773 addr, [addr_issue.local_id for addr_issue in addr_issues]) |
| 774 |
| 775 # 4. Add in the project's issue_notify_address. This happens even if it |
| 776 # is the same as the commenter's email address (which would be an unusual |
| 777 # but valid project configuration). Only issues that any contributor could |
| 778 # view are included in emails to the all-issue-activity mailing lists. |
| 779 if (project.issue_notify_address |
| 780 and project.issue_notify_address not in omit_addrs): |
| 781 non_private_issues_live = [] |
| 782 for issue in issues: |
| 783 contributor_could_view = permissions.CanViewIssue( |
| 784 set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET, |
| 785 project, issue) |
| 786 if contributor_could_view: |
| 787 non_private_issues_live.append(issue) |
| 788 |
| 789 if non_private_issues_live: |
| 790 email = self._FormatBulkIssuesEmail( |
| 791 project.issue_notify_address, non_private_issues_live, |
| 792 users_by_id, commenter_view, hostport, comment_text, amendments, |
| 793 config, project) |
| 794 email_tasks.append(email) |
| 795 omit_addrs.add(project.issue_notify_address) |
| 796 logging.info('about to bulk notify all-issues %s of %s', |
| 797 project.issue_notify_address, |
| 798 [issue.local_id for issue in non_private_issues]) |
| 799 |
| 800 return email_tasks |
| 801 |
| 802 def _FormatBulkIssuesEmail( |
| 803 self, dest_email, issues, users_by_id, commenter_view, |
| 804 hostport, comment_text, amendments, config, _project): |
| 805 """Format an email to one user listing many issues.""" |
| 806 # TODO(jrobbins): Generate two versions of email body: members |
| 807 # vesion has full email addresses exposed. And, use the full |
| 808 # commenter email address in the From: line when sending to |
| 809 # a member. |
| 810 subject, body = self._FormatBulkIssues( |
| 811 issues, users_by_id, commenter_view, hostport, comment_text, |
| 812 amendments, config) |
| 813 |
| 814 from_addr = emailfmt.NoReplyAddress(commenter_view=commenter_view) |
| 815 return dict(from_addr=from_addr, to=dest_email, subject=subject, body=body) |
| 816 |
| 817 def _FormatBulkIssues( |
| 818 self, issues, users_by_id, commenter_view, hostport, comment_text, |
| 819 amendments, config, body_type='email'): |
| 820 """Format a subject and body for a bulk issue edit.""" |
| 821 assert body_type in ('email', 'feed') |
| 822 project_name = issues[0].project_name |
| 823 |
| 824 issue_views = [] |
| 825 for issue in issues: |
| 826 # TODO(jrobbins): choose config from dict of prefetched configs. |
| 827 issue_views.append(tracker_views.IssueView(issue, users_by_id, config)) |
| 828 |
| 829 email_data = { |
| 830 'hostport': hostport, |
| 831 'num_issues': len(issues), |
| 832 'issues': issue_views, |
| 833 'comment_text': comment_text, |
| 834 'commenter': commenter_view, |
| 835 'amendments': amendments, |
| 836 'body_type': body_type, |
| 837 } |
| 838 |
| 839 if len(issues) == 1: |
| 840 subject = 'issue %s in %s: %s' % ( |
| 841 issues[0].local_id, project_name, issues[0].summary) |
| 842 # TODO(jrobbins): Look up the sequence number instead and treat this |
| 843 # more like an individual change for email threading. For now, just |
| 844 # add "Re:" because bulk edits are always replies. |
| 845 subject = 'Re: ' + subject |
| 846 else: |
| 847 subject = '%d issues changed in %s' % (len(issues), project_name) |
| 848 |
| 849 body = self.email_template.GetResponse(email_data) |
| 850 |
| 851 return subject, body |
| 852 |
| 853 |
| 854 class OutboundEmailTask(jsonfeed.InternalTask): |
| 855 """JSON servlet that sends one email.""" |
| 856 |
| 857 def HandleRequest(self, mr): |
| 858 """Process the task to send one email message. |
| 859 |
| 860 Args: |
| 861 mr: common information parsed from the HTTP request. |
| 862 |
| 863 Returns: |
| 864 Results dictionary in JSON format which is useful just for debugging. |
| 865 The main goal is the side-effect of sending emails. |
| 866 """ |
| 867 # If running on a GAFYD domain, you must define an app alias on the |
| 868 # Application Settings admin web page. |
| 869 sender = mr.GetParam('from_addr') |
| 870 reply_to = mr.GetParam('reply_to') |
| 871 to = mr.GetParam('to') |
| 872 if not to: |
| 873 # Cannot proceed if we cannot create a valid EmailMessage. |
| 874 return |
| 875 references = mr.GetParam('references') |
| 876 subject = mr.GetParam('subject') |
| 877 body = mr.GetParam('body') |
| 878 html_body = mr.GetParam('html_body') |
| 879 |
| 880 if settings.dev_mode: |
| 881 to_format = settings.send_dev_email_to |
| 882 else: |
| 883 to_format = settings.send_all_email_to |
| 884 |
| 885 if to_format: |
| 886 to_user, to_domain = to.split('@') |
| 887 to = to_format % {'user': to_user, 'domain': to_domain} |
| 888 |
| 889 logging.info( |
| 890 'Email:\n sender: %s\n reply_to: %s\n to: %s\n references: %s\n ' |
| 891 'subject: %s\n body: %s\n html body: %s', |
| 892 sender, reply_to, to, references, subject, body, html_body) |
| 893 message = mail.EmailMessage( |
| 894 sender=sender, to=to, subject=subject, body=body) |
| 895 if html_body: |
| 896 message.html = html_body |
| 897 if reply_to: |
| 898 message.reply_to = reply_to |
| 899 if references: |
| 900 message.headers = {'References': references} |
| 901 if settings.unit_test_mode: |
| 902 logging.info('Sending message "%s" in test mode.', message.subject) |
| 903 else: |
| 904 message.send() |
| 905 |
| 906 return dict( |
| 907 sender=sender, to=to, subject=subject, body=body, html_body=html_body, |
| 908 reply_to=reply_to, references=references) |
| 909 |
| 910 |
| 911 def _GetSubscribersAddrPermList( |
| 912 cnxn, services, issue, project, config, omit_addrs, users_by_id): |
| 913 """Lookup subscribers, evaluate their saved queries, and decide to notify.""" |
| 914 users_to_queries = notify_helpers.GetNonOmittedSubscriptions( |
| 915 cnxn, services, [project.project_id], omit_addrs) |
| 916 # TODO(jrobbins): need to pass through the user_id to use for "me". |
| 917 subscribers_to_notify = notify_helpers.EvaluateSubscriptions( |
| 918 cnxn, issue, users_to_queries, services, config) |
| 919 # TODO(jrobbins): expand any subscribers that are user groups. |
| 920 subs_needing_user_views = [ |
| 921 uid for uid in subscribers_to_notify if uid not in users_by_id] |
| 922 users_by_id.update(framework_views.MakeAllUserViews( |
| 923 cnxn, services.user, subs_needing_user_views)) |
| 924 sub_addr_perm_list = notify_helpers.ComputeIssueChangeAddressPermList( |
| 925 cnxn, subscribers_to_notify, project, issue, services, omit_addrs, |
| 926 users_by_id, pref_check_function=lambda *args: True) |
| 927 |
| 928 return sub_addr_perm_list |
OLD | NEW |