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

Side by Side Diff: appengine/monorail/features/notify_helpers.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
« no previous file with comments | « appengine/monorail/features/notify.py ('k') | appengine/monorail/features/prettify.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
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 """Helper functions for email notifications of issue changes."""
7
8 import cgi
9 import logging
10 import re
11
12 from django.utils.html import urlize
13
14 from features import filterrules_helpers
15 from features import savedqueries_helpers
16 from framework import emailfmt
17 from framework import framework_bizobj
18 from framework import framework_constants
19 from framework import framework_helpers
20 from framework import monorailrequest
21 from framework import permissions
22 from framework import urls
23 from proto import tracker_pb2
24 from search import query2ast
25 from search import searchpipeline
26 from tracker import component_helpers
27 from tracker import tracker_bizobj
28
29
30 # When sending change notification emails, choose the reply-to header and
31 # footer message based on three levels of the the recipient's permissions
32 # for that issue.
33 REPLY_NOT_ALLOWED = 'REPLY_NOT_ALLOWED'
34 REPLY_MAY_COMMENT = 'REPLY_MAY_COMMENT'
35 REPLY_MAY_UPDATE = 'REPLY_MAY_UPDATE'
36
37 # This HTML template adds mark up which enables Gmail/Inbox to display a
38 # convenient link that takes users to the CL directly from the inbox without
39 # having to click on the email.
40 # Documentation for this schema.org markup is here:
41 # https://developers.google.com/gmail/markup/reference/go-to-action
42 HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE = """
43 <html>
44 <body>
45 <script type="application/ld+json">
46 {
47 "@context": "http://schema.org",
48 "@type": "EmailMessage",
49 "potentialAction": {
50 "@type": "ViewAction",
51 "name": "View Issue",
52 "url": "%s"
53 },
54 "description": ""
55 }
56 </script>
57
58 <div style="font-family: arial, sans-serif">%s</div>
59 </body>
60 </html>
61 """
62
63
64 def ComputeIssueChangeAddressPermList(
65 cnxn, ids_to_consider, project, issue, services, omit_addrs,
66 users_by_id, pref_check_function=lambda u: u.notify_issue_change):
67 """Return a list of user email addresses to notify of an issue change.
68
69 User email addresses are determined by looking up the given user IDs
70 in the given users_by_id dict.
71
72 Args:
73 cnxn: connection to SQL database.
74 ids_to_consider: list of user IDs for users interested in this issue.
75 project: Project PB for the project contianing containing this issue.
76 issue: Issue PB for the issue that was updated.
77 services: Services.
78 omit_addrs: set of strings for email addresses to not notify because
79 they already know.
80 users_by_id: dict {user_id: user_view} user info.
81 pref_check_function: optional function to use to check if a certain
82 User PB has a preference set to receive the email being sent. It
83 defaults to "If I am in the issue's owner or cc field", but it
84 can be set to check "If I starred the issue."
85
86 Returns:
87 A list of tuples: [(recipient_is_member, address, reply_perm), ...] where
88 reply_perm is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT,
89 REPLY_MAY_UPDATE.
90 """
91 memb_addr_perm_list = []
92 for user_id in ids_to_consider:
93 if user_id == framework_constants.NO_USER_SPECIFIED:
94 continue
95 user = services.user.GetUser(cnxn, user_id)
96 # Notify people who have a pref set, or if they have no User PB
97 # because the pref defaults to True.
98 if user and not pref_check_function(user):
99 continue
100 # TODO(jrobbins): doing a bulk operation would reduce DB load.
101 auth = monorailrequest.AuthData.FromUserID(cnxn, user_id, services)
102 perms = permissions.GetPermissions(user, auth.effective_ids, project)
103 config = services.config.GetProjectConfig(cnxn, project.project_id)
104 granted_perms = tracker_bizobj.GetGrantedPerms(
105 issue, auth.effective_ids, config)
106
107 if not permissions.CanViewIssue(
108 auth.effective_ids, perms, project, issue,
109 granted_perms=granted_perms):
110 continue
111
112 addr = users_by_id[user_id].email
113 if addr in omit_addrs:
114 continue
115
116 recipient_is_member = bool(framework_bizobj.UserIsInProject(
117 project, auth.effective_ids))
118
119 reply_perm = REPLY_NOT_ALLOWED
120 if project.process_inbound_email:
121 if permissions.CanEditIssue(auth.effective_ids, perms, project, issue):
122 reply_perm = REPLY_MAY_UPDATE
123 elif permissions.CanCommentIssue(
124 auth.effective_ids, perms, project, issue):
125 reply_perm = REPLY_MAY_COMMENT
126
127 memb_addr_perm_list.append((recipient_is_member, addr, reply_perm))
128
129 logging.info('For %s %s, will notify: %r',
130 project.project_name, issue.local_id, memb_addr_perm_list)
131
132 return memb_addr_perm_list
133
134
135 def ComputeProjectNotificationAddrList(
136 project, contributor_could_view, omit_addrs):
137 """Return a list of non-user addresses to notify of an issue change.
138
139 The non-user addresses are specified by email address strings, not
140 user IDs. One such address can be specified in the project PB.
141 It is not assumed to have permission to see all issues.
142
143 Args:
144 project: Project PB containing the issue that was updated.
145 contributor_could_view: True if any project contributor should be able to
146 see the notification email, e.g., in a mailing list archive or feed.
147 omit_addrs: set of strings for email addresses to not notify because
148 they already know.
149
150 Returns:
151 A list of tuples: [(False, email_address, reply_permission_level), ...],
152 where reply_permission_level is always REPLY_NOT_ALLOWED for now.
153 """
154 memb_addr_perm_list = []
155 if contributor_could_view:
156 ml_addr = project.issue_notify_address
157 if ml_addr and ml_addr not in omit_addrs:
158 memb_addr_perm_list.append((False, ml_addr, REPLY_NOT_ALLOWED))
159
160 return memb_addr_perm_list
161
162
163 def ComputeIssueNotificationAddrList(issue, omit_addrs):
164 """Return a list of non-user addresses to notify of an issue change.
165
166 The non-user addresses are specified by email address strings, not
167 user IDs. They can be set by filter rules with the "Also notify" action.
168 "Also notify" addresses are assumed to have permission to see any issue,
169 even a restricted one.
170
171 Args:
172 issue: Issue PB for the issue that was updated.
173 omit_addrs: set of strings for email addresses to not notify because
174 they already know.
175
176 Returns:
177 A list of tuples: [(False, email_address, reply_permission_level), ...],
178 where reply_permission_level is always REPLY_NOT_ALLOWED for now.
179 """
180 addr_perm_list = []
181 for addr in issue.derived_notify_addrs:
182 if addr not in omit_addrs:
183 addr_perm_list.append((False, addr, REPLY_NOT_ALLOWED))
184
185 return addr_perm_list
186
187
188 def MakeBulletedEmailWorkItems(
189 group_reason_list, subject, body_for_non_members, body_for_members,
190 project, hostport, commenter_view, seq_num=None, detail_url=None):
191 """Make a list of dicts describing email-sending tasks to notify users.
192
193 Args:
194 group_reason_list: list of (is_memb, addr_perm, reason) tuples.
195 subject: string email subject line.
196 body_for_non_members: string body of email to send to non-members.
197 body_for_members: string body of email to send to members.
198 project: Project that contains the issue.
199 hostport: string hostname and port number for links to the site.
200 commenter_view: UserView for the user who made the comment.
201 seq_num: optional int sequence number of the comment.
202 detail_url: optional str direct link to the issue.
203
204 Returns:
205 A list of dictionaries, each with all needed info to send an individual
206 email to one user. Each email contains a footer that lists all the
207 reasons why that user received the email.
208 """
209 logging.info('group_reason_list is %r', group_reason_list)
210 addr_reasons_dict = {}
211 for group, reason in group_reason_list:
212 for memb_addr_perm in group:
213 addr_reasons_dict.setdefault(memb_addr_perm, []).append(reason)
214
215 email_tasks = []
216 for memb_addr_perm, reasons in addr_reasons_dict.iteritems():
217 email_tasks.append(_MakeEmailWorkItem(
218 memb_addr_perm, reasons, subject, body_for_non_members,
219 body_for_members, project, hostport, commenter_view, seq_num=seq_num,
220 detail_url=detail_url))
221
222 return email_tasks
223
224
225 def _MakeEmailWorkItem(
226 (recipient_is_member, to_addr, reply_perm), reasons, subject,
227 body_for_non_members, body_for_members, project, hostport, commenter_view,
228 seq_num=None, detail_url=None):
229 """Make one email task dict for one user, includes a detailed reason."""
230 footer = _MakeNotificationFooter(reasons, reply_perm, hostport)
231 if isinstance(footer, unicode):
232 footer = footer.encode('utf-8')
233 if recipient_is_member:
234 logging.info('got member %r', to_addr)
235 body = body_for_members
236 else:
237 logging.info('got non-member %r', to_addr)
238 body = body_for_non_members
239
240 logging.info('sending body + footer: %r', body + footer)
241 can_reply_to = (
242 reply_perm != REPLY_NOT_ALLOWED and project.process_inbound_email)
243 from_addr = emailfmt.FormatFromAddr(
244 project, commenter_view=commenter_view, reveal_addr=recipient_is_member,
245 can_reply_to=can_reply_to)
246 if can_reply_to:
247 reply_to = '%s@%s' % (project.project_name, emailfmt.MailDomain())
248 else:
249 reply_to = emailfmt.NoReplyAddress()
250 refs = emailfmt.GetReferences(
251 to_addr, subject, seq_num,
252 '%s@%s' % (project.project_name, emailfmt.MailDomain()))
253 # If detail_url is specified then we can use markup to display a convenient
254 # link that takes users directly to the issue without clicking on the email.
255 html_body = None
256 if detail_url:
257 # cgi.escape the body and additionally escape single quotes which are
258 # occassionally used to contain HTML attributes and event handler
259 # definitions.
260 html_escaped_body = cgi.escape(body + footer, quote=1).replace("'", '&#39;')
261 html_body = HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % (
262 detail_url,
263 _AddHTMLTags(html_escaped_body.decode('utf-8')))
264 return dict(to=to_addr, subject=subject, body=body + footer,
265 html_body=html_body, from_addr=from_addr, reply_to=reply_to,
266 references=refs)
267
268
269 def _AddHTMLTags(body):
270 """Adds HMTL tags in the specified email body.
271
272 Specifically does the following:
273 * Detects links and adds <a href>s around the links.
274 * Substitutes <br/> for all occurrences of "\n".
275
276 See crbug.com/582463 for context.
277 """
278 # Convert all URLs into clickable links.
279 body = urlize(body)
280 # The above step converts
281 # '&lt;link.com&gt;' into '&lt;<a href="link.com&gt">link.com&gt</a>;' and
282 # '&lt;x@y.com&gt;' into '&lt;<a href="mailto:x@y.com&gt">x@y.com&gt</a>;'
283 # The below regex fixes this specific problem. See
284 # https://bugs.chromium.org/p/monorail/issues/detail?id=1007 for more details.
285 body = re.sub(r'&lt;<a href="(|mailto:)(.*?)&gt">(.*?)&gt</a>;',
286 r'<a href="\1\2"><\3></a>', body)
287
288 # Convert all "\n"s into "<br/>"s.
289 body = body.replace("\n", "<br/>")
290 return body
291
292
293 def _MakeNotificationFooter(reasons, reply_perm, hostport):
294 """Make an informative footer for a notification email.
295
296 Args:
297 reasons: a list of strings to be used as the explanation. Empty if no
298 reason is to be given.
299 reply_perm: string which is one of REPLY_NOT_ALLOWED, REPLY_MAY_COMMENT,
300 REPLY_MAY_UPDATE.
301 hostport: string with domain_name:port_number to be used in linking to
302 the user preferences page.
303
304 Returns:
305 A string to be used as the email footer.
306 """
307 if not reasons:
308 return ''
309
310 domain_port = hostport.split(':')
311 domain_port[0] = framework_helpers.GetPreferredDomain(domain_port[0])
312 hostport = ':'.join(domain_port)
313
314 prefs_url = 'https://%s%s' % (hostport, urls.USER_SETTINGS)
315 lines = ['-- ']
316 lines.append('You received this message because:')
317 lines.extend(' %d. %s' % (idx + 1, reason)
318 for idx, reason in enumerate(reasons))
319
320 lines.extend(['', 'You may adjust your notification preferences at:',
321 prefs_url])
322
323 if reply_perm == REPLY_MAY_COMMENT:
324 lines.extend(['', 'Reply to this email to add a comment.'])
325 elif reply_perm == REPLY_MAY_UPDATE:
326 lines.extend(['', 'Reply to this email to add a comment or make updates.'])
327
328 return '\n'.join(lines)
329
330
331 def GetNonOmittedSubscriptions(cnxn, services, project_ids, omit_addrs):
332 """Get a dict of users w/ subscriptions in those projects."""
333 users_to_queries = services.features.GetSubscriptionsInProjects(
334 cnxn, project_ids)
335 user_emails = services.user.LookupUserEmails(cnxn, users_to_queries.keys())
336 for user_id, email in user_emails.iteritems():
337 if email in omit_addrs:
338 del users_to_queries[user_id]
339
340 return users_to_queries
341
342
343 def EvaluateSubscriptions(
344 cnxn, issue, users_to_queries, services, config):
345 """Determine subscribers who have subs that match the given issue."""
346 # Note: unlike filter rule, subscriptions see explicit & derived values.
347 lower_labels = [lab.lower() for lab in tracker_bizobj.GetLabels(issue)]
348 label_set = set(lower_labels)
349
350 subscribers_to_notify = []
351 for uid, saved_queries in users_to_queries.iteritems():
352 for sq in saved_queries:
353 if sq.subscription_mode != 'immediate':
354 continue
355 if issue.project_id not in sq.executes_in_project_ids:
356 continue
357 cond = savedqueries_helpers.SavedQueryToCond(sq)
358 logging.info('evaluating query %s: %r', sq.name, cond)
359 cond = searchpipeline.ReplaceKeywordsWithUserID(uid, cond)
360 cond_ast = query2ast.ParseUserQuery(
361 cond, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
362
363 if filterrules_helpers.EvalPredicate(
364 cnxn, services, cond_ast, issue, label_set, config,
365 tracker_bizobj.GetOwnerId(issue), tracker_bizobj.GetCcIds(issue),
366 tracker_bizobj.GetStatus(issue)):
367 subscribers_to_notify.append(uid)
368 break # Don't bother looking at the user's other saved quereies.
369
370 return subscribers_to_notify
371
372
373 def ComputeCustomFieldAddrPerms(
374 cnxn, config, issue, project, services, omit_addrs, users_by_id):
375 """Check the reasons to notify users named in custom fields."""
376 group_reason_list = []
377 for fd in config.field_defs:
378 named_user_ids = ComputeNamedUserIDsToNotify(issue, fd)
379 if named_user_ids:
380 named_addr_perms = ComputeIssueChangeAddressPermList(
381 cnxn, named_user_ids, project, issue, services, omit_addrs,
382 users_by_id, pref_check_function=lambda u: True)
383 group_reason_list.append(
384 (named_addr_perms, 'You are named in the %s field' % fd.field_name))
385
386 return group_reason_list
387
388
389 def ComputeNamedUserIDsToNotify(issue, fd):
390 """Give a list of user IDs to notify because they're in a field."""
391 if (fd.field_type == tracker_pb2.FieldTypes.USER_TYPE and
392 fd.notify_on == tracker_pb2.NotifyTriggers.ANY_COMMENT):
393 return [fv.user_id for fv in issue.field_values
394 if fv.field_id == fd.field_id]
395
396 return []
397
398
399 def ComputeComponentFieldAddrPerms(
400 cnxn, config, issue, project, services, omit_addrs, users_by_id):
401 """Return [(addr_perm, reason), ...] for users auto-cc'd by components."""
402 component_ids = set(issue.component_ids)
403 group_reason_list = []
404 for cd in config.component_defs:
405 if cd.component_id in component_ids:
406 cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(config, cd)
407 comp_addr_perms = ComputeIssueChangeAddressPermList(
408 cnxn, cc_ids, project, issue, services, omit_addrs,
409 users_by_id, pref_check_function=lambda u: True)
410 group_reason_list.append(
411 (comp_addr_perms,
412 'You are auto-CC\'d on all issues in component %s' % cd.path))
413
414 return group_reason_list
OLDNEW
« no previous file with comments | « appengine/monorail/features/notify.py ('k') | appengine/monorail/features/prettify.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698