New permissions system implementation (#2011)

* Check edit permissions by individual feature

* update logic to check permissions

* editors field added

* editors can see edit access in UI

* myfeatures shows editable features

* Make editor field plural

* 1 more plural update

* change var name for site editor

* Editors can delete a feature

* Site editors can now be designated in admin ui

* fix existing tests to work with new changes

* feature and permissions tests

* accounts and models test

* new creator field

* update tests for creator field

* code cleanup

* add creator field to new feature

* unlisted features are displayed in "My Features"

If the user is the creator, an owner, or an editor.

* slight update in unlisted features logic

* My features displays all editable features

in a single dropdown.

* fix web tests

* add test to ensure creator can edit feature

* remove unused _reject_or_proceed() change

* remove mangle and log statement

* Set original permissions

All changes to permissions.py removed.
This commit can be reverted to enable to new permissions system.

* changes suggested by @jrobbins

* update from merge

* remove creator field for later implementation

* changes suggested by @jrobbins
This commit is contained in:
Daniel Smith 2022-07-13 14:31:27 -07:00 коммит произвёл GitHub
Родитель 8a66dbc810
Коммит 83e77b9d5b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
30 изменённых файлов: 580 добавлений и 269 удалений

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

@ -35,11 +35,12 @@ class AccountsAPI(basehandlers.APIHandler):
"""Process a request to create an account."""
email = self.get_param('email', required=True)
is_admin = self.get_bool_param('isAdmin')
user = self.create_account(email, is_admin)
is_site_editor = self.get_bool_param('isSiteEditor')
user = self.create_account(email, is_admin, is_site_editor)
response_json = user.format_for_template()
return response_json
def create_account(self, email, is_admin):
def create_account(self, email, is_admin, is_site_editor):
"""Create and store a new account entity."""
# Don't add a duplicate email address.
user = models.AppUser.query(
@ -47,6 +48,7 @@ class AccountsAPI(basehandlers.APIHandler):
if not user:
user = models.AppUser(email=str(email))
user.is_admin = is_admin
user.is_site_editor = is_site_editor
user.put()
return user
else:

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

@ -31,6 +31,11 @@ class AccountsAPITest(testing_config.CustomTestCase):
self.app_admin = models.AppUser(email='admin@example.com')
self.app_admin.is_admin = True
self.app_admin.put()
self.app_editor = models.AppUser(email='editor@example.com')
self.app_editor.is_site_editor = True
self.app_editor.put()
self.appuser_1 = models.AppUser(email='user@example.com')
self.appuser_1.put()
self.appuser_id = self.appuser_1.key.integer_id()
@ -41,30 +46,56 @@ class AccountsAPITest(testing_config.CustomTestCase):
def tearDown(self):
self.appuser_1.key.delete()
self.app_admin.key.delete()
self.app_editor.key.delete()
def test_create__normal_valid(self):
"""Admin wants to register a normal account."""
testing_config.sign_in('admin@example.com', 123567890)
json_data = {'email': 'new@example.com', 'isAdmin': False}
json_data = {
'email': 'new_user@example.com',
'isAdmin': False, 'isSiteEditor': False}
with test_app.test_request_context(self.request_path, json=json_data):
actual_json = self.handler.do_post()
self.assertEqual('new@example.com', actual_json['email'])
self.assertEqual('new_user@example.com', actual_json['email'])
self.assertFalse(actual_json['is_site_editor'])
self.assertFalse(actual_json['is_admin'])
new_appuser = (models.AppUser.query(
models.AppUser.email == 'new@example.com').get())
self.assertEqual('new@example.com', new_appuser.email)
models.AppUser.email == 'new_user@example.com').get())
self.assertEqual('new_user@example.com', new_appuser.email)
self.assertFalse(new_appuser.is_admin)
def test_create__site_editor_valid(self):
"""Admin wants to register a new site editor account."""
testing_config.sign_in('admin@example.com', 123567890)
json_data = {
'email': 'new_site_editor@example.com',
'isAdmin': False, 'isSiteEditor': True}
with test_app.test_request_context(self.request_path, json=json_data):
actual_json = self.handler.do_post()
self.assertEqual('new_site_editor@example.com', actual_json['email'])
self.assertFalse(actual_json['is_admin'])
self.assertTrue(actual_json['is_site_editor'])
new_appuser = models.AppUser.query(
models.AppUser.email == 'new_site_editor@example.com').get()
self.assertEqual('new_site_editor@example.com', new_appuser.email)
self.assertTrue(new_appuser.is_site_editor)
self.assertFalse(new_appuser.is_admin)
def test_create__admin_valid(self):
"""Admin wants to register a new admin account."""
testing_config.sign_in('admin@example.com', 123567890)
json_data = {'email': 'new_admin@example.com', 'isAdmin': True}
json_data = {
'email': 'new_admin@example.com',
'isAdmin': True, 'isSiteEditor': True}
with test_app.test_request_context(self.request_path, json=json_data):
actual_json = self.handler.do_post()
self.assertEqual('new_admin@example.com', actual_json['email'])
self.assertTrue(actual_json['is_site_editor'])
self.assertTrue(actual_json['is_admin'])
new_appuser = models.AppUser.query(
@ -84,6 +115,18 @@ class AccountsAPITest(testing_config.CustomTestCase):
models.AppUser.email == 'new@example.com').get()
self.assertIsNone(new_appuser)
def test_create__site_editor_forbidden(self):
"""Site editors cannot create an account."""
testing_config.sign_in('editor@example.com', 123567890)
with test_app.test_request_context(self.request_path):
with self.assertRaises(werkzeug.exceptions.Forbidden):
self.handler.do_post()
new_appuser = models.AppUser.query(
models.AppUser.email == 'new@example.com').get()
self.assertIsNone(new_appuser)
def test_create__invalid(self):
"""We cannot create an account without an email address."""
testing_config.sign_in('admin@example.com', 123567890)

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

@ -34,6 +34,7 @@ class FeaturesAPI(basehandlers.APIHandler):
def do_search(self):
user = users.get_current_user()
# Show unlisted features to site editors or admins.
show_unlisted_features = permissions.can_edit_feature(user, None)
features_on_page = None

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

@ -98,9 +98,10 @@ class FeaturesAPITestGet(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = models.Feature(
name='feature one', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=5,
intent_stage=models.INTENT_IMPLEMENT, shipped_milestone=1)
name='feature one', summary='sum', owner=['feature_owner@example.com'],
category=1, visibility=1, standardization=1, web_dev_views=1,
impl_status_chrome=5, intent_stage=models.INTENT_IMPLEMENT,
shipped_milestone=1)
self.feature_1.put()
self.feature_id = self.feature_1.key.integer_id()

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

@ -38,9 +38,10 @@ class PermissionsAPI(basehandlers.APIHandler):
'can_create_feature': permissions.can_create_feature(user),
'can_approve': permissions.can_approve_feature(
user, None, approvers),
'can_edit': permissions.can_edit_any_feature(user),
'can_edit_all': permissions.can_edit_feature(user, None),
'is_admin': permissions.can_admin_site(user),
'email': user.email(),
'editable_features': []
}
return {'user': user_data}

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

@ -50,14 +50,15 @@ class PermissionsAPITest(testing_config.CustomTestCase):
'user': {
'can_create_feature': False,
'can_approve': False,
'can_edit': False,
'can_edit_all': False,
'is_admin': False,
'email': 'one@example.com'
'email': 'one@example.com',
'editable_features': []
}}
self.assertEqual(expected, actual)
def test_get__googler(self):
"""Googlers have default permissions to create feature and edit."""
"""Googlers have default permissions to create and edit features."""
testing_config.sign_in('one@google.com', 67890)
with test_app.test_request_context(self.request_path):
actual = self.handler.do_get()
@ -65,8 +66,9 @@ class PermissionsAPITest(testing_config.CustomTestCase):
'user': {
'can_create_feature': True,
'can_approve': False,
'can_edit': True,
'can_edit_all': True,
'is_admin': False,
'email': 'one@google.com'
'email': 'one@google.com',
'editable_features': []
}}
self.assertEqual(expected, actual)

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

