Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/features/dateaction.py b/features/dateaction.py
new file mode 100644
index 0000000..a525db1
--- /dev/null
+++ b/features/dateaction.py
@@ -0,0 +1,227 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Cron and task handlers for email notifications of issue date value arrival.
+
+If an issue has a date-type custom field, and that custom field is configured
+to perform an action when that date arrives, then this cron handler and the
+associated tasks carry out those actions on that issue.
+"""
+
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+import settings
+
+from features import notify_helpers
+from features import notify_reasons
+from framework import cloud_tasks_helpers
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import jsonfeed
+from framework import permissions
+from framework import timestr
+from framework import urls
+from proto import tracker_pb2
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+TEMPLATE_PATH = framework_constants.TEMPLATE_PATH
+
+class DateActionCron(jsonfeed.InternalTask):
+ """Find and process issues with date-type values that arrived today."""
+
+ def HandleRequest(self, mr):
+ """Find issues with date-type-fields that arrived and spawn tasks."""
+ highest_iid_so_far = 0
+ capped = True
+ timestamp_min, timestamp_max = _GetTimestampRange(int(time.time()))
+ left_joins = [
+ ('Issue2FieldValue ON Issue.id = Issue2FieldValue.issue_id', []),
+ ('FieldDef ON Issue2FieldValue.field_id = FieldDef.id', []),
+ ]
+ where = [
+ ('FieldDef.field_type = %s', ['date_type']),
+ ('FieldDef.date_action IN (%s,%s)',
+ ['ping_owner_only', 'ping_participants']),
+ ('Issue2FieldValue.date_value >= %s', [timestamp_min]),
+ ('Issue2FieldValue.date_value < %s', [timestamp_max]),
+ ]
+ order_by = [
+ ('Issue.id', []),
+ ]
+ while capped:
+ chunk_issue_ids, capped = self.services.issue.RunIssueQuery(
+ mr.cnxn, left_joins,
+ where + [('Issue.id > %s', [highest_iid_so_far])], order_by)
+ if chunk_issue_ids:
+ logging.info('chunk_issue_ids = %r', chunk_issue_ids)
+ highest_iid_so_far = max(highest_iid_so_far, max(chunk_issue_ids))
+ for issue_id in chunk_issue_ids:
+ self.EnqueueDateAction(issue_id)
+
+ def EnqueueDateAction(self, issue_id):
+ """Create a task to notify users that an issue's date has arrived.
+
+ Args:
+ issue_id: int ID of the issue that was changed.
+
+ Returns nothing.
+ """
+ params = {'issue_id': issue_id}
+ task = cloud_tasks_helpers.generate_simple_task(
+ urls.ISSUE_DATE_ACTION_TASK + '.do', params)
+ cloud_tasks_helpers.create_task(task)
+
+
+def _GetTimestampRange(now):
+ """Return a (min, max) timestamp range for today."""
+ timestamp_min = (now // framework_constants.SECS_PER_DAY *
+ framework_constants.SECS_PER_DAY)
+ timestamp_max = timestamp_min + framework_constants.SECS_PER_DAY
+ return timestamp_min, timestamp_max
+
+
+class IssueDateActionTask(notify_helpers.NotifyTaskBase):
+ """JSON servlet that notifies appropriate users after an issue change."""
+
+ _EMAIL_TEMPLATE = 'features/auto-ping-email.ezt'
+ _LINK_ONLY_EMAIL_TEMPLATE = (
+ 'tracker/issue-change-notification-email-link-only.ezt')
+
+ def HandleRequest(self, mr):
+ """Process the task to process an issue date action.
+
+ Args:
+ mr: common information parsed from the HTTP request.
+
+ Returns:
+ Results dictionary in JSON format which is useful just for debugging.
+ The main goal is the side-effect of sending emails.
+ """
+ issue_id = mr.GetPositiveIntParam('issue_id')
+ issue = self.services.issue.GetIssue(mr.cnxn, issue_id, use_cache=False)
+ project = self.services.project.GetProject(mr.cnxn, issue.project_id)
+ hostport = framework_helpers.GetHostPort(project_name=project.project_name)
+ config = self.services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+ pings = self._CalculateIssuePings(issue, config)
+ if not pings:
+ logging.warning('Issue %r has no dates to ping afterall?', issue_id)
+ return
+ comment = self._CreatePingComment(mr.cnxn, issue, pings, hostport)
+ starrer_ids = self.services.issue_star.LookupItemStarrers(
+ mr.cnxn, issue.issue_id)
+
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user,
+ tracker_bizobj.UsersInvolvedInIssues([issue]),
+ tracker_bizobj.UsersInvolvedInComment(comment),
+ starrer_ids)
+ logging.info('users_by_id is %r', users_by_id)
+ tasks = self._MakeEmailTasks(
+ mr.cnxn, issue, project, config, comment, starrer_ids,
+ hostport, users_by_id, pings)
+
+ notified = notify_helpers.AddAllEmailTasks(tasks)
+ return {
+ 'notified': notified,
+ }
+
+ def _CreatePingComment(self, cnxn, issue, pings, hostport):
+ """Create an issue comment saying that some dates have arrived."""
+ content = '\n'.join(self._FormatPingLine(ping) for ping in pings)
+ author_email_addr = '%s@%s' % (settings.date_action_ping_author, hostport)
+ date_action_user_id = self.services.user.LookupUserID(
+ cnxn, author_email_addr, autocreate=True)
+ comment = self.services.issue.CreateIssueComment(
+ cnxn, issue, date_action_user_id, content)
+ return comment
+
+ def _MakeEmailTasks(
+ self, cnxn, issue, project, config, comment, starrer_ids,
+ hostport, users_by_id, pings):
+ """Return a list of dicts for tasks to notify people."""
+ detail_url = framework_helpers.IssueCommentURL(
+ hostport, project, issue.local_id, seq_num=comment.sequence)
+ fields = sorted((field_def for (field_def, _date_value) in pings),
+ key=lambda fd: fd.field_name)
+ email_data = {
+ 'issue': tracker_views.IssueView(issue, users_by_id, config),
+ 'summary': issue.summary,
+ 'ping_comment_content': comment.content,
+ 'detail_url': detail_url,
+ 'fields': fields,
+ }
+
+ # Generate three versions of email body with progressively more info.
+ body_link_only = self.link_only_email_template.GetResponse(
+ {'detail_url': detail_url, 'was_created': ezt.boolean(False)})
+ body_for_non_members = self.email_template.GetResponse(email_data)
+ framework_views.RevealAllEmails(users_by_id)
+ body_for_members = self.email_template.GetResponse(email_data)
+ logging.info('body for non-members is:\n%r' % body_for_non_members)
+ logging.info('body for members is:\n%r' % body_for_members)
+
+ contributor_could_view = permissions.CanViewIssue(
+ set(), permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+ project, issue)
+
+ group_reason_list = notify_reasons.ComputeGroupReasonList(
+ cnxn, self.services, project, issue, config, users_by_id,
+ [], contributor_could_view, starrer_ids=starrer_ids,
+ commenter_in_project=True, include_subscribers=False,
+ include_notify_all=False,
+ starrer_pref_check_function=lambda u: u.notify_starred_ping)
+
+ commenter_view = users_by_id[comment.user_id]
+ email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+ group_reason_list, issue, body_link_only, body_for_non_members,
+ body_for_members, project, hostport, commenter_view, detail_url,
+ seq_num=comment.sequence, subject_prefix='Follow up on issue ',
+ compact_subject_prefix='Follow up ')
+
+ return email_tasks
+
+ def _CalculateIssuePings(self, issue, config):
+ """Return a list of (field, timestamp) pairs for dates that should ping."""
+ timestamp_min, timestamp_max = _GetTimestampRange(int(time.time()))
+ arrived_dates_by_field_id = {
+ fv.field_id: fv.date_value
+ for fv in issue.field_values
+ if timestamp_min <= fv.date_value < timestamp_max}
+ logging.info('arrived_dates_by_field_id = %r', arrived_dates_by_field_id)
+ # TODO(jrobbins): Lookup field defs regardless of project_id to better
+ # handle foreign fields in issues that have been moved between projects.
+ pings = [
+ (field, arrived_dates_by_field_id[field.field_id])
+ for field in config.field_defs
+ if (field.field_id in arrived_dates_by_field_id and
+ field.date_action in (tracker_pb2.DateAction.PING_OWNER_ONLY,
+ tracker_pb2.DateAction.PING_PARTICIPANTS))]
+
+ # TODO(jrobbins): For now, assume all pings apply only to open issues.
+ # Later, allow each date action to specify whether it applies to open
+ # issues or all issues.
+ means_open = tracker_helpers.MeansOpenInProject(
+ tracker_bizobj.GetStatus(issue), config)
+ pings = [ping for ping in pings if means_open]
+
+ pings = sorted(pings, key=lambda ping: ping[0].field_name)
+ return pings
+
+ def _FormatPingLine(self, ping):
+ """Return a one-line string describing the date that arrived."""
+ field, timestamp = ping
+ date_str = timestr.TimestampToDateWidgetStr(timestamp)
+ return 'The %s date has arrived: %s' % (field.field_name, date_str)