chromium-dashboard/internals/notifier.py

481 строка
17 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2017 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.
__author__ = 'ericbidelman@chromium.org (Eric Bidelman)'
from datetime import datetime, timedelta
import collections
import logging
import os
from typing import Optional
import urllib
from framework import permissions
from google.cloud import ndb # type: ignore
from flask import escape
from flask import render_template
from framework import basehandlers
from framework import cloud_tasks_helpers
from framework import users
import settings
from internals import approval_defs
from internals import core_enums
from internals.core_models import Feature, FeatureEntry
from internals.user_models import (
AppUser, BlinkComponent, FeatureOwner, UserPref)
def format_email_body(is_update, feature, changes):
"""Return an HTML string for a notification email body."""
if feature.shipped_milestone:
milestone_str = feature.shipped_milestone
elif feature.shipped_milestone is None and feature.shipped_android_milestone:
milestone_str = '%s (android)' % feature.shipped_android_milestone
else:
milestone_str = 'not yet assigned'
moz_link_urls = [
link for link in feature.doc_links
if urllib.parse.urlparse(link).hostname == 'developer.mozilla.org']
formatted_changes = ''
for prop in changes:
prop_name = prop['prop_name']
new_val = prop['new_val']
old_val = prop['old_val']
formatted_changes += ('<li><b>%s:</b> <br/><b>old:</b> %s <br/>'
'<b>new:</b> %s<br/></li><br/>' %
(prop_name, escape(old_val), escape(new_val)))
if not formatted_changes:
formatted_changes = '<li>None</li>'
body_data = {
'feature': feature,
'creator_email': feature.created_by.email(),
'updater_email': feature.updated_by.email(),
'id': feature.key.integer_id(),
'milestone': milestone_str,
'status': core_enums.IMPLEMENTATION_STATUS[feature.impl_status_chrome],
'formatted_changes': formatted_changes,
'moz_link_urls': moz_link_urls,
}
template_path = ('update-feature-email.html' if is_update
else 'new-feature-email.html')
body = render_template(template_path, **body_data)
return body
def accumulate_reasons(
addr_reasons: dict[str, list], addr_list: list[str], reason: str) -> None:
"""Add a reason string for each user."""
for email in addr_list:
addr_reasons[email].append(reason)
def convert_reasons_to_task(
addr, reasons, email_html, subject, triggering_user_email):
"""Add a task dict to task_list for each user who has not already got one."""
assert reasons, 'We are emailing someone without any reason'
footer_lines = ['<p>You are receiving this email because:</p>', '<ul>']
for reason in sorted(set(reasons)):
footer_lines.append('<li>%s</li>' % reason)
footer_lines.append('</ul>')
footer_lines.append('<p><a href="%ssettings">Unsubscribe</a></p>' %
settings.SITE_URL)
email_html_with_footer = email_html + '\n\n' + '\n'.join(footer_lines)
reply_to = None
recipient_user = users.User(email=addr)
if permissions.can_create_feature(recipient_user):
reply_to = triggering_user_email
one_email_task = {
'to': addr,
'subject': subject,
'reply_to': reply_to,
'html': email_html_with_footer
}
return one_email_task
WEBVIEW_RULE_REASON = (
'This feature has an android milestone, but not a webview milestone')
WEBVIEW_RULE_ADDRS = ['webview-leads-external@google.com']
def apply_subscription_rules(
feature: Feature, changes: list) -> dict[str, list[str]]:
"""Return {"reason": [addrs]} for users who set up rules."""
# Note: for now this is hard-coded, but it will eventually be
# configurable through some kind of user preference.
changed_field_names = {c['prop_name'] for c in changes}
results: dict[str, list[str]] = {}
# Check if feature has some other milestone set, but not webview.
if (feature.shipped_android_milestone and
not feature.shipped_webview_milestone):
milestone_fields = ['shipped_android_milestone']
if not changed_field_names.isdisjoint(milestone_fields):
results[WEBVIEW_RULE_REASON] = WEBVIEW_RULE_ADDRS
return results
def make_email_tasks(feature: Feature, is_update: bool=False,
changes: Optional[list]=None):
"""Return a list of task dicts to notify users of feature changes."""
if changes is None:
changes = []
watchers: list[FeatureOwner] = FeatureOwner.query(
FeatureOwner.watching_all_features == True).fetch(None)
watcher_emails: list[str] = [watcher.email for watcher in watchers]
email_html = format_email_body(is_update, feature, changes)
if is_update:
subject = 'updated feature: %s' % feature.name
triggering_user_email = feature.updated_by.email()
else:
subject = 'new feature: %s' % feature.name
triggering_user_email = feature.created_by.email()
addr_reasons: dict[str, list[str]] = collections.defaultdict(list)
accumulate_reasons(
addr_reasons, feature.owner,
'You are listed as an owner of this feature'
)
accumulate_reasons(
addr_reasons, feature.editors,
'You are listed as an editor of this feature'
)
accumulate_reasons(
addr_reasons, feature.cc_recipients,
'You are CC\'d on this feature'
)
accumulate_reasons(
addr_reasons, watcher_emails,
'You are watching all feature changes')
# There will always be at least one component.
for component_name in feature.blink_components:
component = BlinkComponent.get_by_name(component_name)
if not component:
logging.warning('Blink component "%s" not found.'
'Not sending email to subscribers' % component_name)
continue
owner_emails: list[str] = [owner.email for owner in component.owners]
subscriber_emails: list[str] = [sub.email for sub in component.subscribers]
accumulate_reasons(
addr_reasons, owner_emails,
'You are an owner of this feature\'s component')
accumulate_reasons(
addr_reasons, subscriber_emails,
'You subscribe to this feature\'s component')
starrers = FeatureStar.get_feature_starrers(feature.key.integer_id())
starrer_emails: list[str] = [user.email for user in starrers]
accumulate_reasons(addr_reasons, starrer_emails, 'You starred this feature')
rule_results = apply_subscription_rules(feature, changes)
for reason, sub_addrs in rule_results.items():
accumulate_reasons(addr_reasons, sub_addrs, reason)
all_tasks = [convert_reasons_to_task(
addr, reasons, email_html, subject, triggering_user_email)
for addr, reasons in sorted(addr_reasons.items())]
return all_tasks
class FeatureStar(ndb.Model):
"""A FeatureStar represent one user's interest in one feature."""
email = ndb.StringProperty(required=True)
feature_id = ndb.IntegerProperty(required=True)
# This is so that we do not sync a bell to a star that the user has removed.
starred = ndb.BooleanProperty(default=True)
@classmethod
def get_star(self, email, feature_id):
"""If that user starred that feature, return the model or None."""
q = FeatureStar.query()
q = q.filter(FeatureStar.email == email)
q = q.filter(FeatureStar.feature_id == feature_id)
return q.get()
@classmethod
def set_star(self, email, feature_id, starred=True):
"""Set/clear a star for the specified user and feature."""
feature_star = self.get_star(email, feature_id)
if not feature_star and starred:
feature_star = FeatureStar(email=email, feature_id=feature_id)
feature_star.put()
elif feature_star and feature_star.starred != starred:
feature_star.starred = starred
feature_star.put()
else:
return # No need to update anything in datastore
# Load feature directly from NDB so as to never get a stale cached copy.
feature = Feature.get_by_id(feature_id)
feature.star_count += 1 if starred else -1
if feature.star_count < 0:
logging.error('count would be < 0: %r', (email, feature_id, starred))
return
feature.put(notify=False)
feature_entry = FeatureEntry.get_by_id(feature_id)
feature_entry.star_count += 1 if starred else -1
if feature_entry.star_count < 0:
logging.error('count would be < 0: %r', (email, feature_id, starred))
return
feature_entry.put() # And, do not call notify.
@classmethod
def get_user_stars(self, email):
"""Return a list of feature_ids of all features that the user starred."""
q = FeatureStar.query()
q = q.filter(FeatureStar.email == email)
q = q.filter(FeatureStar.starred == True)
feature_stars = q.fetch(None)
logging.info('found %d stars for %r', len(feature_stars), email)
feature_ids = [fs.feature_id for fs in feature_stars]
logging.info('returning %r', feature_ids)
return sorted(feature_ids, reverse=True)
@classmethod
def get_feature_starrers(self, feature_id: int) -> list[UserPref]:
"""Return list of UserPref objects for starrers that want notifications."""
q = FeatureStar.query()
q = q.filter(FeatureStar.feature_id == feature_id)
q = q.filter(FeatureStar.starred == True)
feature_stars: list[FeatureStar] = q.fetch(None)
logging.info('found %d stars for %r', len(feature_stars), feature_id)
emails: list[str] = [fs.email for fs in feature_stars]
logging.info('looking up %r', repr(emails)[:settings.MAX_LOG_LINE])
user_prefs = UserPref.get_prefs_for_emails(emails)
user_prefs = [up for up in user_prefs
if up.notify_as_starrer and not up.bounced]
return user_prefs
class NotifyInactiveUsersHandler(basehandlers.FlaskHandler):
JSONIFY = True
DEFAULT_LAST_VISIT = datetime(2022, 8, 1) # 2022-08-01
INACTIVE_WARN_DAYS = 180
EMAIL_TEMPLATE_PATH = 'inactive_user_email.html'
def get_template_data(self, **kwargs):
"""Notify any users that have been inactive for 6 months."""
self.require_cron_header()
now = kwargs.get('now', datetime.now())
users_to_notify = self._determine_users_to_notify(now)
email_tasks = self._build_email_tasks(users_to_notify)
send_emails(email_tasks)
message_parts = [f'{len(email_tasks)} users notified of inactivity.',
'Notified users:']
for task in email_tasks:
message_parts.append(task['to'])
message = '\n'.join(message_parts)
logging.info(message)
return {'message': message}
def _determine_users_to_notify(self, now=None):
# date var can be passed in for testing purposes.
if now is None:
now = datetime.now()
q = AppUser.query()
users = q.fetch()
inactive_users = []
inactive_cutoff = now - timedelta(days=self.INACTIVE_WARN_DAYS)
for user in users:
# Site admins and editors aren't warned due to inactivity.
# Also, users that have been previously notified are not notified again.
if user.is_admin or user.is_site_editor or user.notified_inactive:
continue
# If the user does not have a last visit, it is assumed the last visit
# is roughly the date the last_visit field was added.
last_visit = user.last_visit or self.DEFAULT_LAST_VISIT
# Notify the user of inactivity if they haven't already been notified.
if (last_visit < inactive_cutoff):
inactive_users.append(user.email)
user.notified_inactive = True
user.put()
return inactive_users
def _build_email_tasks(self, users_to_notify):
email_tasks = []
for email in users_to_notify:
body_data = {'site_url': settings.SITE_URL}
html = render_template(self.EMAIL_TEMPLATE_PATH, **body_data)
subject = f'Notice of WebStatus user inactivity for {email}'
email_tasks.append({
'to': email,
'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."""
IS_INTERNAL_HANDLER = True
def process_post_data(self, **kwargs):
self.require_task_header()
feature = self.get_param('feature')
is_update = self.get_bool_param('is_update')
changes = self.get_param('changes', required=False) or []
logging.info('Starting to notify subscribers for feature %s',
repr(feature)[:settings.MAX_LOG_LINE])
# Email feature subscribers if the feature exists and there were
# actually changes to it.
# Load feature directly from NDB so as to never get a stale cached copy.
feature = Feature.get_by_id(feature['id'])
if feature and (is_update and len(changes) or not is_update):
email_tasks = make_email_tasks(
feature, is_update=is_update, changes=changes)
send_emails(email_tasks)
return {'message': 'Done'}
BLINK_DEV_ARCHIVE_URL_PREFIX = (
'https://groups.google.com/a/chromium.org/d/msgid/blink-dev/')
TEST_ARCHIVE_URL_PREFIX = (
'https://groups.google.com/d/msgid/jrobbins-test/')
def get_existing_thread_subject(feature, approval_field):
"""If we have the subject line of the Google Groups thread, use it."""
# This improves message threading in gmail.
if approval_field == approval_defs.PrototypeApproval:
return feature.intent_to_implement_subject_line
# TODO(jrobbins): Ready-for-trial threads
elif approval_field == approval_defs.ExperimentApproval:
return feature.intent_to_experiment_subject_line
elif approval_field == approval_defs.ExtendExperimentApproval:
return feature.intent_to_extend_experiment_subject_line
elif approval_field == approval_defs.ShipApproval:
return feature.intent_to_ship_subject_line
else:
raise ValueError('Unexpected approval type')
def generate_thread_subject(feature, approval_field):
"""Use the expected subject based on the feature type and approval type."""
intent_phrase = approval_field.name
if feature.feature_type == core_enums.FEATURE_TYPE_DEPRECATION_ID:
if approval_field == approval_defs.PrototypeApproval:
intent_phrase = 'Intent to Deprecate and Remove'
if approval_field == approval_defs.ExperimentApproval:
intent_phrase = 'Request for Deprecation Trial'
if approval_field == approval_defs.ExtendExperimentApproval:
intent_phrase = 'Intent to Extend Deprecation Trial'
return '%s: %s' % (intent_phrase, feature.name)
def get_thread_id(feature, approval_field):
"""If we have the URL of the Google Groups thread, we can get its ID."""
if approval_field == approval_defs.PrototypeApproval:
thread_url = feature.intent_to_implement_url
# TODO(jrobbins): Ready-for-trial threads
if approval_field == approval_defs.ExperimentApproval:
thread_url = feature.intent_to_experiment_url
if approval_field == approval_defs.ExtendExperimentApproval:
thread_url = feature.intent_to_extend_experiment_url
if approval_field == approval_defs.ShipApproval:
thread_url = feature.intent_to_ship_url
if not thread_url:
return None
thread_url = thread_url.split('#')[0] # Chop off any anchor
thread_url = thread_url.split('?')[0] # Chop off any query string params
thread_url = urllib.parse.unquote(thread_url) # Convert %40 to @.
thread_id = None
if thread_url.startswith(BLINK_DEV_ARCHIVE_URL_PREFIX):
thread_id = thread_url[len(BLINK_DEV_ARCHIVE_URL_PREFIX):]
if thread_url.startswith(TEST_ARCHIVE_URL_PREFIX):
thread_id = thread_url[len(TEST_ARCHIVE_URL_PREFIX):]
return thread_id
def send_emails(email_tasks):
"""Process a list of email tasks (send or log)."""
logging.info('Processing %d email tasks', len(email_tasks))
for task in email_tasks:
if settings.SEND_EMAIL:
cloud_tasks_helpers.enqueue_task(
'/tasks/outbound-email', task)
else:
logging.info(
'Would send the following email:\n'
'To: %s\n'
'From: %s\n'
'References: %s\n'
'Reply-To: %s\n'
'Subject: %s\n'
'Body:\n%s',
task.get('to', None),
task.get('from_user', None),
task.get('references', None),
task.get('reply_to', None),
task.get('subject', None),
task.get('html', "")[:settings.MAX_LOG_LINE])
def post_comment_to_mailing_list(
feature, approval_field_id, author_addr, comment_content):
"""Post a message to the intent thread."""
to_addr = settings.REVIEW_COMMENT_MAILING_LIST
from_user = author_addr.split('@')[0]
approval_field = approval_defs.APPROVAL_FIELDS_BY_ID[approval_field_id]
subject = (get_existing_thread_subject(feature, approval_field) or
generate_thread_subject(feature, approval_field))
if not subject.startswith('Re: '):
subject = 'Re: ' + subject
thread_id = get_thread_id(feature, approval_field)
references = None
if thread_id:
references = '<%s>' % thread_id
html = render_template(
'review-comment-email.html', comment_content=comment_content)
email_task = {
'to': to_addr,
'from_user': from_user,
'references': references,
'subject': subject,
'html': html,
}
send_emails([email_task])