@ -318,8 +318,9 @@ class FlaskHandler(BaseHandler):
'can_create_feature': permissions.can_create_feature(user),
'can_approve': permissions.can_approve_feature(
user, None, approvers),
'can_edit': permissions.can_edit_any_feature(user),
'can_edit_all': permissions.can_edit_any_feature(user),
'is_admin': permissions.can_admin_site(user),
'editable_features': [],
'email': user.email(),
'dismissed_cues': json.dumps(user_pref.dismissed_cues),
}

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

@ -51,22 +51,48 @@ test_app = basehandlers.FlaskApplication(
class PermissionFunctionTests(testing_config.CustomTestCase):
def setUp(self):
self.users = []
self.app_user = models.AppUser(email='registered@example.com')
self.app_user.put()
self.users.append(self.app_user)
self.app_admin = models.AppUser(email='admin@example.com')
self.app_admin.is_admin = True
self.app_admin.put()
self.users.append(self.app_admin)
self.app_editor = models.AppUser(email='editor@example.com')
self.app_editor.is_site_editor = True
self.app_editor.put()
self.users.append(self.app_editor)
self.feature_owner = models.AppUser(email='feature_owner@example.com')
self.feature_owner.put()
self.users.append(self.feature_owner)
self.feature_editor = models.AppUser(email='feature_editor@example.com')
self.feature_editor.put()
self.users.append(self.feature_editor)
# Feature for checking permissions against
self.feature_1 = models.Feature(
name='feature one', summary='sum',
owner=['feature_owner@example.com'],
editors=['feature_editor@example.com'], category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=1)
self.feature_1.put()
self.feature_id = self.feature_1.key.integer_id()
def tearDown(self):
self.app_user.delete()
self.app_admin.delete()
for user in self.users:
user.delete()
self.feature_1.key.delete()
def check_function_results(
self, func, additional_args,
unregistered='missing', registered='missing',
special='missing', admin='missing', anon='missing'):
"""Test func under four conditions and check expected results."""
special='missing', site_editor='missing', admin='missing', anon='missing'):
"""Test func under six conditions and check expected results."""
# Test unregistered users
testing_config.sign_in('unregistered@example.com', 123)
user = users.get_current_user()
@ -86,6 +112,11 @@ class PermissionFunctionTests(testing_config.CustomTestCase):
user = users.get_current_user()
self.assertEqual(special, func(user, *additional_args))
# Test site editor user
testing_config.sign_in('editor@example.com', 123)
user = users.get_current_user()
self.assertEqual(site_editor, func(user, *additional_args))
# Test admin users
testing_config.sign_in('admin@example.com', 123)
user = users.get_current_user()
@ -96,48 +127,98 @@ class PermissionFunctionTests(testing_config.CustomTestCase):
user = users.get_current_user()
self.assertEqual(anon, func(user, *additional_args))
def check_function_results_with_feature(
self, func, additional_args, unregistered='missing',
registered='missing', feature_owner='missing', feature_editor='missing',
site_editor='missing', admin='missing'):
"""Test func in the context of a specific feature id."""
# Test unregistered users
testing_config.sign_in('unregistered@example.com', 123)
user = users.get_current_user()
self.assertEqual(unregistered, func(user, *additional_args))
# Test registered users
testing_config.sign_in('registered@example.com', 123)
user = users.get_current_user()
self.assertEqual(registered, func(user, *additional_args))
# Test feature owners
testing_config.sign_in('feature_owner@example.com', 123)
user = users.get_current_user()
self.assertEqual(feature_owner, func(user, *additional_args))
# Test feature editors
testing_config.sign_in('feature_editor@example.com', 123)
user = users.get_current_user()
self.assertEqual(feature_editor, func(user, *additional_args))
# Test site editor user
testing_config.sign_in('editor@example.com', 123)
user = users.get_current_user()
self.assertEqual(site_editor, func(user, *additional_args))
# Test admin users
testing_config.sign_in('admin@example.com', 123)
user = users.get_current_user()
self.assertEqual(admin, func(user, *additional_args))
def test_can_admin_site(self):
self.check_function_results(
permissions.can_admin_site, tuple(),
unregistered=False, registered=False,
special=False, admin=True, anon=False)
special=False, site_editor=False, admin=True, anon=False)
def test_can_view_feature(self):
self.check_function_results(
permissions.can_view_feature, (None,),
unregistered=True, registered=True,
special=True, admin=True, anon=True)
special=True, site_editor=True, admin=True, anon=True)
self.check_function_results_with_feature(
permissions.can_view_feature, (self.feature_id,),
unregistered=True, registered=True,
feature_owner=True, feature_editor=True,
site_editor=True, admin=True
)
def test_can_create_feature(self):
self.check_function_results(
permissions.can_create_feature, tuple(),
unregistered=False, registered=True,
special=True, admin=True, anon=False)
special=True, site_editor=True, admin=True, anon=False)
def test_can_edit_any_feature(self):
self.check_function_results(
permissions.can_edit_any_feature, tuple(),
unregistered=False, registered=True,
special=True, admin=True, anon=False)
special=True, site_editor=True, admin=True, anon=False)
def test_can_edit_feature(self):
self.check_function_results(
permissions.can_edit_feature, (None,),
unregistered=False, registered=True,
special=True, admin=True, anon=False)
special=True, site_editor=True, admin=True, anon=False)
# Check in context of specific feature.
self.check_function_results_with_feature(
permissions.can_edit_feature, (self.feature_id,),
unregistered=False, registered=True,
feature_owner=True, feature_editor=True,
site_editor=True, admin=True
)
def test_can_approve_feature(self):
approvers = []
self.check_function_results(
permissions.can_approve_feature, (None, approvers),
unregistered=False, registered=False,
special=False, admin=True, anon=False)
special=False, site_editor=False, admin=True, anon=False)
approvers = ['registered@example.com']
self.check_function_results(
permissions.can_approve_feature, (None, approvers),
unregistered=False, registered=True,
special=False, admin=True, anon=False)
special=False, site_editor=False, admin=True, anon=False)
class RequireAdminSiteTests(testing_config.CustomTestCase):
@ -146,12 +227,17 @@ class RequireAdminSiteTests(testing_config.CustomTestCase):
self.app_user = models.AppUser(email='registered@example.com')
self.app_user.put()
self.app_editor = models.AppUser(email='editor@example.com')
self.app_editor.is_site_editor = True
self.app_editor.put()
self.app_admin = models.AppUser(email='admin@example.com')
self.app_admin.is_admin = True
self.app_admin.put()
def tearDown(self):
self.app_user.delete()
self.app_editor.delete()
self.app_admin.delete()
def test_require_admin_site__unregistered_user(self):
@ -181,6 +267,15 @@ class RequireAdminSiteTests(testing_config.CustomTestCase):
handler.do_post()
self.assertEqual(handler.called_with, None)
def test_require_admin_site__editor(self):
"""Wrapped method rejects call from a site editor."""
handler = MockHandler()
testing_config.sign_in('editor@example.com', 123)
with test_app.test_request_context('/path', method='POST'):
with self.assertRaises(werkzeug.exceptions.Forbidden):
handler.do_post()
self.assertEqual(handler.called_with, None)
def test_require_admin_site__admin(self):
"""Wrapped method accepts call from an admin user."""
handler = MockHandler()

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

@ -202,6 +202,10 @@ indexes:
- name: "updated"
direction: desc
- name: "name"
- kind: Feature
properties:
- name: owner
- name: editors
- kind: "FeatureObserver"
properties:
- name: "bucket_id"

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

