Refactored reminders and added prepublication notice. (#2262)

* Refactored reminders and added prepublication notice.

* Added links to references

* Improved tests

* Update internals/reminders.py

Co-authored-by: Daniel Smith <56164590+DanielRyanSmith@users.noreply.github.com>

* Updated comment

Co-authored-by: Daniel Smith <56164590+DanielRyanSmith@users.noreply.github.com>
This commit is contained in:
Jason Robbins 2022-09-23 10:23:55 -07:00 коммит произвёл GitHub
Родитель d4d0a9b063
Коммит 84cbd9016a
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
7 изменённых файлов: 392 добавлений и 163 удалений

Просмотреть файл

@ -17,6 +17,9 @@ cron:
- description: Send reminders to verify the accuracy of feature data.
url: /cron/send_accuracy_notifications
schedule: every monday 09:00
- description: Send reminder to check summary before publication.
url: /cron/send_prepublication
schedule: every tuesday 09:00
- description: Notify any users that have been inactive for 6 months.
url: /cron/warn_inactive_users
schedule: 1st monday of month 9:00

Просмотреть файл

@ -18,13 +18,11 @@ __author__ = 'ericbidelman@chromium.org (Eric Bidelman)'
from datetime import datetime, timedelta
import collections
import logging
import json
import os
import urllib
from framework import permissions
from google.cloud import ndb
import requests
from django.template.loader import render_to_string
from django.utils.html import conditional_escape as escape
@ -320,95 +318,6 @@ class NotifyInactiveUsersHandler(basehandlers.FlaskHandler):
return email_tasks
class FeatureAccuracyHandler(basehandlers.FlaskHandler):
JSONIFY = True
CHROME_RELEASE_SCHEDULE_URL = (
'https://chromiumdash.appspot.com/fetch_milestone_schedule')
ACCURACY_AS_OF_WEEKS = 4
MILESTONE_FIELDS = (
'dt_milestone_android_start',
'dt_milestone_desktop_start',
'dt_milestone_ios_start',
'dt_milestone_webview_start',
'ot_milestone_android_start',
'ot_milestone_desktop_start',
'ot_milestone_webview_start',
'shipped_android_milestone',
'shipped_ios_milestone',
'shipped_milestone',
'shipped_webview_milestone'
)
EMAIL_TEMPLATE_PATH = 'accuracy_notice_email.html'
def get_template_data(self):
"""Sends notifications to users requesting feature updates for accuracy."""
self.require_cron_header()
features_to_notify = self._determine_features_to_notify()
email_tasks = self._build_email_tasks(features_to_notify)
send_emails(email_tasks)
return {'message': f'{len(email_tasks)} email(s) sent or logged.'}
def _determine_features_to_notify(self):
# 'current' milestone is the next stable milestone that hasn't landed.
# We send notifications to any feature planned for beta or stable launch
# in the next 8 weeks. Beta for (current + 2) starts in roughly 8 weeks.
# So we check if any features have launches in these 3 milestones
# (current, current + 1, and current + 2).
try:
resp = requests.get(f'{self.CHROME_RELEASE_SCHEDULE_URL}?mstone=current')
except requests.RequestException as e:
raise e
mstone_info = json.loads(resp.text)
mstone = int(mstone_info['mstones'][0]['mstone'])
features = core_models.Feature.query(
core_models.Feature.deleted == False).fetch(None)
features_to_notify = []
now = datetime.now()
accuracy_as_of_delta = timedelta(weeks=self.ACCURACY_AS_OF_WEEKS)
for feature in features:
# If the data has been recently verified as accurate, no need for email.
if (feature.accurate_as_of is not None and
feature.accurate_as_of + accuracy_as_of_delta < now):
continue
# Check each milestone field and see if it corresponds with
# the next 3 milestones. Use the closest milestone for the email.
closest_mstone = None
for field in self.MILESTONE_FIELDS:
launch_mstone = getattr(feature, field)
if (launch_mstone is not None and
launch_mstone >= mstone and launch_mstone <= mstone + 2):
if closest_mstone is None:
closest_mstone = launch_mstone
else:
closest_mstone = min(closest_mstone, launch_mstone)
if closest_mstone is not None:
features_to_notify.append((feature, closest_mstone))
return features_to_notify
def _build_email_tasks(self, features_to_notify):
email_tasks = []
for feature, mstone in features_to_notify:
body_data = {
'id': feature.key.integer_id(),
'feature': feature,
'site_url': settings.SITE_URL,
'milestone': mstone,
}
html = render_to_string(self.EMAIL_TEMPLATE_PATH, body_data)
subject = f'[Action requested] Update {feature.name}'
for owner in feature.owner:
email_tasks.append({
'to': owner,
'subject': subject,
'reply_to': None,
'html': html
})
return email_tasks
class FeatureChangeHandler(basehandlers.FlaskHandler):
"""This task handles a feature creation or update by making email tasks."""

Просмотреть файл

@ -505,77 +505,6 @@ class FeatureStarTest(testing_config.CustomTestCase):
[au.email for au in actual])
class MockResponse:
"""Creates a fake response object for testing."""
def __init__(self, status_code=200, text='{}'):
self.status_code = status_code
self.text = text
class FeatureAccuracyHandlerTest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = core_models.Feature(
name='feature one', summary='sum', owner=['feature_owner@example.com'],
category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=1,
ot_milestone_desktop_start=100)
self.feature_1.put()
self.feature_2 = core_models.Feature(
name='feature two', summary='sum',
owner=['owner_1@example.com', 'owner_2@example.com'],
category=1, visibility=1, standardization=1,
web_dev_views=1, impl_status_chrome=1, shipped_milestone=150)
self.feature_2.put()
self.feature_3 = core_models.Feature(
name='feature three', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=1)
self.feature_3.put()
def tearDown(self):
self.feature_1.key.delete()
self.feature_2.key.delete()
self.feature_3.key.delete()
@mock.patch('requests.get')
def test_determine_features_to_notify__no_features(self, mock_get):
mock_return = MockResponse(text='{"mstones":[{"mstone": "40"}]}')
mock_get.return_value = mock_return
accuracy_notifier = notifier.FeatureAccuracyHandler()
result = accuracy_notifier.get_template_data()
expected = {'message': '0 email(s) sent or logged.'}
self.assertEqual(result, expected)
@mock.patch('requests.get')
def test_determine_features_to_notify__valid_features(self, mock_get):
mock_return = MockResponse(text='{"mstones":[{"mstone": "100"}]}')
mock_get.return_value = mock_return
accuracy_notifier = notifier.FeatureAccuracyHandler()
result = accuracy_notifier.get_template_data()
expected = {'message': '1 email(s) sent or logged.'}
self.assertEqual(result, expected)
@mock.patch('requests.get')
def test_determine_features_to_notify__multiple_owners(self, mock_get):
mock_return = MockResponse(text='{"mstones":[{"mstone": "148"}]}')
mock_get.return_value = mock_return
accuracy_notifier = notifier.FeatureAccuracyHandler()
result = accuracy_notifier.get_template_data()
expected = {'message': '2 email(s) sent or logged.'}
self.assertEqual(result, expected)
def test_build_email_tasks(self):
accuracy_notifier = notifier.FeatureAccuracyHandler()
actual = accuracy_notifier._build_email_tasks([(self.feature_1, 100)])
self.assertEqual(1, len(actual))
task = actual[0]
self.assertEqual('feature_owner@example.com', task['to'])
self.assertEqual('[Action requested] Update feature one', task['subject'])
self.assertEqual(None, task['reply_to'])
self.assertIn('/guide/verify_accuracy/%d' % self.feature_1.key.integer_id(),
task['html'])
class NotifyInactiveUsersHandlerTest(testing_config.CustomTestCase):
def setUp(self):

