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