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