177
internals/reminders.py Normal file
Просмотреть файл

@ -0,0 +1,177 @@
# Copyright 2022 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License")
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from datetime import datetime, timedelta
import json
import requests
from django.template.loader import render_to_string
from framework import basehandlers
from internals import core_models
from internals import notifier
import settings
CHROME_RELEASE_SCHEDULE_URL = (
'https://chromiumdash.appspot.com/fetch_milestone_schedule')
def get_current_milestone_info():
"""Return a dict of info about the next milestone reaching beta."""
try:
resp = requests.get(f'{CHROME_RELEASE_SCHEDULE_URL}?mstone=current')
except requests.RequestException as e:
raise e
mstone_info = json.loads(resp.text)
return mstone_info['mstones'][0]
def build_email_tasks(
features_to_notify, subject_format, body_template_path,
current_milestone_info):
email_tasks = []
beta_date = datetime.fromisoformat(current_milestone_info['earliest_beta'])
beta_date_str = beta_date.strftime('%Y-%m-%d')
for feature, mstone in features_to_notify:
body_data = {
'id': feature.key.integer_id(),
'feature': feature,
'site_url': settings.SITE_URL,
'milestone': mstone,
'beta_date_str': beta_date_str,
}
html = render_to_string(body_template_path, body_data)
subject = subject_format % feature.name
for owner in feature.owner:
email_tasks.append({
'to': owner,
'subject': subject,
'reply_to': None,
'html': html
})
return email_tasks
class AbstractReminderHandler(basehandlers.FlaskHandler):
JSONIFY = True
SUBJECT_FORMAT = '%s'
EMAIL_TEMPLATE_PATH = None # Subclasses must override
FUTURE_MILESTONES_TO_CONSIDER = 0
MILESTONE_FIELDS = None # Subclasses must override
def get_template_data(self):
"""Sends notifications to users requesting feature updates for accuracy."""
self.require_cron_header()
current_milestone_info = get_current_milestone_info()
features_to_notify = self.determine_features_to_notify(
current_milestone_info)
email_tasks = build_email_tasks(
features_to_notify, self.SUBJECT_FORMAT, self.EMAIL_TEMPLATE_PATH,
current_milestone_info)
notifier.send_emails(email_tasks)
return {'message': f'{len(email_tasks)} email(s) sent or logged.'}
def prefilter_features(self, current_milestone_info, features):
"""Return a list of features that fit class-specific criteria."""
return features # Defaults to no prefiltering.
def filter_by_milestones(self, current_milestone_info, features):
"""Return [(feature, milestone)] for features with a milestone in range."""
# 'current' milestone is the next stable milestone that hasn't landed.
# We send notifications to any feature planned for beta or stable launch
# in the next 4 * FUTURE_MILESTONES_TO_CONSIDER weeks.
min_mstone = int(current_milestone_info['mstone'])
max_mstone = min_mstone + self.FUTURE_MILESTONES_TO_CONSIDER
result = []
for feature in features:
field_values = [getattr(feature, field) for field in self.MILESTONE_FIELDS]
matching_values = [
m for m in field_values
if m is not None and m >= min_mstone and m <= max_mstone]
if matching_values:
result.append((feature, min(matching_values)))
return result
def determine_features_to_notify(self, current_milestone_info):
"""Get all features filter them by class-specific and milestone criteria."""
features = core_models.Feature.query(
core_models.Feature.deleted == False).fetch(None)
prefiltered_features = self.prefilter_features(
current_milestone_info, features)
features_milestone_pairs = self.filter_by_milestones(
current_milestone_info, prefiltered_features)
return features_milestone_pairs
class FeatureAccuracyHandler(AbstractReminderHandler):
"""Periodically remind owners to verify the accuracy of their entries."""
ACCURACY_GRACE_PERIOD = timedelta(weeks=4)
SUBJECT_FORMAT = '[Action requested] Update %s'
EMAIL_TEMPLATE_PATH = 'accuracy_notice_email.html'
FUTURE_MILESTONES_TO_CONSIDER = 2
MILESTONE_FIELDS = (
'dt_milestone_android_start',
'dt_milestone_desktop_start',
'dt_milestone_ios_start',
'dt_milestone_webview_start',
'ot_milestone_android_start',
'ot_milestone_desktop_start',
'ot_milestone_webview_start',
'shipped_android_milestone',
'shipped_ios_milestone',
'shipped_milestone',
'shipped_webview_milestone')
def prefilter_features(self, current_milestone_info, features):
now = datetime.now()
prefiltered_features = [
feature for feature in features
# It needs review if never reviewed, or if grace period has passed.
if (feature.accurate_as_of is None or
feature.accurate_as_of + self.ACCURACY_GRACE_PERIOD < now)]
return prefiltered_features
class PrepublicationHandler(AbstractReminderHandler):
"""Give feature owners a final preview just before publication."""
SUBJECT_FORMAT = '[Action requested] Review %s'
EMAIL_TEMPLATE_PATH = 'prepublication-notice-email.html'
MILESTONE_FIELDS = (
'shipped_android_milestone',
'shipped_ios_milestone',
'shipped_milestone',
'shipped_webview_milestone')
# Devrel copies summaries 1 week before the beta goes live.
PUBLICATION_LEAD_TIME = timedelta(weeks=1)
# We remind owners 1 week before that.
REMINDER_WINDOW = timedelta(weeks=1)
def prefilter_features(self, current_milestone_info, features, now=None):
earliest_beta = datetime.fromisoformat(
current_milestone_info['earliest_beta'])
now = now or datetime.now()
window_end = earliest_beta - self.PUBLICATION_LEAD_TIME
window_start = window_end - self.REMINDER_WINDOW
if now >= window_start and now <= window_end:
# If we are in the reminder window, process all releveant features.
return features
else:
# If this cron is running on an off week, do nothing.
return []