@ -633,6 +633,7 @@ class Feature(DictModel):
'docs': d.pop('doc_links', []),
}
d['tags'] = d.pop('search_tags', [])
d['editors'] = d.pop('editors', [])
d['browsers'] = {
'chrome': {
'bug': d.pop('bug_url', None),
@ -748,6 +749,7 @@ class Feature(DictModel):
d = self.to_dict()
#d['id'] = self.key().id
d['owner'] = ', '.join(self.owner)
d['editors'] = ', '.join(self.editors)
d['explainer_links'] = '\r\n'.join(self.explainer_links)
d['spec_mentors'] = ', '.join(self.spec_mentors)
d['standard_maturity'] = self.standard_maturity or UNKNOWN_STD
@ -778,10 +780,15 @@ class Feature(DictModel):
# TODO(ericbidelman): Support more than one filter.
if filterby:
if filterby[0] == 'category':
query = query.filter(Feature.category == filterby[1])
elif filterby[0] == 'owner':
query = query.filter(Feature.owner == filterby[1])
filter_type, comparator = filterby
if filter_type == 'can_edit':
# can_edit will check if the user has any access to edit the feature.
# This includes being an owner, editor, or the original creator
# of the feature.
query = query.filter(
ndb.OR(Feature.owner == comparator, Feature.editors == comparator))
else:
query = query.filter(getattr(Feature, filter_type) == comparator)
features = query.fetch(limit)
@ -879,6 +886,23 @@ class Feature(DictModel):
ramcache.set(KEY, feature)
return feature
@classmethod
def filter_unlisted(self, feature_list):
"""Filters a feature list to display only features the user should see."""
user = users.get_current_user()
email = None
if user:
email = user.email()
listed_features = []
for f in feature_list:
# Owners and editors of a feature should still be able to see their features.
if ((not f.get('unlisted', False)) or
('browsers' in f and email in f['browsers']['chrome']['owners']) or
(email in f.get('editors', []))):
listed_features.append(f)
return listed_features
@classmethod
def get_by_ids(self, feature_ids, update_cache=False):
@ -1000,11 +1024,9 @@ class Feature(DictModel):
ramcache.set(cache_key, feature_list)
allowed_feature_list = [
f for f in feature_list
if show_unlisted or not f['unlisted']]
return allowed_feature_list
if not show_unlisted:
feature_list = self.filter_unlisted(feature_list)
return feature_list
@classmethod
def get_in_milestone(
@ -1012,145 +1034,147 @@ class Feature(DictModel):
if milestone == None:
return None
cache_key = '%s|%s|%s|%s' % (
Feature.DEFAULT_CACHE_KEY, 'milestone', show_unlisted, milestone)
cached_allowed_features_by_type = ramcache.get(cache_key)
if cached_allowed_features_by_type:
return cached_allowed_features_by_type
all_features = {}
all_features[IMPLEMENTATION_STATUS[ENABLED_BY_DEFAULT]] = []
all_features[IMPLEMENTATION_STATUS[DEPRECATED]] = []
all_features[IMPLEMENTATION_STATUS[REMOVED]] = []
all_features[IMPLEMENTATION_STATUS[INTERVENTION]] = []
all_features[IMPLEMENTATION_STATUS[ORIGIN_TRIAL]] = []
all_features[IMPLEMENTATION_STATUS[BEHIND_A_FLAG]] = []
logging.info('Getting chronological feature list in milestone %d',
milestone)
# Start each query asynchronously in parallel.
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.shipped_milestone == milestone)
desktop_shipping_features_future = q.fetch_async(None)
# Features with an android shipping milestone but no desktop milestone.
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.shipped_android_milestone == milestone)
q = q.filter(Feature.shipped_milestone == None)
android_only_shipping_features_future = q.fetch_async(None)
# Features that are in origin trial (Desktop) in this milestone
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.ot_milestone_desktop_start == milestone)
desktop_origin_trial_features_future = q.fetch_async(None)
# Features that are in origin trial (Android) in this milestone
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.ot_milestone_android_start == milestone)
q = q.filter(Feature.ot_milestone_desktop_start == None)
android_origin_trial_features_future = q.fetch_async(None)
# Features that are in origin trial (Webview) in this milestone
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.ot_milestone_webview_start == milestone)
q = q.filter(Feature.ot_milestone_desktop_start == None)
webview_origin_trial_features_future = q.fetch_async(None)
# Features that are in dev trial (Desktop) in this milestone
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.dt_milestone_desktop_start == milestone)
desktop_dev_trial_features_future = q.fetch_async(None)
# Features that are in dev trial (Android) in this milestone
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.dt_milestone_android_start == milestone)
q = q.filter(Feature.dt_milestone_desktop_start == None)
android_dev_trial_features_future = q.fetch_async(None)
# Wait for all futures to complete.
desktop_shipping_features = desktop_shipping_features_future.result()
android_only_shipping_features = (
android_only_shipping_features_future.result())
desktop_origin_trial_features = (
desktop_origin_trial_features_future.result())
android_origin_trial_features = (
android_origin_trial_features_future.result())
webview_origin_trial_features = (
webview_origin_trial_features_future.result())
desktop_dev_trial_features = desktop_dev_trial_features_future.result()
android_dev_trial_features = android_dev_trial_features_future.result()
# Push feature to list corresponding to the implementation status of
# feature in queried milestone
for feature in desktop_shipping_features:
if feature.impl_status_chrome == ENABLED_BY_DEFAULT:
all_features[IMPLEMENTATION_STATUS[ENABLED_BY_DEFAULT]].append(feature)
elif feature.impl_status_chrome == DEPRECATED:
all_features[IMPLEMENTATION_STATUS[DEPRECATED]].append(feature)
elif feature.impl_status_chrome == REMOVED:
all_features[IMPLEMENTATION_STATUS[REMOVED]].append(feature)
elif feature.impl_status_chrome == INTERVENTION:
all_features[IMPLEMENTATION_STATUS[INTERVENTION]].append(feature)
elif (feature.feature_type == FEATURE_TYPE_DEPRECATION_ID and
Feature.dt_milestone_desktop_start != None):
all_features[IMPLEMENTATION_STATUS[DEPRECATED]].append(feature)
elif feature.feature_type == FEATURE_TYPE_INCUBATE_ID:
all_features[IMPLEMENTATION_STATUS[ENABLED_BY_DEFAULT]].append(feature)
# Push feature to list corresponding to the implementation status
# of feature in queried milestone
for feature in android_only_shipping_features:
if feature.impl_status_chrome == ENABLED_BY_DEFAULT:
all_features[IMPLEMENTATION_STATUS[ENABLED_BY_DEFAULT]].append(feature)
elif feature.impl_status_chrome == DEPRECATED:
all_features[IMPLEMENTATION_STATUS[DEPRECATED]].append(feature)
elif feature.impl_status_chrome == REMOVED:
all_features[IMPLEMENTATION_STATUS[REMOVED]].append(feature)
elif (feature.feature_type == FEATURE_TYPE_DEPRECATION_ID and
Feature.dt_milestone_android_start != None):
all_features[IMPLEMENTATION_STATUS[DEPRECATED]].append(feature)
elif feature.feature_type == FEATURE_TYPE_INCUBATE_ID:
all_features[IMPLEMENTATION_STATUS[ENABLED_BY_DEFAULT]].append(feature)
for feature in desktop_origin_trial_features:
all_features[IMPLEMENTATION_STATUS[ORIGIN_TRIAL]].append(feature)
for feature in android_origin_trial_features:
all_features[IMPLEMENTATION_STATUS[ORIGIN_TRIAL]].append(feature)
for feature in webview_origin_trial_features:
all_features[IMPLEMENTATION_STATUS[ORIGIN_TRIAL]].append(feature)
for feature in desktop_dev_trial_features:
all_features[IMPLEMENTATION_STATUS[BEHIND_A_FLAG]].append(feature)
for feature in android_dev_trial_features:
all_features[IMPLEMENTATION_STATUS[BEHIND_A_FLAG]].append(feature)
features_by_type = {}
allowed_features_by_type = {}
cache_key = '%s|%s|%s' % (
Feature.DEFAULT_CACHE_KEY, 'milestone', milestone)
cached_features_by_type = ramcache.get(cache_key)
if cached_features_by_type:
features_by_type = cached_features_by_type
else:
all_features = {}
all_features[IMPLEMENTATION_STATUS[ENABLED_BY_DEFAULT]] = []
all_features[IMPLEMENTATION_STATUS[DEPRECATED]] = []
all_features[IMPLEMENTATION_STATUS[REMOVED]] = []
all_features[IMPLEMENTATION_STATUS[INTERVENTION]] = []
all_features[IMPLEMENTATION_STATUS[ORIGIN_TRIAL]] = []
all_features[IMPLEMENTATION_STATUS[BEHIND_A_FLAG]] = []
# Construct results as: {type: [json_feature, ...], ...}.
for shippingType in all_features:
all_features[shippingType].sort(key=lambda f: f.name)
all_features[shippingType] = [
f for f in all_features[shippingType] if not f.deleted]
features_by_type[shippingType] = [
f.format_for_template() for f in all_features[shippingType]]
allowed_features_by_type[shippingType] = [
f for f in features_by_type[shippingType]
if show_unlisted or not f['unlisted']]
logging.info('Getting chronological feature list in milestone %d',
milestone)
# Start each query asynchronously in parallel.
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.shipped_milestone == milestone)
desktop_shipping_features_future = q.fetch_async(None)
ramcache.set(cache_key, allowed_features_by_type)
# Features with an android shipping milestone but no desktop milestone.
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.shipped_android_milestone == milestone)
q = q.filter(Feature.shipped_milestone == None)
android_only_shipping_features_future = q.fetch_async(None)
return allowed_features_by_type
# Features that are in origin trial (Desktop) in this milestone
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.ot_milestone_desktop_start == milestone)
desktop_origin_trial_features_future = q.fetch_async(None)
# Features that are in origin trial (Android) in this milestone
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.ot_milestone_android_start == milestone)
q = q.filter(Feature.ot_milestone_desktop_start == None)
android_origin_trial_features_future = q.fetch_async(None)
# Features that are in origin trial (Webview) in this milestone
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.ot_milestone_webview_start == milestone)
q = q.filter(Feature.ot_milestone_desktop_start == None)
webview_origin_trial_features_future = q.fetch_async(None)
# Features that are in dev trial (Desktop) in this milestone
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.dt_milestone_desktop_start == milestone)
desktop_dev_trial_features_future = q.fetch_async(None)
# Features that are in dev trial (Android) in this milestone
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.dt_milestone_android_start == milestone)
q = q.filter(Feature.dt_milestone_desktop_start == None)
android_dev_trial_features_future = q.fetch_async(None)
# Wait for all futures to complete.
desktop_shipping_features = desktop_shipping_features_future.result()
android_only_shipping_features = (
android_only_shipping_features_future.result())
desktop_origin_trial_features = (
desktop_origin_trial_features_future.result())
android_origin_trial_features = (
android_origin_trial_features_future.result())
webview_origin_trial_features = (
webview_origin_trial_features_future.result())
desktop_dev_trial_features = desktop_dev_trial_features_future.result()
android_dev_trial_features = android_dev_trial_features_future.result()
# Push feature to list corresponding to the implementation status of
# feature in queried milestone
for feature in desktop_shipping_features:
if feature.impl_status_chrome == ENABLED_BY_DEFAULT:
all_features[IMPLEMENTATION_STATUS[ENABLED_BY_DEFAULT]].append(feature)
elif feature.impl_status_chrome == DEPRECATED:
all_features[IMPLEMENTATION_STATUS[DEPRECATED]].append(feature)
elif feature.impl_status_chrome == REMOVED:
all_features[IMPLEMENTATION_STATUS[REMOVED]].append(feature)
elif feature.impl_status_chrome == INTERVENTION:
all_features[IMPLEMENTATION_STATUS[INTERVENTION]].append(feature)
elif (feature.feature_type == FEATURE_TYPE_DEPRECATION_ID and
Feature.dt_milestone_desktop_start != None):
all_features[IMPLEMENTATION_STATUS[DEPRECATED]].append(feature)
elif feature.feature_type == FEATURE_TYPE_INCUBATE_ID:
all_features[IMPLEMENTATION_STATUS[ENABLED_BY_DEFAULT]].append(feature)
# Push feature to list corresponding to the implementation status
# of feature in queried milestone
for feature in android_only_shipping_features:
if feature.impl_status_chrome == ENABLED_BY_DEFAULT:
all_features[IMPLEMENTATION_STATUS[ENABLED_BY_DEFAULT]].append(feature)
elif feature.impl_status_chrome == DEPRECATED:
all_features[IMPLEMENTATION_STATUS[DEPRECATED]].append(feature)
elif feature.impl_status_chrome == REMOVED:
all_features[IMPLEMENTATION_STATUS[REMOVED]].append(feature)
elif (feature.feature_type == FEATURE_TYPE_DEPRECATION_ID and
Feature.dt_milestone_android_start != None):
all_features[IMPLEMENTATION_STATUS[DEPRECATED]].append(feature)
elif feature.feature_type == FEATURE_TYPE_INCUBATE_ID:
all_features[IMPLEMENTATION_STATUS[ENABLED_BY_DEFAULT]].append(feature)
for feature in desktop_origin_trial_features:
all_features[IMPLEMENTATION_STATUS[ORIGIN_TRIAL]].append(feature)
for feature in android_origin_trial_features:
all_features[IMPLEMENTATION_STATUS[ORIGIN_TRIAL]].append(feature)
for feature in webview_origin_trial_features:
all_features[IMPLEMENTATION_STATUS[ORIGIN_TRIAL]].append(feature)
for feature in desktop_dev_trial_features:
all_features[IMPLEMENTATION_STATUS[BEHIND_A_FLAG]].append(feature)
for feature in android_dev_trial_features:
all_features[IMPLEMENTATION_STATUS[BEHIND_A_FLAG]].append(feature)
# Construct results as: {type: [json_feature, ...], ...}.
for shipping_type in all_features:
all_features[shipping_type].sort(key=lambda f: f.name)
all_features[shipping_type] = [
f for f in all_features[shipping_type] if not f.deleted]
features_by_type[shipping_type] = [
f.format_for_template() for f in all_features[shipping_type]]
ramcache.set(cache_key, features_by_type)
for shipping_type in features_by_type:
if not show_unlisted:
features_by_type[shipping_type] = self.filter_unlisted(features_by_type[shipping_type])
else:
features_by_type[shipping_type] = all_features[shipping_type].copy()
return features_by_type
def crbug_number(self):
if not self.bug_url:
@ -1249,6 +1273,7 @@ class Feature(DictModel):
search_tags = ndb.StringProperty(repeated=True)
comments = ndb.StringProperty()
owner = ndb.StringProperty(repeated=True)
editors = ndb.StringProperty(repeated=True)
footprint = ndb.IntegerProperty() # Deprecated
# Tracability to intent discussion threads
@ -1380,6 +1405,7 @@ QUERIABLE_FIELDS = {
'tags': Feature.search_tags,
'owner': Feature.owner,
'browsers.chrome.owners': Feature.owner,
'editors': Feature.editors,
'intent_to_implement_url': Feature.intent_to_implement_url,
'intent_to_ship_url': Feature.intent_to_ship_url,
'ready_for_trial_url': Feature.ready_for_trial_url,
@ -1656,6 +1682,7 @@ class AppUser(DictModel):
email = ndb.StringProperty(required=True)
is_admin = ndb.BooleanProperty(default=False)
is_site_editor = ndb.BooleanProperty(default=False)
created = ndb.DateTimeProperty(auto_now_add=True)
updated = ndb.DateTimeProperty(auto_now=True)

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

@ -83,23 +83,27 @@ class FeatureTest(testing_config.CustomTestCase):
def setUp(self):
ramcache.SharedInvalidate.check_for_distributed_invalidation()
self.feature_2 = models.Feature(
name='feature b', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=3)
name='feature b', summary='sum', owner=['feature_owner@example.com'],
category=1, visibility=1, standardization=1, web_dev_views=1,
impl_status_chrome=3)
self.feature_2.put()
self.feature_1 = models.Feature(
name='feature a', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=3)
name='feature a', summary='sum', owner=['feature_owner@example.com'],
category=1, visibility=1, standardization=1, web_dev_views=1,
impl_status_chrome=3)
self.feature_1.put()
self.feature_4 = models.Feature(
name='feature d', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=2)
name='feature d', summary='sum', owner=['feature_owner@example.com'],
category=1, visibility=1, standardization=1, web_dev_views=1,
impl_status_chrome=2)
self.feature_4.put()
self.feature_3 = models.Feature(
name='feature c', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=2)
name='feature c', summary='sum', owner=['feature_owner@example.com'],
category=1, visibility=1, standardization=1, web_dev_views=1,
impl_status_chrome=2)
self.feature_3.put()
def tearDown(self):
@ -160,6 +164,30 @@ class FeatureTest(testing_config.CustomTestCase):
self.assertEqual(
['feature a'],
names)
def test_get_all__owner_unlisted(self):
"""Unlisted features should still be visible to their owners."""
self.feature_2.unlisted = True
self.feature_2.owner = ['feature_owner@example.com']
self.feature_2.put()
testing_config.sign_in('feature_owner@example.com', 1234567890)
actual = models.Feature.get_all(update_cache=True)
names = [f['name'] for f in actual]
testing_config.sign_out()
self.assertEqual(
['feature b', 'feature c', 'feature d', 'feature a'], names)
def test_get_all__editor_unlisted(self):
"""Unlisted features should still be visible to feature editors."""
self.feature_2.unlisted = True
self.feature_2.editors = ['feature_editor@example.com']
self.feature_2.put()
testing_config.sign_in("feature_editor@example.com", 1234567890)
actual = models.Feature.get_all(update_cache=True)
names = [f['name'] for f in actual]
testing_config.sign_out()
self.assertEqual(
['feature b', 'feature c', 'feature d', 'feature a'], names)
def test_get_by_ids__empty(self):
"""A request to load zero features returns zero results."""
@ -190,12 +218,17 @@ class FeatureTest(testing_config.CustomTestCase):
ramcache.global_cache.clear()
cache_key = '%s|%s' % (
models.Feature.DEFAULT_CACHE_KEY, self.feature_1.key.integer_id())
ramcache.set(cache_key, 'fake cached feature')
cached_feature = {
'name': 'fake cached_feature',
'id': self.feature_1.key.integer_id(),
'unlisted': False
}
ramcache.set(cache_key, cached_feature)
actual = models.Feature.get_by_ids([self.feature_1.key.integer_id()])
self.assertEqual(1, len(actual))
self.assertEqual('fake cached feature', actual[0])
self.assertEqual(cached_feature, actual[0])
def test_get_by_ids__batch_order(self):
"""Features are returned in the order of the given IDs."""
@ -303,8 +336,8 @@ class FeatureTest(testing_config.CustomTestCase):
enabled_by_default)
self.assertEqual(6, len(actual))
cache_key = '%s|%s|%s|%s' % (
models.Feature.DEFAULT_CACHE_KEY, 'milestone', False, 1)
cache_key = '%s|%s|%s' % (
models.Feature.DEFAULT_CACHE_KEY, 'milestone', 1)
cached_result = ramcache.get(cache_key)
self.assertEqual(cached_result, actual)
@ -359,13 +392,14 @@ class FeatureTest(testing_config.CustomTestCase):
def test_get_in_milestone__cached(self):
"""If there is something in the cache, we use it."""
cache_key = '%s|%s|%s|%s' % (
models.Feature.DEFAULT_CACHE_KEY, 'milestone', False, 1)
ramcache.set(cache_key, 'fake feature dict')
cache_key = '%s|%s|%s' % (
models.Feature.DEFAULT_CACHE_KEY, 'milestone', 1)
cached_test_feature = {'test': [{'name': 'test_feature', 'unlisted': False}]}
ramcache.set(cache_key, cached_test_feature)
actual = models.Feature.get_in_milestone(milestone=1)
self.assertEqual(
'fake feature dict',
cached_test_feature,
actual)
@ -466,8 +500,9 @@ class CommentTest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = models.Feature(
name='feature a', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=3)
name='feature a', summary='sum', owner=['feature_owner@example.com'],
category=1, visibility=1, standardization=1, web_dev_views=1,
impl_status_chrome=3)
self.feature_1.put()
self.feature_1_id = self.feature_1.key.integer_id()
self.comment_1_1 = models.Comment(
@ -482,8 +517,9 @@ class CommentTest(testing_config.CustomTestCase):
self.comment_1_2.put()
self.feature_2 = models.Feature(
name='feature b', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=3)
name='feature b', summary='sum', owner=['feature_owner@example.com'],
category=1, visibility=1, standardization=1, web_dev_views=1,
impl_status_chrome=3)
self.feature_2.put()
self.feature_2_id = self.feature_2.key.integer_id()

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

