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