147
internals/reminders_test.py Normal file
Просмотреть файл

@ -0,0 +1,147 @@
# Copyright 2022 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License")
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import testing_config # Must be imported before the module under test.
from datetime import datetime
from unittest import mock
from internals import core_models
from internals import reminders
class MockResponse:
"""Creates a fake response object for testing."""
def __init__(self, status_code=200, text='{}'):
self.status_code = status_code
self.text = text
def make_test_features():
feature_1 = core_models.Feature(
name='feature one', summary='sum', owner=['feature_owner@example.com'],
category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=1,
ot_milestone_desktop_start=100)
feature_1.put()
feature_2 = core_models.Feature(
name='feature two', summary='sum',
owner=['owner_1@example.com', 'owner_2@example.com'],
category=1, visibility=1, standardization=1,
web_dev_views=1, impl_status_chrome=1, shipped_milestone=150)
feature_2.put()
feature_3 = core_models.Feature(
name='feature three', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=1)
feature_3.put()
return feature_1, feature_2, feature_3
class FunctionTest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1, self.feature_2, self.feature_3 = make_test_features()
self.current_milestone_info = {
'earliest_beta': '2022-09-21T12:34:56',
}
def tearDown(self):
self.feature_1.key.delete()
self.feature_2.key.delete()
self.feature_3.key.delete()
def test_build_email_tasks(self):
actual = reminders.build_email_tasks(
[(self.feature_1, 100)], '[Action requested] Update %s',
reminders.FeatureAccuracyHandler.EMAIL_TEMPLATE_PATH,
self.current_milestone_info)
self.assertEqual(1, len(actual))
task = actual[0]
self.assertEqual('feature_owner@example.com', task['to'])
self.assertEqual('[Action requested] Update feature one', task['subject'])
self.assertEqual(None, task['reply_to'])
self.assertIn('/guide/verify_accuracy/%d' % self.feature_1.key.integer_id(),
task['html'])
class FeatureAccuracyHandlerTest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1, self.feature_2, self.feature_3 = make_test_features()
self.handler = reminders.FeatureAccuracyHandler()
def tearDown(self):
self.feature_1.key.delete()
self.feature_2.key.delete()
self.feature_3.key.delete()
@mock.patch('requests.get')
def test_determine_features_to_notify__no_features(self, mock_get):
mock_return = MockResponse(
text=('{"mstones":[{"mstone": "40", '
'"earliest_beta": "2018-01-01T01:23:45"}]}'))
mock_get.return_value = mock_return
result = self.handler.get_template_data()
expected = {'message': '0 email(s) sent or logged.'}
self.assertEqual(result, expected)
@mock.patch('requests.get')
def test_determine_features_to_notify__valid_features(self, mock_get):
mock_return = MockResponse(
text=('{"mstones":[{"mstone": "100", '
'"earliest_beta": "2022-08-01T01:23:45"}]}'))
mock_get.return_value = mock_return
result = self.handler.get_template_data()
expected = {'message': '1 email(s) sent or logged.'}
self.assertEqual(result, expected)
@mock.patch('requests.get')
def test_determine_features_to_notify__multiple_owners(self, mock_get):
mock_return = MockResponse(
text=('{"mstones":[{"mstone": "148", '
'"earliest_beta": "2024-02-03T01:23:45"}]}'))
mock_get.return_value = mock_return
result = self.handler.get_template_data()
expected = {'message': '2 email(s) sent or logged.'}
self.assertEqual(result, expected)
class PrepublicationHandlerTest(testing_config.CustomTestCase):
def setUp(self):
self.current_milestone_info = {
'earliest_beta': '2022-09-21T12:34:56',
}
self.handler = reminders.PrepublicationHandler()
def test_prefilter_features__off_week(self):
"""No reminders sent because the next beta is far future or past."""
features = ['mock feature']
mock_now = datetime(2022, 9, 1) # Way before beta.
actual = self.handler.prefilter_features(
self.current_milestone_info, features, now=mock_now)
self.assertEqual([], actual)
mock_now = datetime(2022, 9, 18) # After DevRel cut-off.
actual = self.handler.prefilter_features(
self.current_milestone_info, features, now=mock_now)
self.assertEqual([], actual)
def test_prefilter_features__on_week(self):
"""Reminders are sent because the next beta is coming up."""
features = ['mock feature']
mock_now = datetime(2022, 9, 12)
actual = self.handler.prefilter_features(
self.current_milestone_info, features, now=mock_now)
self.assertEqual(['mock feature'], actual)

