Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(83)

Side by Side Diff: appengine/monorail/features/notify.py

Issue 1868553004: Open Source Monorail (Closed) Base URL: https://chromium.googlesource.com/infra/infra.git@master
Patch Set: Rebase Created 4 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(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
OLDNEW
« no previous file with comments | « appengine/monorail/features/inboundemail.py ('k') | appengine/monorail/features/notify_helpers.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698