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)