Skip to content

Commit a9650cd

Browse files
authored
Query-String Parameter for milestone in features api (GoogleChrome#1424)
* Added query-string support for milestones in feature API * Added tests * Factored out the call to permissions.can_edit_feature(user, None) to the top of the method * Resolved error in comment * Added method for annotating first of each implementation status * Added tests * Made version 2 as the default version for api template * Renamed versions to statuses * Removed reverse argument
1 parent 9f5ba41 commit a9650cd

File tree

4 files changed

+282
-21
lines changed

4 files changed

+282
-21
lines changed

api/features_api.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,25 @@ class FeaturesAPI(basehandlers.APIHandler):
2929

3030
def do_get(self):
3131
user = users.get_current_user()
32-
feature_list = models.Feature.get_chronological(
33-
version=2,
34-
show_unlisted=permissions.can_edit_feature(user, None))
32+
show_unlisted_features = permissions.can_edit_feature(user, None)
33+
feature_list = None
34+
35+
# Query-string parameter 'milestone' is provided
36+
if self.request.args.get('milestone') is not None:
37+
try:
38+
milestone = int(self.request.args.get('milestone'))
39+
feature_list = models.Feature.get_in_milestone(
40+
show_unlisted=show_unlisted_features,
41+
milestone=milestone)
42+
except ValueError:
43+
self.abort(400, msg='Invalid Milestone')
44+
45+
# No Query-string parameter is provided
46+
if feature_list is None:
47+
feature_list = models.Feature.get_chronological(
48+
version=2,
49+
show_unlisted=show_unlisted_features)
50+
3551
return feature_list
3652

3753
# TODO(jrobbins): do_post

api/features_api_test.py

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from framework import ramcache
2828

2929

30-
class FeaturesAPITest(testing_config.CustomTestCase):
30+
class FeaturesAPITestDelete(testing_config.CustomTestCase):
3131

3232
def setUp(self):
3333
self.feature_1 = models.Feature(
@@ -95,6 +95,31 @@ def test_delete__not_found(self):
9595
revised_feature = models.Feature.get_by_id(self.feature_id)
9696
self.assertFalse(revised_feature.deleted)
9797

98+
99+
class FeaturesAPITestGet(testing_config.CustomTestCase):
100+
101+
def setUp(self):
102+
self.feature_1 = models.Feature(
103+
name='feature one', summary='sum', category=1, visibility=1,
104+
standardization=1, web_dev_views=1, impl_status_chrome=5,
105+
intent_stage=models.INTENT_IMPLEMENT, shipped_milestone=1)
106+
self.feature_1.put()
107+
self.feature_id = self.feature_1.key.integer_id()
108+
109+
self.request_path = '/api/v0/features'
110+
self.handler = features_api.FeaturesAPI()
111+
112+
self.app_admin = models.AppUser(email='admin@example.com')
113+
self.app_admin.is_admin = True
114+
self.app_admin.put()
115+
116+
def tearDown(self):
117+
self.feature_1.key.delete()
118+
self.app_admin.key.delete()
119+
testing_config.sign_out()
120+
ramcache.flush_all()
121+
ramcache.check_for_distributed_invalidation()
122+
98123
def test_get__all_listed(self):
99124
"""Get all features that are listed."""
100125
with register.app.test_request_context(self.request_path):
@@ -105,16 +130,14 @@ def test_get__all_listed(self):
105130
self.assertEqual(1, len(actual_response))
106131
self.assertEqual('feature one', actual_response[0]['name'])
107132

108-
def test_get__unlisted_no_perms(self):
133+
def test_get__all_unlisted_no_perms(self):
109134
"""JSON feed does not include unlisted features for users who can't edit."""
110135
self.feature_1.unlisted = True
111136
self.feature_1.put()
112137

113138
# No signed-in user
114139
with register.app.test_request_context(self.request_path):
115-
actual_response = self.handler.do_get()
116-
# Comparing only the total number of features and name of the feature
117-
# as certain fields like `updated` cannot be compared
140+
actual_response = self.handler.do_get()
118141
self.assertEqual(0, len(actual_response))
119142

120143
# Signed-in user with no permissions
@@ -123,15 +146,73 @@ def test_get__unlisted_no_perms(self):
123146
actual_response = self.handler.do_get()
124147
self.assertEqual(0, len(actual_response))
125148

126-
def test_get__unlisted_can_edit(self):
149+
def test_get__all_unlisted_can_edit(self):
127150
"""JSON feed includes unlisted features for users who may edit."""
128151
self.feature_1.unlisted = True
129152
self.feature_1.put()
130153

154+
# Signed-in user with permissions
155+
testing_config.sign_in('admin@example.com', 123567890)
156+
with register.app.test_request_context(self.request_path):
157+
actual_response = self.handler.do_get()
158+
self.assertEqual(1, len(actual_response))
159+
self.assertEqual('feature one', actual_response[0]['name'])
160+
161+
def test_get__in_milestone_listed(self):
162+
"""Get all features in a specific milestone that are listed."""
163+
# Atleast one feature is present in milestone
164+
with register.app.test_request_context(self.request_path+'?milestone=1'):
165+
actual_response = self.handler.do_get()
166+
self.assertEqual(1, len(actual_response))
167+
self.assertEqual('feature one', actual_response[0]['name'])
168+
self.assertEqual(1, actual_response[0]['browsers']['chrome']['status']['milestone_str'])
169+
170+
# No Feature is present in milestone
171+
with register.app.test_request_context(self.request_path+'?milestone=2'):
172+
actual_response = self.handler.do_get()
173+
self.assertEqual(0, len(actual_response))
174+
175+
def test_get__in_milestone_unlisted_no_perms(self):
176+
"""JSON feed does not include unlisted features for users who can't edit."""
177+
self.feature_1.unlisted = True
178+
self.feature_1.put()
179+
131180
# No signed-in user
181+
with register.app.test_request_context(self.request_path+'?milestone=1'):
182+
actual_response = self.handler.do_get()
183+
self.assertEqual(0, len(actual_response))
184+
132185
# Signed-in user with no permissions
186+
testing_config.sign_in('one@example.com', 123567890)
187+
with register.app.test_request_context(self.request_path+'?milestone=1'):
188+
actual_response = self.handler.do_get()
189+
self.assertEqual(0, len(actual_response))
190+
191+
def test_get__in_milestone_unlisted_can_edit(self):
192+
"""JSON feed includes unlisted features for users who may edit."""
193+
self.feature_1.unlisted = True
194+
self.feature_1.put()
195+
196+
# Signed-in user with permissions
133197
testing_config.sign_in('admin@example.com', 123567890)
134-
with register.app.test_request_context(self.request_path):
198+
199+
# Feature is present in milestone
200+
with register.app.test_request_context(self.request_path+'?milestone=1'):
135201
actual_response = self.handler.do_get()
136202
self.assertEqual(1, len(actual_response))
137-
self.assertEqual('feature one', actual_response[0]['name'])
203+
self.assertEqual('feature one', actual_response[0]['name'])
204+
self.assertEqual(1, actual_response[0]['browsers']['chrome']['status']['milestone_str'])
205+
206+
# Feature is not present in milestone
207+
with register.app.test_request_context(self.request_path+'?milestone=2'):
208+
actual_response = self.handler.do_get()
209+
self.assertEqual(0, len(actual_response))
210+
211+
@mock.patch('flask.abort')
212+
def test_get__in_milestone_invalid_query(self, mock_abort):
213+
"""Invalid value of milestone should not be processed."""
214+
215+
# Feature is present in milestone
216+
with register.app.test_request_context(self.request_path+'?milestone=chromium'):
217+
actual_response = self.handler.do_get()
218+
mock_abort.assert_called_once_with(400, description='Invalid Milestone')

internals/models.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,30 @@ def _annotate_first_of_milestones(self, feature_list, version=None):
554554
except Exception as e:
555555
logging.error(e)
556556

557+
@classmethod
558+
def _annotate_first_of_impl_status_in_milestones(self, feature_list, version=2):
559+
try:
560+
statuses = [
561+
IMPLEMENTATION_STATUS[BEHIND_A_FLAG],
562+
IMPLEMENTATION_STATUS[ENABLED_BY_DEFAULT],
563+
IMPLEMENTATION_STATUS[REMOVED],
564+
IMPLEMENTATION_STATUS[ORIGIN_TRIAL],
565+
IMPLEMENTATION_STATUS[INTERVENTION],
566+
IMPLEMENTATION_STATUS[ON_HOLD]
567+
]
568+
first_of_milestone_func = Feature._first_of_milestone
569+
if version == 2:
570+
first_of_milestone_func = Feature._first_of_milestone_v2
571+
572+
last_good_idx = 0
573+
for i, status in enumerate(statuses):
574+
idx = first_of_milestone_func(feature_list, status, start=last_good_idx)
575+
if idx != -1:
576+
feature_list[idx]['first_of_milestone'] = True
577+
last_good_idx = idx
578+
except Exception as e:
579+
logging.error(e)
580+
557581
def migrate_views(self):
558582
"""Migrate obsolete values for views on first edit."""
559583
if self.ff_views == MIXED_SIGNALS:
@@ -571,7 +595,7 @@ def migrate_views(self):
571595
if self.safari_views == PUBLIC_SKEPTICISM:
572596
self.safari_views = OPPOSED
573597

574-
def format_for_template(self, version=None):
598+
def format_for_template(self, version=2):
575599
self.migrate_views()
576600
d = self.to_dict()
577601
is_released = self.impl_status_chrome in RELEASE_IMPL_STATES
@@ -894,6 +918,48 @@ def getSortingMilestone(feature):
894918

895919
return allowed_feature_list
896920

921+
@classmethod
922+
def get_in_milestone(
923+
self, show_unlisted=False, milestone=None):
924+
if milestone == None:
925+
return None
926+
927+
logging.info('Getting chronological feature list in milestone %d', milestone)
928+
q = Feature.query()
929+
q = q.order(Feature.name)
930+
q = q.filter(Feature.shipped_milestone == milestone)
931+
desktop_shipping_features = q.fetch(None)
932+
933+
# Features with an android shipping milestone but no desktop milestone.
934+
q = Feature.query()
935+
q = q.order(Feature.name)
936+
q = q.filter(Feature.shipped_android_milestone == milestone)
937+
q = q.filter(Feature.shipped_milestone == None)
938+
android_only_shipping_features = q.fetch(None)
939+
940+
# Constructor the proper ordering.
941+
all_features = []
942+
all_features.extend(desktop_shipping_features)
943+
all_features.extend(android_only_shipping_features)
944+
945+
# Feature list must be first sorted by implementation status and then by name.
946+
# The implementation may seem to be counter-intuitive using sort() method.
947+
all_features.sort(key=lambda f: f.name)
948+
all_features.sort(key=lambda f: f.impl_status_chrome)
949+
950+
all_features = [f for f in all_features if not f.deleted]
951+
952+
feature_list = [f.format_for_template() for f in all_features]
953+
954+
self._annotate_first_of_impl_status_in_milestones(feature_list)
955+
956+
allowed_feature_list = [
957+
f for f in feature_list
958+
if show_unlisted or not f['unlisted']]
959+
960+
return allowed_feature_list
961+
962+
897963
@classmethod
898964
def get_shipping_samples(self, limit=None, update_cache=False):
899965
cache_key = '%s|%s|%s' % (Feature.DEFAULT_CACHE_KEY, 'samples', limit)

0 commit comments

Comments
 (0)