blob: a525db1e8252b84145366058b568963c962b7f9e [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2016 The Chromium Authors. All rights reserved.
2# Use of this source code is governed 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"""Cron and task handlers for email notifications of issue date value arrival.
7
8If an issue has a date-type custom field, and that custom field is configured
9to perform an action when that date arrives, then this cron handler and the
10associated tasks carry out those actions on that issue.
11"""
12
13from __future__ import division
14from __future__ import print_function
15from __future__ import absolute_import
16
17import logging
18import time
19
20import ezt
21
22import settings
23
24from features import notify_helpers
25from features import notify_reasons
26from framework import cloud_tasks_helpers
27from framework import framework_constants
28from framework import framework_helpers
29from framework import framework_views
30from framework import jsonfeed
31from framework import permissions
32from framework import timestr
33from framework import urls
34from proto import tracker_pb2
35from tracker import tracker_bizobj
36from tracker import tracker_helpers
37from tracker import tracker_views
38
39
40TEMPLATE_PATH = framework_constants.TEMPLATE_PATH
41
42class DateActionCron(jsonfeed.InternalTask):
43 """Find and process issues with date-type values that arrived today."""
44
45 def HandleRequest(self, mr):
46 """Find issues with date-type-fields that arrived and spawn tasks."""
47 highest_iid_so_far = 0
48 capped = True
49 timestamp_min, timestamp_max = _GetTimestampRange(int(time.time()))
50 left_joins = [
51 ('Issue2FieldValue ON Issue.id = Issue2FieldValue.issue_id', []),
52 ('FieldDef ON Issue2FieldValue.field_id = FieldDef.id', []),
53 ]
54 where = [
55 ('FieldDef.field_type = %s', ['date_type']),
56 ('FieldDef.date_action IN (%s,%s)',
57 ['ping_owner_only', 'ping_participants']),
58 ('Issue2FieldValue.date_value >= %s', [timestamp_min]),
59 ('Issue2FieldValue.date_value < %s', [timestamp_max]),
60 ]
61 order_by = [
62 ('Issue.id', []),
63 ]
64 while capped:
65 chunk_issue_ids, capped = self.services.issue.RunIssueQuery(
66 mr.cnxn, left_joins,
67 where + [('Issue.id > %s', [highest_iid_so_far])], order_by)
68 if chunk_issue_ids:
69 logging.info('chunk_issue_ids = %r', chunk_issue_ids)
70 highest_iid_so_far = max(highest_iid_so_far, max(chunk_issue_ids))
71 for issue_id in chunk_issue_ids:
72 self.EnqueueDateAction(issue_id)
73
74 def EnqueueDateAction(self, issue_id):
75 """Create a task to notify users that an issue's date has arrived.
76
77 Args:
78 issue_id: int ID of the issue that was changed.
79
80 Returns nothing.
81 """
82 params = {'issue_id': issue_id}
83 task = cloud_tasks_helpers.generate_simple_task(
84 urls.ISSUE_DATE_ACTION_TASK + '.do', params)
85 cloud_tasks_helpers.create_task(task)
86
87
88def _GetTimestampRange(now):
89 """Return a (min, max) timestamp range for today."""
90 timestamp_min = (now // framework_constants.SECS_PER_DAY *
91 framework_constants.SECS_PER_DAY)
92 timestamp_max = timestamp_min + framework_constants.SECS_PER_DAY
93 return timestamp_min, timestamp_max
94
95
96class IssueDateActionTask(notify_helpers.NotifyTaskBase):
97 """JSON servlet that notifies appropriate users after an issue change."""
98
99 _EMAIL_TEMPLATE = 'features/auto-ping-email.ezt'
100 _LINK_ONLY_EMAIL_TEMPLATE = (
101 'tracker/issue-change-notification-email-link-only.ezt')
102
103 def HandleRequest(self, mr):
104 """Process the task to process an issue date action.
105
106 Args:
107 mr: common information parsed from the HTTP request.
108
109 Returns:
110 Results dictionary in JSON format which is useful just for debugging.
111 The main goal is the side-effect of sending emails.
112 """
113 issue_id = mr.GetPositiveIntParam('issue_id')
114 issue = self.services.issue.GetIssue(mr.cnxn, issue_id, use_cache=False)
115 project = self.services.project.GetProject(mr.cnxn, issue.project_id)
116 hostport = framework_helpers.GetHostPort(project_name=project.project_name)
117 config = self.services.config.GetProjectConfig(mr.cnxn, issue.project_id)
118 pings = self._CalculateIssuePings(issue, config)
119 if not pings:
120 logging.warning('Issue %r has no dates to ping afterall?', issue_id)
121 return
122 comment = self._CreatePingComment(mr.cnxn, issue, pings, hostport)
123 starrer_ids = self.services.issue_star.LookupItemStarrers(
124 mr.cnxn, issue.issue_id)
125
126 users_by_id = framework_views.MakeAllUserViews(
127 mr.cnxn, self.services.user,
128 tracker_bizobj.UsersInvolvedInIssues([issue]),
129 tracker_bizobj.UsersInvolvedInComment(comment),
130 starrer_ids)
131 logging.info('users_by_id is %r', users_by_id)
132 tasks = self._MakeEmailTasks(
133 mr.cnxn, issue, project, config, comment, starrer_ids,
134 hostport, users_by_id, pings)
135
136 notified = notify_helpers.AddAllEmailTasks(tasks)
137 return {
138 'notified': notified,
139 }
140
141 def _CreatePingComment(self, cnxn, issue, pings, hostport):
142 """Create an issue comment saying that some dates have arrived."""
143 content = '\n'.join(self._FormatPingLine(ping) for ping in pings)
144 author_email_addr = '%s@%s' % (settings.date_action_ping_author, hostport)
145 date_action_user_id = self.services.user.LookupUserID(
146 cnxn, author_email_addr, autocreate=True)
147 comment = self.services.issue.CreateIssueComment(
148 cnxn, issue, date_action_user_id, content)
149 return comment
150
151 def _MakeEmailTasks(
152 self, cnxn, issue, project, config, comment, starrer_ids,
153 hostport, users_by_id, pings):
154 """Return a list of dicts for tasks to notify people."""
155 detail_url = framework_helpers.IssueCommentURL(
156 hostport, project, issue.local_id, seq_num=comment.sequence)
157 fields = sorted((field_def for (field_def, _date_value) in pings),
158 key=lambda fd: fd.field_name)
159 email_data = {
160 'issue': tracker_views.IssueView(issue, users_by_id, config),
161 'summary': issue.summary,
162 'ping_comment_content': comment.content,
163 'detail_url': detail_url,
164 'fields': fields,
165 }
166
167 # Generate three versions of email body with progressively more info.
168 body_link_only = self.link_only_email_template.GetResponse(
169 {'detail_url': detail_url, 'was_created': ezt.boolean(False)})
170 body_for_non_members = self.email_template.GetResponse(email_data)
171 framework_views.RevealAllEmails(users_by_id)
172 body_for_members = self.email_template.GetResponse(email_data)
173 logging.info('body for non-members is:\n%r' % body_for_non_members)
174 logging.info('body for members is:\n%r' % body_for_members)
175
176 contributor_could_view = permissions.CanViewIssue(
177 set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
178 project, issue)
179
180 group_reason_list = notify_reasons.ComputeGroupReasonList(
181 cnxn, self.services, project, issue, config, users_by_id,
182 [], contributor_could_view, starrer_ids=starrer_ids,
183 commenter_in_project=True, include_subscribers=False,
184 include_notify_all=False,
185 starrer_pref_check_function=lambda u: u.notify_starred_ping)
186
187 commenter_view = users_by_id[comment.user_id]
188 email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
189 group_reason_list, issue, body_link_only, body_for_non_members,
190 body_for_members, project, hostport, commenter_view, detail_url,
191 seq_num=comment.sequence, subject_prefix='Follow up on issue ',
192 compact_subject_prefix='Follow up ')
193
194 return email_tasks
195
196 def _CalculateIssuePings(self, issue, config):
197 """Return a list of (field, timestamp) pairs for dates that should ping."""
198 timestamp_min, timestamp_max = _GetTimestampRange(int(time.time()))
199 arrived_dates_by_field_id = {
200 fv.field_id: fv.date_value
201 for fv in issue.field_values
202 if timestamp_min <= fv.date_value < timestamp_max}
203 logging.info('arrived_dates_by_field_id = %r', arrived_dates_by_field_id)
204 # TODO(jrobbins): Lookup field defs regardless of project_id to better
205 # handle foreign fields in issues that have been moved between projects.
206 pings = [
207 (field, arrived_dates_by_field_id[field.field_id])
208 for field in config.field_defs
209 if (field.field_id in arrived_dates_by_field_id and
210 field.date_action in (tracker_pb2.DateAction.PING_OWNER_ONLY,
211 tracker_pb2.DateAction.PING_PARTICIPANTS))]
212
213 # TODO(jrobbins): For now, assume all pings apply only to open issues.
214 # Later, allow each date action to specify whether it applies to open
215 # issues or all issues.
216 means_open = tracker_helpers.MeansOpenInProject(
217 tracker_bizobj.GetStatus(issue), config)
218 pings = [ping for ping in pings if means_open]
219
220 pings = sorted(pings, key=lambda ping: ping[0].field_name)
221 return pings
222
223 def _FormatPingLine(self, ping):
224 """Return a one-line string describing the date that arrived."""
225 field, timestamp = ping
226 date_str = timestr.TimestampToDateWidgetStr(timestamp)
227 return 'The %s date has arrived: %s' % (field.field_name, date_str)