@ -22,7 +22,6 @@ from internals import approval_defs
from internals import models
from internals import notifier
PENDING_STATES = [
models.Approval.REVIEW_REQUESTED, models.Approval.REVIEW_STARTED,
models.Approval.NEED_INFO]
@ -69,12 +68,13 @@ def process_starred_me_query():
return feature_ids
def process_owner_me_query():
"""Return features that the current user owns."""
def process_access_me_query(field):
"""Return features that the current user owns or can edit."""
user = users.get_current_user()
if not user:
return []
features = models.Feature.get_all(filterby=('owner', user.email()))
# Checks if the user's email exists in the given field.
features = models.Feature.get_all(filterby=(field, user.email()))
feature_ids = [f['id'] for f in features]
return feature_ids
@ -150,11 +150,18 @@ def process_query_term(field_name, op_str, val_str):
return process_pending_approval_me_query()
if query_term == 'starred-by:me':
return process_starred_me_query()
if query_term == 'owner:me':
return process_owner_me_query()
if query_term == 'is:recently-reviewed':
return process_recent_reviews_query()
# These queries can display unlisted features if the users
# has edit access to them. Also return a flag to signal this.
if query_term == 'owner:me':
return process_access_me_query('owner')
if query_term == 'editor:me':
return process_access_me_query('editors')
if query_term == 'can_edit:me':
return process_access_me_query('can_edit')
if val_str.startswith('"') and val_str.endswith('"'):
val_str = val_str[1:-1]
return process_queriable_field(field_name, op_str, val_str)
@ -184,6 +191,7 @@ def process_query(
terms = TERM_RE.findall(user_query + ' ')[:MAX_TERMS] or []
if not show_deleted:
terms.append(('deleted', '=', 'false', None))
# TODO(jrobbins): include unlisted features that the user is allowed to view.
if not show_unlisted:
terms.append(('unlisted', '=', 'false', None))
# 1b. Parse the sort directive.

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

@ -29,6 +29,7 @@ class SearchFunctionsTest(testing_config.CustomTestCase):
name='feature 1', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=3)
self.feature_1.owner = ['owner@example.com']
self.feature_1.editors = ['editor@example.com']
self.feature_1.put()
self.feature_2 = models.Feature(
name='feature 2', summary='sum', category=2, visibility=1,
@ -121,20 +122,33 @@ class SearchFunctionsTest(testing_config.CustomTestCase):
self.assertEqual(len(actual), 1)
self.assertEqual(actual[0], self.feature_1.key.integer_id())
def test_process_owner_me_query__none(self):
def test_process_access_me_query__owner_none(self):
"""We can return a list of features owned by the user."""
testing_config.sign_in('visitor@example.com', 111)
actual = search.process_owner_me_query()
actual = search.process_access_me_query('owner')
self.assertEqual(actual, [])
def test_process_owner_me_query__some(self):
def test_process_access_me_query__owner_some(self):
"""We can return a list of features owned by the user."""
testing_config.sign_in('owner@example.com', 111)
actual = search.process_owner_me_query()
actual = search.process_access_me_query('owner')
self.assertEqual(len(actual), 2)
self.assertEqual(actual[0], self.feature_1.key.integer_id())
self.assertEqual(actual[1], self.feature_2.key.integer_id())
def test_process_access_me_query__editors_none(self):
"""We can return a list of features the user can edit."""
testing_config.sign_in('visitor@example.com', 111)
actual = search.process_access_me_query('editors')
self.assertEqual(actual, [])
def test_process_access_me_query__editors_some(self):
"""We can return a list of features the user can edit."""
testing_config.sign_in('editor@example.com', 111)
actual = search.process_access_me_query('editors')
self.assertEqual(len(actual), 1)
self.assertEqual(actual[0], self.feature_1.key.integer_id())
@mock.patch('internals.models.Approval.get_approvals')
@mock.patch('internals.approval_defs.fields_approvable_by')
def test_process_recent_reviews_query__none(
@ -172,7 +186,7 @@ class SearchFunctionsTest(testing_config.CustomTestCase):
@mock.patch('internals.search.process_pending_approval_me_query')
@mock.patch('internals.search.process_starred_me_query')
@mock.patch('internals.search.process_owner_me_query')
@mock.patch('internals.search.process_access_me_query')
@mock.patch('internals.search.process_recent_reviews_query')
def test_process_query__predefined(
self, mock_recent, mock_own_me, mock_star_me, mock_pend_me):

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

@ -35,10 +35,17 @@ class TestWithFeature(testing_config.CustomTestCase):
HANDLER_CLASS = 'subclasses fill this in'
def setUp(self):
self.app_user = models.AppUser(email='registered@example.com')
self.app_user.put()
self.app_admin = models.AppUser(email='admin@example.com')
self.app_admin.is_admin = True
self.app_admin.put()
self.feature_1 = models.Feature(
name='feature one', summary='detailed sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=1,
intent_stage=models.INTENT_IMPLEMENT)
name='feature one', summary='detailed sum', owner=['owner@example.com'],
category=1, visibility=1, standardization=1, web_dev_views=1,
impl_status_chrome=1, intent_stage=models.INTENT_IMPLEMENT)
self.feature_1.put()
self.feature_id = self.feature_1.key.integer_id()
@ -49,6 +56,9 @@ class TestWithFeature(testing_config.CustomTestCase):
def tearDown(self):
self.feature_1.key.delete()
self.app_user.delete()
self.app_admin.delete()
ramcache.flush_all()
ramcache.check_for_distributed_invalidation()
@ -83,11 +93,11 @@ class FeaturesJsonHandlerTest(TestWithFeature):
self.assertEqual(0, len(json_data))
def test_get_template_data__unlisted_can_edit(self):
"""JSON feed includes unlisted features for users who may edit."""
"""JSON feed includes unlisted features for site editors and admins."""
self.feature_1.unlisted = True
self.feature_1.put()
testing_config.sign_in('user@google.com', 111)
testing_config.sign_in('admin@example.com', 111)
with test_app.test_request_context(self.request_path):
json_data = self.handler.get_template_data()
self.assertEqual(1, len(json_data))

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

@ -107,7 +107,7 @@ class FeatureNew(basehandlers.FlaskHandler):
TEMPLATE_PATH = 'guide/new.html'
@permissions.require_edit_feature
@permissions.require_create_feature
def get_template_data(self):
user = self.get_current_user()
@ -118,9 +118,10 @@ class FeatureNew(basehandlers.FlaskHandler):
}
return template_data
@permissions.require_edit_feature
@permissions.require_create_feature
def process_post_data(self):
owners = self.split_emails('owner')
editors = self.split_emails('editors')
blink_components = (
self.split_input('blink_components', delim=',') or
@ -139,6 +140,7 @@ class FeatureNew(basehandlers.FlaskHandler):
intent_stage=models.INTENT_NONE,
summary=self.form.get('summary'),
owner=owners,
editors=editors,
impl_status_chrome=models.NO_ACTIVE_DEV,
standardization=models.EDITORS_DRAFT,
unlisted=self.form.get('unlisted') == 'on',
@ -170,6 +172,7 @@ class ProcessOverview(basehandlers.FlaskHandler):
@permissions.require_edit_feature
def get_template_data(self, feature_id):
f = models.Feature.get_by_id(int(feature_id))
if f is None:
self.abort(404, msg='Feature not found')
@ -246,6 +249,7 @@ class FeatureEditStage(basehandlers.FlaskHandler):
@permissions.require_edit_feature
def get_template_data(self, feature_id, stage_id):
f, feature_process = self.get_feature_and_process(feature_id)
stage_name = ''
@ -289,6 +293,7 @@ class FeatureEditStage(basehandlers.FlaskHandler):
@permissions.require_edit_feature
def process_post_data(self, feature_id, stage_id=0):
if feature_id:
feature = models.Feature.get_by_id(feature_id)
if feature is None:
@ -438,6 +443,9 @@ class FeatureEditStage(basehandlers.FlaskHandler):
if self.touched('owner'):
feature.owner = self.split_emails('owner')
if self.touched('editors'):
feature.editors = self.split_emails('editors')
if self.touched('doc_links'):
feature.doc_links = self.parse_links('doc_links')
@ -579,6 +587,7 @@ class FeatureEditAllFields(FeatureEditStage):
@permissions.require_edit_feature
def get_template_data(self, feature_id):
f, feature_process = self.get_feature_and_process(feature_id)
feature_edit_dict = f.format_for_edit()

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

@ -137,9 +137,9 @@ class ProcessOverviewTest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = models.Feature(
name='feature one', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=models.DEV_NO_SIGNALS,
impl_status_chrome=1)
name='feature one', summary='sum', owner=['user1@google.com'],
category=1, visibility=1, standardization=1,
web_dev_views=models.DEV_NO_SIGNALS, impl_status_chrome=1)
self.feature_1.put()
self.request_path = '/guide/edit/%d' % self.feature_1.key.integer_id()
@ -209,9 +209,9 @@ class ProcessOverviewTemplateTest(TestWithFeature):
super(ProcessOverviewTemplateTest, self).setUp()
self.feature_1 = models.Feature(
name='feature one', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=models.DEV_NO_SIGNALS,
impl_status_chrome=1)
name='feature one', summary='sum', owner=['user1@google.com'],
category=1, visibility=1, standardization=1,
web_dev_views=models.DEV_NO_SIGNALS, impl_status_chrome=1)
self.feature_1.put()
self.request_path = '/guide/edit/%d' % self.feature_1.key.integer_id()
@ -243,8 +243,9 @@ class FeatureEditStageTest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = models.Feature(
name='feature one', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=1)
name='feature one', summary='sum', owner=['user1@google.com'],
category=1, visibility=1, standardization=1, web_dev_views=1,
impl_status_chrome=1)
self.feature_1.put()
self.stage = models.INTENT_INCUBATE # Shows first form
@ -394,9 +395,9 @@ class FeatureEditStageTemplateTest(TestWithFeature):
def setUp(self):
super(FeatureEditStageTemplateTest, self).setUp()
self.feature_1 = models.Feature(
name='feature one', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=models.DEV_NO_SIGNALS,
impl_status_chrome=1)
name='feature one', summary='sum', owner=['user1@google.com'],
category=1, visibility=1, standardization=1,
web_dev_views=models.DEV_NO_SIGNALS, impl_status_chrome=1)
self.feature_1.put()
self.stage = models.INTENT_INCUBATE # Shows first form
testing_config.sign_in('user1@google.com', 1234567890)
@ -426,9 +427,9 @@ class FeatureEditAllFieldsTemplateTest(TestWithFeature):
def setUp(self):
super(FeatureEditAllFieldsTemplateTest, self).setUp()
self.feature_1 = models.Feature(
name='feature one', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=models.DEV_NO_SIGNALS,
impl_status_chrome=1)
name='feature one', summary='sum', owner=['user1@google.com'],
category=1, visibility=1, standardization=1,
web_dev_views=models.DEV_NO_SIGNALS, impl_status_chrome=1)
self.feature_1.put()
self.stage = models.INTENT_INCUBATE # Shows first form
testing_config.sign_in('user1@google.com', 1234567890)

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

