Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1 | # 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 | |
| 8 | If an issue has a date-type custom field, and that custom field is configured |
| 9 | to perform an action when that date arrives, then this cron handler and the |
| 10 | associated tasks carry out those actions on that issue. |
| 11 | """ |
| 12 | |
| 13 | from __future__ import division |
| 14 | from __future__ import print_function |
| 15 | from __future__ import absolute_import |
| 16 | |
| 17 | import logging |
| 18 | import time |
| 19 | |
| 20 | import ezt |
| 21 | |
| 22 | import settings |
| 23 | |
| 24 | from features import notify_helpers |
| 25 | from features import notify_reasons |
| 26 | from framework import cloud_tasks_helpers |
| 27 | from framework import framework_constants |
| 28 | from framework import framework_helpers |
| 29 | from framework import framework_views |
| 30 | from framework import jsonfeed |
| 31 | from framework import permissions |
| 32 | from framework import timestr |
| 33 | from framework import urls |
| 34 | from proto import tracker_pb2 |
| 35 | from tracker import tracker_bizobj |
| 36 | from tracker import tracker_helpers |
| 37 | from tracker import tracker_views |
| 38 | |
| 39 | |
| 40 | TEMPLATE_PATH = framework_constants.TEMPLATE_PATH |
| 41 | |
| 42 | class 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 | |
| 88 | def _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 | |
| 96 | class 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) |