Просмотреть файл

@ -38,6 +38,7 @@ from internals import data_backup
from internals import inactive_users
from internals import schema_migration
from internals import deprecate_field
from internals import reminders
from pages import blink_handler
from pages import featurelist
from pages import guide
@ -189,7 +190,8 @@ internals_routes = [
('/cron/histograms', fetchmetrics.HistogramsHandler),
('/cron/update_blink_components', fetchmetrics.BlinkComponentHandler),
('/cron/export_backup', data_backup.BackupExportHandler),
('/cron/send_accuracy_notifications', notifier.FeatureAccuracyHandler),
('/cron/send_accuracy_notifications', reminders.FeatureAccuracyHandler),
('/cron/send_prepublication', reminders.PrepublicationHandler),
('/cron/warn_inactive_users', notifier.NotifyInactiveUsersHandler),
('/cron/remove_inactive_users', inactive_users.RemoveInactiveUsersHandler),
('/cron/schema_migration_comment_activity', schema_migration.MigrateCommentsToActivities),

Просмотреть файл

@ -0,0 +1,62 @@
<p>Your feature is slated to ship in M{{milestone}} which will reach the
beta channel on {{beta_date_str}}.</p>
<p>About one week before that date, the summary field of your feature
entry will be used verbatim (in blog posts and enterprise release
notes) to announce this new milestone. These posts are widely read by
web developers and IT admins, and they are sometimes used as the basis
for popular press articles.</p>
<p>Please take a look at
<a href="https://developer.chrome.com/tags/new-in-chrome/"
>past blog posts</a> and
<a href="https://support.google.com/chrome/a/answer/7679408"
>enterprise release notes</a>,
and then revise your feature summary to prepare it for
publication.</p>
<p>Here's what we have now for publication:</p>
<h3>{{feature.name}}<h3>
<p>{{feature.summary}}</p>
<p><a href="{{site_url}}/guide/edit/{{id}}">Edit your feature</a></p>
<p>Guidelines for writing the summary:</p>
<ul>
<li>Provide a one sentence description followed by one or two lines
explaining how this feature works and how it helps web
developers.</li>
<li>Write from a web developer's point of view, not a browser developer's</li>
<li>Do not use markup or markdown because they will not be rendered.</li>
<li>Do not use hard or soft returns because they will not be rendered.</li>
<li>Avoid phrases such as "a new feature". Every feature on the site
was new when it was created. You don't need to repeat that
information.</li>
<li>The first line should be a sentence fragment beginning with a
verb. (See below.) This is the rare exception to the requirement to
always use complete sentences.</li>
<li>"Conformance with spec" is not adequate. Most if not all
features are in conformance to spec.</li>
</ul>
<p>Example:</p>
<p style="border: 1px solid #444; padding: 1em; margin: 1em">
Splits the HTTP cache using the top frame origin (and possibly
subframe origin) to prevent documents from one origin from knowing
whether a resource from another origin was cached. The HTTP cache is
currently one per profile, with a single namespace for all resources
and subresources regardless of origin or renderer process. Splitting
the cache on top frame origins helps the browser deflect
side-channel attacks where one site can detect resources in another
site's cache.
</p>