Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1 | # 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. |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 4 | |
| 5 | """Cron and task handlers for email notifications of issue date value arrival. |
| 6 | |
| 7 | If an issue has a date-type custom field, and that custom field is configured |
| 8 | to perform an action when that date arrives, then this cron handler and the |
| 9 | associated tasks carry out those actions on that issue. |
| 10 | """ |
| 11 | |
| 12 | from __future__ import division |
| 13 | from __future__ import print_function |
| 14 | from __future__ import absolute_import |
| 15 | |
| 16 | import logging |
| 17 | import time |
| 18 | |
| 19 | import ezt |
| 20 | |
| 21 | import settings |
| 22 | |
| 23 | from features import notify_helpers |
| 24 | from features import notify_reasons |
| 25 | from framework import cloud_tasks_helpers |
| 26 | from framework import framework_constants |
| 27 | from framework import framework_helpers |
| 28 | from framework import framework_views |
| 29 | from framework import jsonfeed |
| 30 | from framework import permissions |
| 31 | from framework import timestr |
| 32 | from framework import urls |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 33 | from mrproto import tracker_pb2 |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 34 | from tracker import tracker_bizobj |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 35 | from tracker import tracker_views |
| 36 | |
| 37 | |
| 38 | TEMPLATE_PATH = framework_constants.TEMPLATE_PATH |
| 39 | |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 40 | |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 41 | class DateActionCron(jsonfeed.InternalTask): |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 42 | """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ínez | 9f9ade5 | 2022-10-10 23:20:11 +0200 | [diff] [blame] | 86 | def GetDateActionCron(self, **kwargs): |
| 87 | return self.handler(**kwargs) |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 88 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 89 | |
| 90 | def _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 | |
| 98 | class 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))] |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 214 | 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ínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 222 | |
Adrià Vilanova Martínez | 9f9ade5 | 2022-10-10 23:20:11 +0200 | [diff] [blame] | 223 | def PostIssueDateActionTask(self, **kwargs): |
| 224 | return self.handler(**kwargs) |