Merge pull request #495 from GoogleChrome/subscribers

Subscribers and owners
This commit is contained in:
Eric Bidelman 2017-08-20 09:08:13 -07:00 коммит произвёл GitHub
Родитель 6108bdd2dc 88815d96a4
Коммит 9d5eaf080a
8 изменённых файлов: 303 добавлений и 88 удалений

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

@ -65,6 +65,11 @@ handlers:
login: required # non-admin
secure: always
- url: /admin/subscribers.*
script: blink_handler.app
login: required # non-admin
secure: always
- url: /admin/features/.*
script: admin.app
secure: always

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

@ -30,9 +30,9 @@ import settings
import util
class PopulateOwnersHandler(common.ContentHandler):
class PopulateSubscribersHandler(common.ContentHandler):
def __populate_devrel_owers(self):
def __populate_subscribers(self):
"""Seeds the database with the team in devrel_team.yaml and adds the team
member to the specified blink components in that file. Should only be ran
if the FeatureOwner database entries have been cleared"""
@ -42,13 +42,13 @@ class PopulateOwnersHandler(common.ContentHandler):
blink_components = [models.BlinkComponent.get_by_name(name).key() for name in blink_components]
blink_components = filter(None, blink_components) # Filter out None values
owner = models.FeatureOwner(
user = models.FeatureOwner(
name=unicode(profile['name']),
email=unicode(profile['email']),
twitter=profile.get('twitter', None),
blink_components=blink_components
)
owner.put()
user.put()
f.close()
@common.require_whitelisted_user
@ -56,24 +56,30 @@ class PopulateOwnersHandler(common.ContentHandler):
if settings.PROD:
return self.response.out.write('Handler not allowed in production.')
models.BlinkComponent.update_db()
self.__populate_devrel_owers()
self.__populate_subscribers()
return self.redirect('/admin/blink')
class BlinkHandler(common.ContentHandler):
def __update_owners_list(self, add=True, user_id=None, blink_component=None):
def __update_subscribers_list(self, add=True, user_id=None, blink_component=None, primary=False):
if not user_id or not blink_component:
return False
owner = models.FeatureOwner.get_by_id(long(user_id))
if not owner:
user = models.FeatureOwner.get_by_id(long(user_id))
if not user:
return True
if add:
owner.add_as_component_owner(blink_component)
if primary:
if add:
user.add_as_component_owner(blink_component)
else:
user.remove_as_component_owner(blink_component)
else:
owner.remove_from_component_owners(blink_component)
if add:
user.add_to_component_subscribers(blink_component)
else:
user.remove_from_component_subscribers(blink_component)
return True
@ -85,41 +91,72 @@ class BlinkHandler(common.ContentHandler):
# data = memcache.get(key)
# if data is None:
components = models.BlinkComponent.all().order('name').fetch(None)
owners = models.FeatureOwner.all().order('name').fetch(None)
subscribers = models.FeatureOwner.all().order('name').fetch(None)
# Format for django template
owners = [x.format_for_template() for x in owners]
subscribers = [x.format_for_template() for x in subscribers]
for c in components:
c.primaries = [o.name for o in c.owners]
# wf_component_content = models.BlinkComponent.fetch_wf_content_for_components()
# for c in components:
# c.wf_urls = wf_component_content.get(c.name) or []
data = {
'owners': owners,
'subscribers': subscribers,
'components': components[1:] # ditch generic "Blink" component
}
# memcache.set(key, data)
self.render(data, template_path=os.path.join('admin/blink.html'))
# Remove user from component subscribers.
def put(self, path):
params = json.loads(self.request.body)
self.__update_owners_list(False, user_id=params.get('userId'),
blink_component=params.get('componentName'))
self.response.set_status(200, message='User removed from owners')
self.__update_subscribers_list(False, user_id=params.get('userId'),
blink_component=params.get('componentName'),
primary=params.get('primary'))
self.response.set_status(200, message='User removed from subscribers')
return self.response.write(json.dumps({'done': True}))
# Add user to component subscribers.
def post(self, path):
params = json.loads(self.request.body)
self.__update_owners_list(True, user_id=params.get('userId'),
blink_component=params.get('componentName'))
self.__update_subscribers_list(True, user_id=params.get('userId'),
blink_component=params.get('componentName'),
primary=params.get('primary'))
# memcache.flush_all()
# memcache.delete('%s|blinkcomponentowners' % (settings.MEMCACHE_KEY_PREFIX))
self.response.set_status(200, message='User added to owners')
self.response.set_status(200, message='User added to subscribers')
return self.response.write(json.dumps(params))
class SubscribersHandler(common.ContentHandler):
@common.require_whitelisted_user
# @common.strip_trailing_slash
def get(self, path):
subscribers = models.FeatureOwner.all().order('name').fetch(None)
# Format for django template
# subscribers = [x.format_for_template() for x in subscribers]
for s in subscribers:
s.subscribed_components = [models.BlinkComponent.get(key) for key in s.blink_components]
s.owned_components = [models.BlinkComponent.get(key) for key in s.primary_blink_components]
data = {
'subscribers': subscribers,
}
self.render(data, template_path=os.path.join('admin/subscribers.html'))
app = webapp2.WSGIApplication([
('/admin/blink/populate_owners', PopulateOwnersHandler),
('/admin/blink/populate_subscribers', PopulateSubscribersHandler),
('/admin/subscribers(.*)', SubscribersHandler),
('(.*)', BlinkHandler),
], debug=settings.DEBUG)

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

@ -158,6 +158,11 @@ indexes:
- name: blink_components
- name: name
- kind: FeatureOwner
properties:
- name: primary_blink_components
- name: name
- kind: StableInstance
properties:
- name: bucket_id

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

@ -229,10 +229,13 @@ class BlinkComponent(DictModel):
created = db.DateTimeProperty(auto_now_add=True)
updated = db.DateTimeProperty(auto_now=True)
@property
def subscribers(self):
return FeatureOwner.all().filter('blink_components = ', self.key()).order('name').fetch(None)
@property
def owners(self):
q = FeatureOwner.all().filter('blink_components = ', self.key()).order('name')
return q.fetch(None)
return FeatureOwner.all().filter('primary_blink_components = ', self.key()).order('name').fetch(None)
@classmethod
def fetch_all_components(self, update_cache=False):
@ -289,6 +292,7 @@ class BlinkComponent(DictModel):
return None
return component[0]
# UMA metrics.
class StableInstance(DictModel):
created = db.DateTimeProperty(auto_now_add=True)
@ -743,8 +747,8 @@ class Feature(DictModel):
old_val = getattr(self, prop_name, None)
setattr(self, '_old_' + prop_name, old_val)
def __notify_feature_owners_of_changes(self, is_update):
"""Async notifies owners of new features and property changes to features by
def __notify_feature_subscribers_of_changes(self, is_update):
"""Async notifies subscribers of new features and property changes to features by
posting to a task queue."""
# Diff values to see what properties have changed.
changed_props = []
@ -761,9 +765,9 @@ class Feature(DictModel):
'feature': self.format_for_template(version=2)
})
# Create task to email owners.
# Create task to email subscribers.
queue = taskqueue.Queue()#name='emailer')
task = taskqueue.Task(method="POST", url='/tasks/email-owners',
task = taskqueue.Task(method="POST", url='/tasks/email-subscribers',
target='notifier', payload=payload)
queue.add(task)
@ -777,7 +781,7 @@ class Feature(DictModel):
def put(self, **kwargs):
is_update = self.is_saved()
key = super(Feature, self).put(**kwargs)
self.__notify_feature_owners_of_changes(is_update)
self.__notify_feature_subscribers_of_changes(is_update)
return key
# Metadata.
@ -1009,33 +1013,62 @@ class AppUser(DictModel):
updated = db.DateTimeProperty(auto_now=True)
def list_with_component(l, component):
return [x for x in l if x.id() == component.key().id()]
def list_without_component(l, component):
return [x for x in l if x.id() != component.key().id()]
class FeatureOwner(DictModel):
"""Describes owner of a web platform feature."""
"""Describes subscribers of a web platform feature."""
created = db.DateTimeProperty(auto_now_add=True)
updated = db.DateTimeProperty(auto_now=True)
name = db.StringProperty(required=True)
email = db.EmailProperty(required=True)
twitter = db.StringProperty()
blink_components = db.ListProperty(db.Key)
primary_blink_components = db.ListProperty(db.Key)
watching_all_features = db.BooleanProperty(default=False)
def add_as_component_owner(self, component_name):
"""Adds the user to the list of Blink component owners."""
def add_to_component_subscribers(self, component_name):
"""Adds the user to the list of Blink component subscribers."""
c = BlinkComponent.get_by_name(component_name)
if c:
already_added = len([x for x in self.blink_components if x.id() == c.key().id()])
if not already_added:
# Add the user if they're not already in the list.
if not len(list_with_component(self.blink_components, c)):
self.blink_components.append(c.key())
return self.put()
return None
def remove_from_component_owners(self, component_name):
"""Removes the user from the list of Blink component owners."""
def remove_from_component_subscribers(self, component_name, remove_as_owner=False):
"""Removes the user from the list of Blink component subscribers or as the owner
of the component."""
c = BlinkComponent.get_by_name(component_name)
if c:
self.blink_components = [x for x in self.blink_components if x.id() != c.key().id()]
if remove_as_owner:
self.primary_blink_components = list_without_component(self.primary_blink_components, c)
else:
self.blink_components = list_without_component(self.blink_components, c)
self.primary_blink_components = list_without_component(self.primary_blink_components, c)
return self.put()
return None
def add_as_component_owner(self, component_name):
"""Adds the user as the Blink component owner."""
c = BlinkComponent.get_by_name(component_name)
if c:
# Update both the primary list and blink components subscribers if the
# user is not already in them.
self.add_to_component_subscribers(component_name)
if not len(list_with_component(self.primary_blink_components, c)):
self.primary_blink_components.append(c.key())
return self.put()
return None
def remove_as_component_owner(self, component_name):
return self.remove_from_component_subscribers(component_name, remove_as_owner=True)
class HistogramModel(db.Model):
"""Container for a histogram."""

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

@ -56,19 +56,27 @@ def create_wf_content_list(component):
list = '<li>None</li>'
return list
def email_feature_owners(feature, is_update=False, changes=[]):
def email_feature_subscribers(feature, is_update=False, changes=[]):
feature_watchers = models.FeatureOwner.all().filter('watching_all_features = ', True).fetch(None)
for component_name in feature.blink_components:
component = models.BlinkComponent.get_by_name(component_name)
if not component:
logging.warn('Blink component "%s" not found. Not sending email to owners' % component_name)
logging.warn('Blink component "%s" not found. Not sending email to subscribers' % component_name)
return
# owners = component.owners
# TODO: restrict emails to me for now to see if they're not too noisy.
owners = models.FeatureOwner.all().filter('email = ', 'e.bidelman@google.com').fetch(1)
def list_diff(subscribers, owners):
"""Returns list B - A."""
owner_ids = [x.key().id() for x in owners]
return [x for x in subscribers if not x.key().id() in owner_ids]
if not owners:
logging.info('Blink component "%s" has no owners. Skipping email.' % component_name)
# TODO: switch back
owners = [] #component.owners
# subscribers = list_diff(component.subscribers, owners) + feature_watchers
subscribers = models.FeatureOwner.all().filter('email = ', 'e.bidelman@google.com').fetch(1)
if not subscribers and not owners:
logging.info('Blink component "%s" has no subscribers or owners. Skipping email.' % component_name)
return
if feature.shipped_milestone:
@ -78,12 +86,16 @@ def email_feature_owners(feature, is_update=False, changes=[]):
else:
milestone_str = 'not yet assigned'
intro = 'You are listed as an owner for web platform features under "{component_name}"'.format(component_name=component_name)
if not owners:
intro = 'Just letting you know that there\'s a new feature under "{component_name}".'.format(component_name=component_name)
created_on = datetime.datetime.strptime(str(feature.created), "%Y-%m-%d %H:%M:%S.%f").date()
new_msg = """
<html><body>
<p>Hi {owners},</p>
<p>You are listed as a web platform owner for "{component_name}". {created_by} added a new feature to this component:</p>
<p>{intro}. {created_by} added a new feature to this component:</p>
<hr>
<p><b><a href="https://www.chromestatus.com/feature/{id}">{name}</a></b> (added {created})</p>
@ -99,10 +111,11 @@ def email_feature_owners(feature, is_update=False, changes=[]):
<li>Add a sample to https://github.com/GoogleChrome/samples (see <a href="https://github.com/GoogleChrome/samples#contributing-samples">contributing</a>).</li>
<li>Don't forget add your demo link to the <a href="https://www.chromestatus.com/admin/features/edit/{id}">chromestatus feature entry</a>.</li>
</ul>
<p>CC'd on this email? Feel free to reply-all if you can help with these tasks.</p>
</body></html>
""".format(name=feature.name, id=feature.key().id(), created=created_on,
created_by=feature.created_by, component_name=component_name,
owners=', '.join([owner.name for owner in owners]), milestone=milestone_str,
created_by=feature.created_by, intro=intro,
owners=', '.join([o.name for o in owners]), milestone=milestone_str,
status=models.IMPLEMENTATION_STATUS[feature.impl_status_chrome])
updated_on = datetime.datetime.strptime(str(feature.updated), "%Y-%m-%d %H:%M:%S.%f").date()
@ -112,10 +125,14 @@ def email_feature_owners(feature, is_update=False, changes=[]):
if not formatted_changes:
formatted_changes = '<li>None</li>'
intro = 'You are listed as an owner for web platform features under "{component_name}"'.format(component_name=component_name)
if not owners:
intro = 'Just letting you know that a feature under "{component_name}" has changed.'.format(component_name=component_name)
update_msg = """<html><body>
<p>Hi {owners},</p>
<p>You are listed as a web platform owner for "{component_name}". {updated_by} updated this feature:</p>
<p>{intro}. {updated_by} updated this feature:</p>
<hr>
<p><b><a href="https://www.chromestatus.com/feature/{id}">{name}</a></b> (updated {updated})</p>
@ -135,17 +152,23 @@ def email_feature_owners(feature, is_update=False, changes=[]):
<ul>{wf_content}</ul>
</li>
</ul>
<p>CC'd on this email? Feel free to reply-all if can help.</p>
</body></html>
""".format(name=feature.name, id=feature.key().id(), updated=updated_on,
updated_by=feature.updated_by, component_name=component_name,
owners=', '.join([owner.name for owner in owners]), milestone=milestone_str,
updated_by=feature.updated_by, intro=intro,
owners=', '.join([o.name for o in owners]), milestone=milestone_str,
status=models.IMPLEMENTATION_STATUS[feature.impl_status_chrome],
formatted_changes=formatted_changes,
wf_content=create_wf_content_list(component_name))
message = mail.EmailMessage(sender='Chromestatus <admin@cr-status.appspotmail.com>',
subject='update',
to=[owner.email for owner in owners])
cc=[s.email for s in subscribers])
# Only include to: line if there are feature owners. Otherwise, we'll just use cc.
if owners:
message.to = [s.email for s in owners]
if is_update:
message.html = update_msg
@ -160,7 +183,7 @@ def email_feature_owners(feature, is_update=False, changes=[]):
message.send()
class EmailOwnersHandler(webapp2.RequestHandler):
class EmailHandler(webapp2.RequestHandler):
def post(self):
json_body = json.loads(self.request.body)
@ -168,10 +191,10 @@ class EmailOwnersHandler(webapp2.RequestHandler):
is_update = json_body.get('is_update') or False
changes = json_body.get('changes') or []
# Email feature owners if the feature exists and there were actually changes to it.
# Email feature subscribers if the feature exists and there were actually changes to it.
feature = models.Feature.get_by_id(feature['id'])
if feature and (is_update and len(changes) or not is_update):
email_feature_owners(feature, is_update=is_update, changes=changes)
email_feature_subscribers(feature, is_update=is_update, changes=changes)
class NotificationNewSubscriptionHandler(webapp2.RequestHandler):
@ -260,7 +283,7 @@ class NotificationSendHandler(webapp2.RequestHandler):
is_update = json_body.get('is_update') or False
changes = json_body.get('changes') or []
# Email feature owners if the feature exists and there were changes to it.
# Email feature subscribers if the feature exists and there were changes to it.
feature = models.Feature.get_by_id(feature['id'])
if feature and (is_update and len(changes) or not is_update):
self._send_notification_to_feature_subscribers(feature=feature, is_update=is_update)
@ -299,7 +322,7 @@ class NotificationsListHandler(common.ContentHandler):
app = webapp2.WSGIApplication([
('/admin/notifications/list', NotificationsListHandler),
('/tasks/email-owners', EmailOwnersHandler),
('/tasks/email-subscribers', EmailHandler),
('/tasks/send_notifications', NotificationSendHandler),
('/features/push/new', NotificationNewSubscriptionHandler),
('/features/push/info', NotificationSubscriptionInfoHandler),

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

@ -39,6 +39,9 @@ body {
}
}
}
.view_owners_linke {
margin-left: $content-padding;
}
}
#components_list {
list-style: none;
@ -79,6 +82,9 @@ body {
.owners_list {
flex: 1 0 auto;
}
.component_owner {
font-weight: 600;
}
.owners_list_add_remove {
margin-left: $content-padding / 2;
opacity: 0;
@ -95,7 +101,7 @@ body {
}
select[multiple] {
min-width: 125px;
min-width: 275px;
background-color: #eee;
border: none;
transition: 200ms background-color cubic-bezier(0,0,0.2,1);

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

@ -16,12 +16,15 @@
{% block subheader %}
<div id="subheader">
<div>
<h2>blink component owners</h2>
<div>listing {{components|length}} components</div>
<div class="layout horizontal center">
<div>
<h2>Blink components</h2>
<div>listing {{components|length}} components</div>
</div>
<a href="/admin/subscribers" class="view_owners_linke">View list by owner →</a>
</div>
<div class="layout horizontal subheader_toggles">
<paper-toggle-button noink>Edit owners</paper-toggle-button>
<paper-toggle-button noink>Edit mode</paper-toggle-button>
</div>
</div>
{% endblock %}
@ -60,20 +63,24 @@
</div>
<div class="owners_list layout horizontal center">
<div>
<div class="column_header">Owners</div>
<select multiple disabled id="owner_list_{{forloop.counter}}" size="{{c.owners|length}}">
{% for owner in c.owners %}
<option class="owner_name" value="{{owner.id}}">{{owner.name}}</option>
<div class="column_header">Receives email updates:</div>
<select multiple disabled id="owner_list_{{forloop.counter}}" size="{{c.subscribers|length}}">
{% for s in c.subscribers %}
<option class="owner_name {% if s.name in c.primaries %}component_owner{% endif %}"
value="{{s.id}}">{{s.name}}: {{s.email}}</option>
{% endfor %}
</select>
</div>
<div class="owners_list_add_remove">
<select class="owner_candidates">
<option selected disabled>Select owner to add/remove</option>
{% for owner in owners %}
<option class="owner_name" value="{{owner.id}}">{{owner.name}}</option>
{% endfor %}
</select>
<div>
<select class="owner_candidates">
<option selected disabled>Select owner to add/remove</option>
{% for s in subscribers %}
<option class="owner_name" value="{{s.id}}" data-email="{{s.email}}">{{s.name}}</option>
{% endfor %}
</select><br>
<label title="Toggles the user as an owner. If you click 'Remove' ans this is not checked, the user is removed from the component.">Owner? <input type="checkbox" class="is_primary_checkbox"></label>
</div>
<button class="add_owner_button" data-index="{{forloop.counter}}"
data-component-name="{{c.name}}">Add</button>
<button class="remove_owner_button" data-index="{{forloop.counter}}"
@ -107,11 +114,14 @@ $('#components_list').addEventListener('click', function(e) {
}
const candidates = e.target.parentElement.querySelector('.owner_candidates');
const primaryCheckbox = e.target.parentElement.querySelector('.is_primary_checkbox');
const idx = e.target.dataset.index;
const componentName = e.target.dataset.componentName;
const userId = candidates.value;
const selectedCandidate = candidates.selectedOptions[0];
const username = selectedCandidate.textContent;
const email = selectedCandidate.dataset.email;
const toggleAsOwner = primaryCheckbox.checked;
if (selectedCandidate.disabled) {
alert('Please select a user');
@ -120,40 +130,47 @@ $('#components_list').addEventListener('click', function(e) {
// Add new item to owners list.
const ownersList = this.querySelector(`#owner_list_${idx}`);
const foundName = Array.from(ownersList.options).find(option => option.textContent === username);
let doFetch = false;
const optionText = `${username}: ${email}`;
const foundName = Array.from(ownersList.options).find(option => option.textContent === optionText);
if (addUser) {
// Don't try to add user if they're already in the list, and we're not
// modifying their owner state.
if (foundName && !toggleAsOwner) {
return;
}
const option = document.createElement('option');
option.value = userId;
option.textContent = optionText;
if (!foundName) {
const option = document.createElement('option');
option.value = userId;
option.textContent = candidates.selectedOptions[0].textContent;
ownersList.appendChild(option);
doFetch = true;
}
if (toggleAsOwner) {
const el = foundName || option;
el.classList.add('component_owner');
}
} else if (removeUser && foundName) {
foundName.remove(); // remove existing name.
doFetch = true;
}
if (!doFetch) {
return;
if (toggleAsOwner) {
foundName.classList.remove('component_owner');
} else {
foundName.remove(); // remove existing name.
}
}
fetch('/admin/blink', {
method: removeUser ? 'PUT' : 'POST',
credentials: 'include',
body: JSON.stringify({userId, componentName})
body: JSON.stringify({userId, componentName, primary: toggleAsOwner})
})
.then(resp => resp.json())
.then(json => {
const Toast = document.querySelector('chromedash-toast');
if (addUser) {
Toast.showMessage(`User added to "${componentName}" owners.`);
} else if (removeUser) {
Toast.showMessage(`User removed from "${componentName}" owners.`);
}
Toast.showMessage(`"${componentName}" updated.`);
ownersList.size = ownersList.options.length;
primaryCheckbox.checked = false;
});
});

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

@ -0,0 +1,89 @@
{% extends "base.html" %}
{% load verbatim %}
{% load inline_file %}
{% block html_imports %}
<link rel="import" href="/static/elements/admin-imports{% if VULCANIZE %}.vulcanize{% endif %}.html">
{% endblock %}
{% block css %}
<!-- <style>{% inline_file "/static/css/blink.css" %}</style> -->
<style>
:root {
--padding: 8px;
}
#users_list {
margin-bottom: 100px;
}
#users_list > li {
padding: var(--padding) 0;
}
.component_list {
list-style: none;
padding: var(--padding) calc(var(--padding) * 2);
}
.component_list li {
margin-left: calc(var(--padding) * 2);
}
.layout.horizontal {
display: flex;
}
.layout.horizontal.center {
align-items: center;
}
#subheader a {
margin-left: calc(var(--padding) * 3);
}
.user_name {
min-width: 175px;
}
</style>
{% endblock %}
{% block drawer %}
{% endblock %}
{% block subheader %}
<div id="subheader">
<div>
<h2>Feature owners</h2>
</div>
<a href="/admin/blink">View list as components →</a>
</div>
{% endblock %}
{% block content %}
<section>
<ul id="users_list">
{% for s in subscribers %}
<li class="layout horizontal center">
<div>
<h3 class="user_name">{{s.name}}</h3>
<h4><a href="mailto:{{s.email}}">{{s.email}}</a></h4>
</div>
<ul class="component_list layout horizontal center">
{% for c in s.owned_components %}
<li><a href="/admin/blink#{{c.name}}">{{c.name}}</a></li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
</section>
{% endblock %}
{% block js %}
<script>
(function() {
'use strict';
document.body.classList.remove('loading');
})();
</script>
{% endblock %}