@ -144,6 +144,10 @@ ALL_FIELDS = {
required=True, label='Feature owners',
widget=forms.EmailInput(attrs=MULTI_EMAIL_FIELD_ATTRS)),
'editors': MultiEmailField(
required=False, label='Feature editors',
widget=forms.EmailInput(attrs=MULTI_EMAIL_FIELD_ATTRS)),
'category': forms.ChoiceField(
required=False,
initial=models.MISC,
@ -502,7 +506,7 @@ ALL_FIELDS = {
# These are shown in a top card for all processes.
METADATA_FIELDS = [
'name', 'summary', 'unlisted', 'owner',
'category',
'editors', 'category',
'feature_type', 'intent_stage',
'search_tags',
# Implemention
@ -576,7 +580,7 @@ def define_form_class_using_shared_fields(class_name, field_spec_list):
NewFeatureForm = define_form_class_using_shared_fields(
'NewFeatureForm',
('name', 'summary',
'unlisted', 'owner',
'unlisted', 'owner', 'editors',
'blink_components', 'category'))
# Note: feature_type is done with custom HTML
@ -681,7 +685,7 @@ Any_Ship = define_form_class_using_shared_fields(
Existing_Prototype = define_form_class_using_shared_fields(
'Existing_Prototype',
('owner', 'blink_components', 'motivation', 'explainer_links',
('owner', 'editors', 'blink_components', 'motivation', 'explainer_links',
'spec_link', 'standard_maturity', 'api_spec', 'bug_url', 'launch_bug_url',
'intent_to_implement_url', 'comments'))
@ -753,7 +757,7 @@ Flat_Metadata = define_form_class_using_shared_fields(
'Flat_Metadata',
(# Standardizaton
'name', 'summary', 'unlisted', 'owner',
'category',
'editors', 'category',
'feature_type', 'intent_stage',
'search_tags',
# Implementtion
@ -880,7 +884,7 @@ DEPRECATED_FIELDS = ['standardization']
DISPLAY_IN_FEATURE_HIGHLIGHTS = [
'name', 'summary',
'motivation', 'deprecation_motivation',
'unlisted', 'owner',
'unlisted', 'owner', 'editors',
'search_tags',
# Implementtion
'impl_status_chrome',

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

@ -31,9 +31,9 @@ class IntentEmailPreviewHandlerTest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = models.Feature(
name='feature one', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=1,
intent_stage=models.INTENT_IMPLEMENT)
name='feature one', summary='sum', owner=['user1@google.com'],
category=1, visibility=1, standardization=1, web_dev_views=1,
impl_status_chrome=1, intent_stage=models.INTENT_IMPLEMENT)
self.feature_1.put()
self.request_path = '/admin/features/launch/%d/%d?intent' % (
@ -187,9 +187,9 @@ class IntentEmailPreviewTemplateTest(testing_config.CustomTestCase):
def setUp(self):
super(IntentEmailPreviewTemplateTest, self).setUp()
self.feature_1 = models.Feature(
name='feature one', summary='sum', category=1, visibility=1,
standardization=1, web_dev_views=1, impl_status_chrome=1,
intent_stage=models.INTENT_IMPLEMENT)
name='feature one', summary='sum', owner=['user1@google.com'],
category=1, visibility=1, standardization=1, web_dev_views=1,
impl_status_chrome=1, intent_stage=models.INTENT_IMPLEMENT)
self.feature_1.put()
self.request_path = '/admin/features/launch/%d/%d?intent' % (

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

@ -69,6 +69,7 @@ export class ChromedashFeaturePage extends LitElement {
user: {type: Object},
featureId: {type: Number},
feature: {type: Object},
editableFeatures: {type: Object},
process: {type: Object},
fieldDefs: {type: Object},
dismissedCues: {type: Array},
@ -82,6 +83,7 @@ export class ChromedashFeaturePage extends LitElement {
super();
this.user = {};
this.featureId = 0;
this.editableFeatures = [];
this.feature = {};
this.process = {};
this.fieldDefs = {};
@ -193,6 +195,9 @@ export class ChromedashFeaturePage extends LitElement {
}
renderSubHeader() {
const canEdit = (this.user &&
(this.user.can_edit_all || this.user.editableFeatures.includes(this.featureId)));
return html`
<div id="subheader" style="display:block">
<div class="tooltips" style="float:right">
@ -225,7 +230,7 @@ export class ChromedashFeaturePage extends LitElement {
</a>
</span>
`: nothing}
${this.user && this.user.can_edit ? html`
${canEdit ? html`
<span class="tooltip" title="Edit this feature">
<a href="/guide/edit/${this.featureId}" class="editfeature" data-tooltip>
<iron-icon icon="chromestatus:create"></iron-icon>
@ -252,8 +257,8 @@ export class ChromedashFeaturePage extends LitElement {
return html`
${this.feature.unlisted ? html`
<section id="access">
<p><b>This feature is only shown in the feature list to users with
edit access.</b></p>
<p><b>This feature is only shown in the feature list
to users with access to edit this feature.</b></p>
</section>
`: nothing}

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

@ -9,7 +9,7 @@ describe('chromedash-feature-page', () => {
const permissionsPromise = Promise.resolve({
can_approve: false,
can_create_feature: true,
can_edit: true,
can_edit_all: true,
is_admin: false,
email: 'example@google.com',
});

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

@ -295,7 +295,7 @@ class ChromedashFeature extends LitElement {
<summary>
${this.feature.unlisted ?
html`<p><b>This feature is only shown in the feature list
to users with edit access.</b></p>
to users with access to edit this feature.</b></p>
`: nothing }
<p class="${this.open ? 'preformatted' : ''}"
><span>${autolink(this.feature.summary)}</span

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

@ -11,9 +11,10 @@ class ChromedashFeaturelist extends LitElement {
static get properties() {
return {
canEdit: {type: Boolean},
isSiteEditor: {type: Boolean},
canApprove: {type: Boolean},
signedInUser: {type: String},
editableFeatures: {type: Object},
features: {attribute: false}, // Directly edited and accessed in template/features.html
metadataEl: {attribute: false}, // The metadata component element. Directly edited in template/features.html
searchEl: {attribute: false}, // The search input element. Directly edited in template/features.html
@ -26,10 +27,11 @@ class ChromedashFeaturelist extends LitElement {
constructor() {
super();
this.features = [];
this.editableFeatures = [];
this.filtered = [];
this.metadataEl = document.querySelector('chromedash-metadata');
this.searchEl = document.querySelector('.search input');
this.canEdit = false;
this.isSiteEditor = false;
this.canApprove = false;
this.signedInUser = '';
this._hasInitialized = false; // Used to check initialization code.
@ -363,10 +365,13 @@ class ChromedashFeaturelist extends LitElement {
render() {
// TODO: Avoid computing values in render().
let filteredWithState = this.filtered.map((feature) => {
const editable = this.isSiteEditor ||
(this.editableFeatures && this.editableFeatures.includes(feature.id));
return {
feature: feature,
open: this.openFeatures.has(feature.id),
starred: this.starredFeatures.has(feature.id),
canEditFeature: editable,
};
});
let numOverLimit = 0;
@ -391,7 +396,7 @@ class ChromedashFeaturelist extends LitElement {
@star-toggled="${this._onStarToggledBound}"
@open-approvals-event="${this._onOpenApprovalsBound}"
.feature="${item.feature}"
?canEdit="${this.canEdit}"
?canEdit="${item.canEditFeature}"
?canApprove="${this.canApprove}"
?signedIn="${this.signedInUser != ''}"
></chromedash-feature>

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

@ -81,7 +81,7 @@ export class ChromedashMyFeaturesPage extends LitElement {
<chromedash-feature-table
query="${query}"
?signedIn=${Boolean(this.user)}
?canEdit=${this.user && this.user.can_edit}
?canEdit=${this.user && this.user.can_edit_all}
?canApprove=${this.user && this.user.can_approve}
.starredFeatures=${this.starredFeatures}
@star-toggle-event=${this.handleStarToggle}
@ -105,9 +105,9 @@ export class ChromedashMyFeaturesPage extends LitElement {
'Features I starred', 'starred-by:me', 'normal');
}
renderIOwn() {
renderICanEdit() {
return this.renderBox(
'Features I own', 'owner:me', 'normal');
'Features I can edit', 'can_edit:me', 'normal');
}
render() {
@ -116,7 +116,7 @@ export class ChromedashMyFeaturesPage extends LitElement {
<h2>My features</h2>
</div>
${this.user && this.user.can_approve ? this.renderPendingAndRecentApprovals() : nothing}
${this.renderIOwn()}
${this.renderICanEdit()}
${this.renderIStarred()}
`;
}

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

@ -6,7 +6,7 @@ import '../js-src/cs-client';
import sinon from 'sinon';
describe('chromedash-myfeatures-page', () => {
let recentReview; let pendingReview; let featureIOwn; let featureIStarred;
let recentReview; let pendingReview; let featureICanEdit; let featureIStarred;
/* window.csClient and <chromedash-toast> are initialized at _base.html
* which are not available here, so we initialize them before each test.
@ -24,7 +24,7 @@ describe('chromedash-myfeatures-page', () => {
recentReview = null;
pendingReview = null;
featureIOwn = null;
featureICanEdit = null;
featureIStarred = null;
});
@ -37,9 +37,10 @@ describe('chromedash-myfeatures-page', () => {
window.csClient.getPermissions.returns(Promise.resolve({
can_approve: false,
can_create_feature: true,
can_edit: true,
can_edit_all: true,
is_admin: false,
email: 'example@gmail.com',
editable_features: [],
}));
const component = await fixture(
html`<chromedash-myfeatures-page></chromedash-myfeatures-page>`);
@ -53,7 +54,7 @@ describe('chromedash-myfeatures-page', () => {
const slDetails = component.shadowRoot.querySelectorAll('sl-details');
slDetails.forEach((item) => {
const itemHTML = item.outerHTML;
if (itemHTML.includes('summary="Features I own"')) featureIOwn = item;
if (itemHTML.includes('summary="Features I can edit"')) featureICanEdit = item;
if (itemHTML.includes('summary="Features I starred"')) featureIStarred = item;
if (itemHTML.includes('summary="Features pending my approval"')) pendingReview = item;
if (itemHTML.includes('summary="Recently reviewed features"')) recentReview = item;
@ -63,9 +64,9 @@ describe('chromedash-myfeatures-page', () => {
assert.notExists(pendingReview);
assert.notExists(recentReview);
// Features I own sl-details exists and has a correct query
assert.exists(featureIOwn);
assert.include(featureIOwn.innerHTML, 'query="owner:me"');
// "Features I can edit" sl-details exists and has a correct query
assert.exists(featureICanEdit);
assert.include(featureICanEdit.innerHTML, 'query="can_edit:me"');
// Features I starred sl-details exists and has a correct query
assert.exists(featureIStarred);
@ -76,9 +77,10 @@ describe('chromedash-myfeatures-page', () => {
window.csClient.getPermissions.returns(Promise.resolve({
can_approve: true,
can_create_feature: true,
can_edit: true,
can_edit_all: true,
is_admin: false,
email: 'example@gmail.com',
editable_features: [],
}));
const component = await fixture(
html`<chromedash-myfeatures-page></chromedash-myfeatures-page>`);
@ -92,7 +94,7 @@ describe('chromedash-myfeatures-page', () => {
const slDetails = component.shadowRoot.querySelectorAll('sl-details');
slDetails.forEach((item) => {
const itemHTML = item.outerHTML;
if (itemHTML.includes('summary="Features I own"')) featureIOwn = item;
if (itemHTML.includes('summary="Features I can edit')) featureICanEdit = item;
if (itemHTML.includes('summary="Features I starred"')) featureIStarred = item;
if (itemHTML.includes('summary="Features pending my approval"')) pendingReview = item;
if (itemHTML.includes('summary="Recently reviewed features"')) recentReview = item;
@ -106,9 +108,9 @@ describe('chromedash-myfeatures-page', () => {
assert.exists(recentReview);
assert.include(recentReview.innerHTML, 'query="is:recently-reviewed"');
// "Features I own" sl-details exists and has a correct query
assert.exists(featureIOwn);
assert.include(featureIOwn.innerHTML, 'query="owner:me"');
// "Features I can edit" sl-details exists and has a correct query
assert.exists(featureICanEdit);
assert.include(featureICanEdit.innerHTML, 'query="can_edit:me"');
// "Features I starred" sl-details exists and has a correct query
assert.exists(featureIStarred);

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

@ -55,6 +55,32 @@ class ChromedashUserlist extends LitElement {
this.users = this.users.slice(0); // Refresh the list
}
sortUsers() {
this.users.sort((a, b) => {
if ((a.is_admin && !b.is_admin) || (a.is_site_editor && (!b.is_site_editor && !b.is_admin))) {
return -1;
}
if ((b.is_admin && !a.is_admin) || (b.is_site_editor && (!a.is_site_editor && !a.is_admin))) {
return 1;
}
return a.email.localeCompare(b.email);
});
}
_onAdminToggle() {
const formEl = this.shadowRoot.querySelector('form');
const adminCheckbox = formEl.querySelector('input[name="is_admin"]');
const siteEditorCheckbox = formEl.querySelector('input[name="is_site_editor"]');
// Admins will always be site editors, so if the admin box is checked,
// the site editor box is also checked and disabled.
if (adminCheckbox.checked) {
siteEditorCheckbox.checked = true;
siteEditorCheckbox.disabled = true;
} else {
siteEditorCheckbox.disabled = false;
}
}
// TODO(jrobbins): Change this to be a JSON API call via csClient.
async ajaxSubmit(e) {
e.preventDefault();
@ -63,12 +89,14 @@ class ChromedashUserlist extends LitElement {
if (formEl.checkValidity()) {
const email = formEl.querySelector('input[name="email"]').value;
const isAdmin = formEl.querySelector('input[name="is_admin"]').checked;
window.csClient.createAccount(email, isAdmin).then((json) => {
const isSiteEditor = formEl.querySelector('input[name="is_site_editor"]').checked;
window.csClient.createAccount(email, isAdmin, isSiteEditor).then((json) => {
if (json.error_message) {
alert(json.error_message);
} else {
this.addUser(json);
formEl.reset();
formEl.querySelector('input[name="is_site_editor"]').disabled = false;
}
});
}
@ -87,6 +115,7 @@ class ChromedashUserlist extends LitElement {
}
render() {
this.sortUsers();
return html`
<form id="form" name="user_form" method="POST">
<div>
@ -94,7 +123,10 @@ class ChromedashUserlist extends LitElement {
required>
</div>
<div>
<label><input type="checkbox" name="is_admin"> User is admin</label>
<label><input type="checkbox" name="is_admin" @click="${this._onAdminToggle}"> User is admin</label>
</div>
<div>
<label><input type="checkbox" name="is_site_editor"> User is site editor</label>
</div>
<div>
<input type="submit" @click="${this.ajaxSubmit}" value="Add user">
@ -108,8 +140,9 @@ class ChromedashUserlist extends LitElement {
data-index="${index}"
data-account="${user.id}"
@click="${this.ajaxDelete}">delete</a>
<span>${user.email}</span>
${user.is_admin ? html`(admin)` : nothing}
${!user.is_admin && user.is_site_editor ? html`(site editor)` : nothing}
<span>${user.email}</span>
</li>
`)}
</ul>

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

@ -54,6 +54,12 @@ export const ALL_FIELDS = {
from @chromium.org are preferred.`,
},
'editors': {
help_text: html`
Comma separated list of full email addresses. These users will be
allowed to edit this feature, but will not be listed as feature owners.`,
},
'unlisted': {
help_text: html`
Check this box to hide draft emails in list views. Anyone with

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

@ -189,8 +189,8 @@ class ChromeStatusClient {
// Accounts API
createAccount(email, isAdmin) {
return this.doPost('/accounts', {email, isAdmin});
createAccount(email, isAdmin, isSiteEditor) {
return this.doPost('/accounts', {email, isAdmin, isSiteEditor});
// TODO: catch((error) => { display message }
}

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

@ -40,7 +40,7 @@
</button>
</div>
<div class="actionlinks">
{% if user.can_edit %}
{% if user.can_create_feature %}
<a href="/guide/new" class="blue-button" title="Adds a new feature to the site">
<iron-icon icon="chromestatus:add-circle-outline"></iron-icon><span>Add new feature</span>
</a>
@ -48,8 +48,9 @@
</div>
</div>
<chromedash-featurelist
{% if user %} signedInUser="{{user.email}}" {% endif %}
{% if user.can_edit %}canEdit{% endif %}
{% if user %} signedInUser="{{user.email}}" {% endif %}
{% if user.can_edit_all %}isSiteEditor{% endif %}
{% if user %} editableFeatures="{{user.editable_features}}" {% endif %}
{% if user.can_approve %}canApprove{% endif %}>
</chromedash-featurelist>
</div>

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

@ -25,7 +25,7 @@
{% if feature.unlisted %}
<div style="padding: 8px">
This feature is only shown in the feature list
to users who with edit access.
to users with access to edit this feature.
</div>
{% endif %}

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

@ -14,7 +14,7 @@
{% block content %}
<chromedash-new-feature-list
{% if user %} signedinuser="{{user.email}}" {% endif %}
{% if user.can_edit %} canedit {% endif %}
{% if user.can_edit_all %} canedit {% endif %}
{% if user.can_approve %} canapprove {% endif %}
>
</chromedash-new-feature-list>