Use FeatureEntry for most client-side use cases (#2433)

* Use FeatureEntry entity for most client-side uses

* leave notify and amendments to Feature for now

* handle redis keys
This commit is contained in:
Daniel Smith 2022-11-06 12:57:40 -08:00 коммит произвёл GitHub
Родитель 5f4c89657e
Коммит e2ce1d405e
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
19 изменённых файлов: 531 добавлений и 282 удалений

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

@ -32,16 +32,16 @@ NOW = datetime.datetime.now()
class ApprovalsAPITest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = core_models.Feature(
self.feature_1 = core_models.FeatureEntry(
name='feature one', summary='sum', category=1)
self.feature_1.put()
self.feature_id = self.feature_1.key.integer_id()
self.gate_1 = Gate(id=1, feature_id=self.feature_id, stage_id=1,
gate_type=1, state=Approval.NA)
gate_type=1, state=Vote.NA)
self.gate_1.put()
self.gate_2 = Gate(id=2, feature_id=self.feature_id, stage_id=2,
gate_type=2, state=Approval.NA)
gate_type=2, state=Vote.NA)
self.gate_2.put()
self.handler = approvals_api.ApprovalsAPI()
@ -244,7 +244,7 @@ class ApprovalsAPITest(testing_config.CustomTestCase):
class ApprovalConfigsAPITest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = core_models.Feature(
self.feature_1 = core_models.FeatureEntry(
name='feature one', summary='sum', category=1)
self.feature_1.put()
self.feature_1_id = self.feature_1.key.integer_id()
@ -253,7 +253,7 @@ class ApprovalConfigsAPITest(testing_config.CustomTestCase):
owners=['one_a@example.com', 'one_b@example.com'])
self.config_1.put()
self.feature_2 = core_models.Feature(
self.feature_2 = core_models.FeatureEntry(
name='feature two', summary='sum', category=1)
self.feature_2.put()
self.feature_2_id = self.feature_2.key.integer_id()
@ -262,7 +262,7 @@ class ApprovalConfigsAPITest(testing_config.CustomTestCase):
owners=['two_a@example.com', 'two_b@example.com'])
self.config_2.put()
self.feature_3 = core_models.Feature(
self.feature_3 = core_models.FeatureEntry(
name='feature three', summary='sum', category=1)
self.feature_3.put()
self.feature_3_id = self.feature_3.key.integer_id()

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

@ -77,7 +77,7 @@ class CommentsConvertersTest(testing_config.CustomTestCase):
class CommentsAPITest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = core_models.Feature(
self.feature_1 = core_models.FeatureEntry(
name='feature one', summary='sum', category=1)
self.feature_1.put()
self.feature_id = self.feature_1.key.integer_id()

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

@ -290,6 +290,8 @@ def feature_entry_to_json_verbose(fe: FeatureEntry) -> dict[str, Any]:
stages['dev_trial'], 'ios_first', True)
d['dt_milestone_webview_start'] = _stage_attr(
stages['dev_trial'], 'webview_first', True)
d['ready_for_trial_url'] = _stage_attr(
stages['dev_trial'], 'announcement_url')
# Origin trial stage fields.
d['ot_milestone_desktop_start'] = _stage_attr(
@ -320,6 +322,7 @@ def feature_entry_to_json_verbose(fe: FeatureEntry) -> dict[str, Any]:
# Ship stage fields.
d['intent_to_ship_url'] = _stage_attr(stages['ship'], 'intent_thread_url')
d['finch_url'] = _stage_attr(stages['ship'], 'finch_url')
impl_status_chrome = d.pop('impl_status_chrome', None)
standard_maturity = d.pop('standard_maturity', None)
@ -427,3 +430,62 @@ def feature_entry_to_json_verbose(fe: FeatureEntry) -> dict[str, Any]:
del_none(d) # Further prune response by removing null/[] values.
return d
def feature_entry_to_json_basic(fe: FeatureEntry) -> dict[str, Any]:
"""Returns a dictionary with basic info about a feature."""
# Return an empty dictionary if the entity has not been saved to datastore.
if not fe.key:
return {}
return {
'id': fe.key.integer_id(),
'name': fe.name,
'summary': fe.summary,
'unlisted': fe.unlisted,
'blink_components': fe.blink_components or [],
'browsers': {
'chrome': {
'bug': fe.bug_url,
'blink_components': fe.blink_components or [],
'devrel': fe.devrel_emails or [],
'owners': fe.owner_emails or [],
'origintrial': fe.impl_status_chrome == ORIGIN_TRIAL,
'intervention': fe.impl_status_chrome == INTERVENTION,
'prefixed': fe.prefixed,
'flag': fe.impl_status_chrome == BEHIND_A_FLAG,
'status': {
'text': IMPLEMENTATION_STATUS[fe.impl_status_chrome],
'val': fe.impl_status_chrome
}
},
'ff': {
'view': {
'text': VENDOR_VIEWS[fe.ff_views],
'val': fe.ff_views,
'url': fe.ff_views_link,
'notes': fe.ff_views_notes,
}
},
'safari': {
'view': {
'text': VENDOR_VIEWS[fe.safari_views],
'val': fe.safari_views,
'url': fe.safari_views_link,
'notes': fe.safari_views_notes,
}
},
'webdev': {
'view': {
'text': WEB_DEV_VIEWS[fe.web_dev_views],
'val': fe.web_dev_views,
'url': fe.web_dev_views_link,
'notes': fe.web_dev_views_notes,
}
},
'other': {
'view': {
'notes': fe.other_views_notes,
}
},
}
}

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

@ -85,7 +85,7 @@ class FeaturesAPI(basehandlers.APIHandler):
return {'message': 'ID does not match any feature.'}
feature.deleted = True
feature.put()
rediscache.delete_keys_with_prefix(Feature.feature_cache_prefix())
rediscache.delete_keys_with_prefix(FeatureEntry.feature_cache_prefix())
# Write for new FeatureEntry entity.
feature_entry: Optional[FeatureEntry] = (

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

@ -20,7 +20,7 @@ import werkzeug.exceptions # Flask HTTP stuff.
from api import features_api
from internals import core_enums
from internals import core_models
from internals.core_models import Feature, FeatureEntry, MilestoneSet, Stage
from internals import user_models
from framework import rediscache
@ -30,7 +30,7 @@ test_app = flask.Flask(__name__)
class FeaturesAPITestDelete(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = core_models.Feature(
self.feature_1 = FeatureEntry(
name='feature one', summary='sum', category=1,
intent_stage=core_enums.INTENT_IMPLEMENT)
self.feature_1.put()
@ -45,7 +45,7 @@ class FeaturesAPITestDelete(testing_config.CustomTestCase):
def tearDown(self):
cache_key = '%s|%s' % (
core_models.Feature.DEFAULT_CACHE_KEY, self.feature_1.key.integer_id())
FeatureEntry.DEFAULT_CACHE_KEY, self.feature_1.key.integer_id())
self.feature_1.key.delete()
self.app_admin.key.delete()
testing_config.sign_out()
@ -59,7 +59,7 @@ class FeaturesAPITestDelete(testing_config.CustomTestCase):
actual_json = self.handler.do_delete(feature_id=self.feature_id)
self.assertEqual({'message': 'Done'}, actual_json)
revised_feature = core_models.Feature.get_by_id(self.feature_id)
revised_feature = FeatureEntry.get_by_id(self.feature_id)
self.assertTrue(revised_feature.deleted)
def test_delete__forbidden(self):
@ -70,7 +70,7 @@ class FeaturesAPITestDelete(testing_config.CustomTestCase):
with self.assertRaises(werkzeug.exceptions.Forbidden):
self.handler.do_delete(feature_id=self.feature_id)
revised_feature = core_models.Feature.get_by_id(self.feature_id)
revised_feature = FeatureEntry.get_by_id(self.feature_id)
self.assertFalse(revised_feature.deleted)
def test_delete__invalid(self):
@ -81,7 +81,7 @@ class FeaturesAPITestDelete(testing_config.CustomTestCase):
with self.assertRaises(werkzeug.exceptions.BadRequest):
self.handler.do_delete()
revised_feature = core_models.Feature.get_by_id(self.feature_id)
revised_feature = FeatureEntry.get_by_id(self.feature_id)
self.assertFalse(revised_feature.deleted)
def test_delete__not_found(self):
@ -92,53 +92,57 @@ class FeaturesAPITestDelete(testing_config.CustomTestCase):
with self.assertRaises(werkzeug.exceptions.NotFound):
self.handler.do_delete(feature_id=self.feature_id + 1)
revised_feature = core_models.Feature.get_by_id(self.feature_id)
revised_feature = FeatureEntry.get_by_id(self.feature_id)
self.assertFalse(revised_feature.deleted)
class FeaturesAPITestGet_NewSchema(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = core_models.Feature(
name='feature one', summary='sum Z',
owner=['feature_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT)
self.feature_1.put()
self.feature_1_id = self.feature_1.key.integer_id()
self.fe_1 = core_models.FeatureEntry(
id=self.feature_1_id,
self.feature_1 = FeatureEntry(
name='feature one', summary='sum Z',
owner_emails=['feature_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT)
self.fe_1.put()
self.feature_1.put()
self.feature_1_id = self.feature_1.key.integer_id()
self.feature_2 = core_models.Feature(
name='feature two', summary='sum K',
owner=['other_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT)
self.feature_2.put()
self.feature_2_id = self.feature_2.key.integer_id()
self.fe_2 = core_models.FeatureEntry(
id=self.feature_2_id,
self.feature_2 = FeatureEntry(
name='feature two', summary='sum K',
owner_emails=['other_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT)
self.fe_2.put()
self.feature_2.put()
self.feature_2_id = self.feature_2.key.integer_id()
self.feature_3 = core_models.Feature(
self.feature_3 = FeatureEntry(
name='feature three', summary='sum A',
owner=['other_owner@example.com'], category=1,
owner_emails=['other_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT,
unlisted=True)
self.feature_3.put()
self.feature_3_id = self.feature_3.key.integer_id()
self.fe_3 = core_models.FeatureEntry(
id=self.feature_3_id,
# Feature entities for testing legacy functions.
self.legacy_feature_1 = Feature(
name='feature one', summary='sum Z',
owner=['feature_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT)
self.legacy_feature_1.put()
self.legacy_feature_1_id = self.feature_1.key.integer_id()
self.legacy_feature_2 = Feature(
name='feature two', summary='sum K',
owner=['other_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT)
self.legacy_feature_2.put()
self.legacy_feature_2_id = self.feature_2.key.integer_id()
self.legacy_feature_3 = Feature(
name='feature three', summary='sum A',
owner_emails=['other_owner@example.com'], category=1,
owner=['other_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT,
unlisted=True)
self.fe_3.put()
self.legacy_feature_3.put()
self.legacy_feature_3_id = self.feature_3.key.integer_id()
self.request_path = '/api/v0/features'
self.handler = features_api.FeaturesAPI()
@ -148,15 +152,13 @@ class FeaturesAPITestGet_NewSchema(testing_config.CustomTestCase):
self.app_admin.put()
def tearDown(self):
self.feature_1.key.delete()
self.feature_2.key.delete()
self.feature_3.key.delete()
self.fe_1.key.delete()
self.fe_2.key.delete()
self.fe_3.key.delete()
self.app_admin.key.delete()
for kind in [Feature, FeatureEntry, user_models.AppUser]:
for entity in kind.query():
entity.key.delete()
testing_config.sign_out()
rediscache.delete_keys_with_prefix('features|*')
rediscache.delete_keys_with_prefix('FeatureEntries|*')
def test_get__all_listed(self):
"""Get all features that are listed."""
@ -239,8 +241,8 @@ class FeaturesAPITestGet_NewSchema(testing_config.CustomTestCase):
def test_get__all_unlisted_no_perms(self):
"""JSON feed does not include unlisted features for users who can't edit."""
self.fe_1.unlisted = True
self.fe_1.put()
self.feature_1.unlisted = True
self.feature_1.put()
# No signed-in user
with test_app.test_request_context(self.request_path):
@ -259,8 +261,8 @@ class FeaturesAPITestGet_NewSchema(testing_config.CustomTestCase):
def test_get__all_unlisted_can_edit(self):
"""JSON feed includes unlisted features for users who may edit."""
self.fe_1.unlisted = True
self.fe_1.put()
self.feature_1.unlisted = True
self.feature_1.put()
# Signed-in user with permissions
testing_config.sign_in('admin@example.com', 123567890)
@ -318,27 +320,58 @@ class FeaturesAPITestGet_NewSchema(testing_config.CustomTestCase):
class FeaturesAPITestGet_OldSchema(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = core_models.Feature(
self.feature_1 = FeatureEntry(
name='feature one', summary='sum Z',
owner=['feature_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT, shipped_milestone=1)
owner_emails=['feature_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT, feature_type=0)
self.feature_1.put()
self.feature_1_id = self.feature_1.key.integer_id()
self.feature_2 = core_models.Feature(
self.ship_stage_1 = Stage(feature_id=self.feature_1_id,
stage_type=160, milestones=MilestoneSet(desktop_first=1))
self.ship_stage_1.put()
self.feature_2 = FeatureEntry(
name='feature two', summary='sum K',
owner=['other_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT, shipped_milestone=2)
owner_emails=['other_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT, feature_type=1)
self.feature_2.put()
self.feature_2_id = self.feature_2.key.integer_id()
self.ship_stage_2 = Stage(feature_id=self.feature_1_id,
stage_type=260, milestones=MilestoneSet(desktop_first=2))
self.feature_3 = core_models.Feature(
self.feature_3 = FeatureEntry(
name='feature three', summary='sum A',
owner=['other_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT, shipped_milestone=2,
owner_emails=['other_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT, feature_type=2,
unlisted=True)
self.feature_3.put()
self.feature_3_id = self.feature_3.key.integer_id()
self.ship_stage_3 = Stage(feature_id=self.feature_1_id,
stage_type=360, milestones=MilestoneSet(desktop_first=2))
# Feature entities for testing legacy functions.
self.legacy_feature_1 = Feature(
name='feature one', summary='sum Z',
owner=['feature_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT,
shipped_milestone=1)
self.legacy_feature_1.put()
self.legacy_feature_1_id = self.feature_1.key.integer_id()
self.legacy_feature_2 = Feature(
name='feature two', summary='sum K',
owner=['other_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT,
shipped_milestone=2)
self.legacy_feature_2.put()
self.legacy_feature_2_id = self.feature_2.key.integer_id()
self.legacy_feature_3 = Feature(
name='feature three', summary='sum A',
owner=['other_owner@example.com'], category=1,
intent_stage=core_enums.INTENT_IMPLEMENT,
shipped_milestone=2, unlisted=True)
self.legacy_feature_3.put()
self.legacy_feature_3_id = self.feature_3.key.integer_id()
self.request_path = '/api/v0/features'
self.handler = features_api.FeaturesAPI()
@ -348,12 +381,12 @@ class FeaturesAPITestGet_OldSchema(testing_config.CustomTestCase):
self.app_admin.put()
def tearDown(self):
self.feature_1.key.delete()
self.feature_2.key.delete()
self.feature_3.key.delete()
self.app_admin.key.delete()
for kind in [Feature, FeatureEntry, Stage, user_models.AppUser]:
for entity in kind.query():
entity.key.delete()
testing_config.sign_out()
rediscache.delete_keys_with_prefix('features|*')
rediscache.delete_keys_with_prefix('FeatureEntries|*')
def test_get__in_milestone_listed(self):
"""Get all features in a specific milestone that are listed."""
@ -373,8 +406,8 @@ class FeaturesAPITestGet_OldSchema(testing_config.CustomTestCase):
def test_get__in_milestone_unlisted_no_perms(self):
"""JSON feed does not include unlisted features for users who can't edit."""
self.feature_1.unlisted = True
self.feature_1.put()
self.legacy_feature_1.unlisted = True
self.legacy_feature_1.put()
# No signed-in user
with test_app.test_request_context(self.request_path+'?milestone=1'):

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

@ -28,7 +28,6 @@ export function formatFeatureForEdit(feature) {
// from feature.standards
spec_link: feature.standards.spec,
standardization: feature.standards.status.val,
standard_maturity: feature.standards.maturity.val,
tag_review_status: feature.tag_review_status_int,

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

@ -35,7 +35,7 @@ from framework import users
from framework import utils
from framework import xsrf
from internals import approval_defs
from internals.core_models import Feature
from internals.core_models import FeatureEntry
from internals import user_models
from google.auth.transport import requests
@ -117,12 +117,13 @@ class BaseHandler(flask.views.MethodView):
self.abort(400, msg='Parameter %r was not a bool' % name)
return val
def get_specified_feature(self, feature_id: Optional[int]=None) -> Feature:
def get_specified_feature(
self, feature_id: Optional[int]=None) -> FeatureEntry:
"""Get the feature specified in the featureId parameter."""
feature_id = (feature_id or
self.get_int_param('featureId', required=True))
# Load feature directly from NDB so as to never get a stale cached copy.
feature = Feature.get_by_id(feature_id)
feature = FeatureEntry.get_by_id(feature_id)
if not feature:
self.abort(404, msg='Feature not found')
user = self.get_current_user()

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

@ -15,12 +15,13 @@
import logging
from typing import Optional
import flask
import settings
from framework.users import User
from internals import feature_helpers
from internals.core_models import Feature
from internals.core_models import FeatureEntry
from internals.user_models import AppUser
@ -73,7 +74,7 @@ def can_edit_any_feature(user: User) -> bool:
return app_user.is_admin or app_user.is_site_editor
def feature_edit_list(user: User) -> list[Feature]:
def feature_edit_list(user: User) -> list[int]:
"""Return a list of features the current user can edit"""
if not user:
return []
@ -100,18 +101,19 @@ def can_edit_feature(user: User, feature_id: int) -> bool:
return False
# Load feature directly from NDB so as to never get a stale cached copy.
feature = Feature.get_by_id(feature_id)
feature: Optional[FeatureEntry] = FeatureEntry.get_by_id(feature_id)
if not feature:
return False
email = user.email()
# Check if the user is an owner, editor, or creator for this feature.
# If yes, the feature can be edited.
return (email in feature.owner or
email in feature.editors or email == feature.creator)
return (email in feature.owner_emails or
email in feature.editor_emails or
email == feature.creator_email)
def can_approve_feature(user: User, feature: Feature, approvers) -> bool:
def can_approve_feature(user: User, feature: FeatureEntry, approvers) -> bool:
"""Return True if the user is allowed to approve the given feature."""
# TODO(jrobbins): make this per-feature
if not can_view_feature(user, feature):
@ -192,7 +194,7 @@ def validate_feature_edit_permission(handler_obj, feature_id: int):
# Redirect to 404 if feature is not found.
# Load feature directly from NDB so as to never get a stale cached copy.
if Feature.get_by_id(int(feature_id)) is None:
if FeatureEntry.get_by_id(int(feature_id)) is None:
handler_obj.abort(404, msg='Feature not found')
# Redirect to 403 if user does not have edit permission for feature.

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

@ -77,11 +77,11 @@ class PermissionFunctionTests(testing_config.CustomTestCase):
self.users.append(self.feature_editor)
# Feature for checking permissions against
self.feature_1 = core_models.Feature(
self.feature_1 = core_models.FeatureEntry(
name='feature one', summary='sum',
creator="feature_creator@example.com",
owner=['feature_owner@example.com'],
editors=['feature_editor@example.com'], category=1)
creator_email="feature_creator@example.com",
owner_emails=['feature_owner@example.com'],
editor_emails=['feature_editor@example.com'], category=1)
self.feature_1.put()
self.feature_id = self.feature_1.key.integer_id()

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

@ -150,33 +150,6 @@ class Feature(DictModel):
except Exception as e:
logging.error(e)
def crbug_number(self) -> Optional[str]:
if not self.bug_url:
return None
m = re.search(r'[\/|?id=]([0-9]+)$', self.bug_url)
if m:
return m.group(1)
return None
def new_crbug_url(self) -> str:
url = 'https://bugs.chromium.org/p/chromium/issues/entry'
if len(self.blink_components) > 0:
params = ['components=' + self.blink_components[0]]
else:
params = ['components=' + settings.DEFAULT_COMPONENT]
crbug_number = self.crbug_number()
if crbug_number and self.impl_status_chrome in (
NO_ACTIVE_DEV,
PROPOSED,
IN_DEVELOPMENT,
BEHIND_A_FLAG,
ORIGIN_TRIAL,
INTERVENTION):
params.append('blocking=' + crbug_number)
if self.owner:
params.append('cc=' + ','.join(self.owner))
return url + '?' + '&'.join(params)
def stash_values(self) -> None:
# Stash existing values when entity is created so we can diff property
@ -490,74 +463,30 @@ class FeatureEntry(ndb.Model): # Copy from Feature
def feature_cache_prefix(cls):
return '%s|*' % (cls.DEFAULT_CACHE_KEY)
@classmethod
def get_feature_entry(self, feature_id: int, update_cache: bool=False
) -> Optional[FeatureEntry]:
KEY = self.feature_cache_key(
FeatureEntry.DEFAULT_CACHE_KEY, feature_id)
feature = rediscache.get(KEY)
def stash_values(self) -> None:
# Stash existing values when entity is created so we can diff property
# values later in put() to know what's changed.
# https://stackoverflow.com/a/41344898
if feature is None or update_cache:
entry = FeatureEntry.get_by_id(feature_id)
if entry:
if entry.deleted:
return None
rediscache.set(KEY, entry)
for prop_name in self._properties.keys():
old_val = getattr(self, prop_name, None)
setattr(self, '_old_' + prop_name, old_val)
setattr(self, '_values_stashed', True)
return entry
def put(self, notify: bool=False, **kwargs) -> Any:
key = super(FeatureEntry, self).put(**kwargs)
# TODO(danielrsmith): Notifying subscribers will not fully function
# until stage fields are also stashed and marked as changed.
# Notifying and saving amendments is handled by Feature until then.
#
# notifier_helpers.notify_subscribers_and_save_amendments(self, notify)
@classmethod
def filter_unlisted(self, entry_list: list[FeatureEntry]
) -> list[FeatureEntry]:
"""Filters feature entries to display only features the user should see."""
user = users.get_current_user()
email = None
if user:
email = user.email()
allowed_entries = []
for fe in entry_list:
# Owners and editors of a feature can see their unlisted features.
if (not fe.unlisted or
email in fe.owners or
email in fe.editors or
(email is not None and fe.creator == email)):
allowed_entries.append(fe)
# Invalidate rediscache for the individual feature view.
cache_key = FeatureEntry.feature_cache_key(
FeatureEntry.DEFAULT_CACHE_KEY, self.key.integer_id())
rediscache.delete(cache_key)
return allowed_entries
@classmethod
def get_by_ids(self, entry_ids: list[int], update_cache: bool=False
) -> list[int]:
"""Return a list of FeatureEntry instances for the specified features.
Because the cache may rarely have stale data, this should only be
used for displaying data read-only, not for populating forms or
procesing a POST to edit data. For editing use case, load the
data from NDB directly.
"""
result_dict: dict[int, int] = {}
futures = []
for fe_id in entry_ids:
lookup_key = self.feature_cache_key(
FeatureEntry.DEFAULT_CACHE_KEY, fe_id)
entry = rediscache.get(lookup_key)
if entry is None or update_cache:
futures.append(FeatureEntry.get_by_id_async(fe_id))
else:
result_dict[fe_id] = entry
for future in futures:
entry = future.get_result()
if entry and not entry.deleted:
store_key = self.feature_cache_key(
FeatureEntry.DEFAULT_CACHE_KEY, entry.key.integer_id())
rediscache.set(store_key, entry)
result_dict[entry.key.integer_id()] = entry
result_list = [result_dict[fe_id] for fe_id in entry_ids
if fe_id in result_dict]
return result_list
return key
# Note: get_in_milestone will be in a new file legacy_queries.py.

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

@ -47,31 +47,52 @@ class ModelsFunctionsTest(testing_config.CustomTestCase):
class FeatureTest(testing_config.CustomTestCase):
def setUp(self):
self.feature_2 = core_models.Feature(
name='feature b', summary='sum', owner=['feature_owner@example.com'],
category=1)
self.feature_2 = core_models.FeatureEntry(
name='feature b', summary='sum',
owner_emails=['feature_owner@example.com'], category=1)
self.feature_2.put()
self.feature_1 = core_models.Feature(
name='feature a', summary='sum', owner=['feature_owner@example.com'],
category=1, impl_status_chrome=3)
self.feature_1 = core_models.FeatureEntry(
name='feature a', summary='sum', impl_status_chrome=3,
owner_emails=['feature_owner@example.com'], category=1)
self.feature_1.put()
self.feature_4 = core_models.Feature(
name='feature d', summary='sum', owner=['feature_owner@example.com'],
category=1, impl_status_chrome=2)
self.feature_4 = core_models.FeatureEntry(
name='feature d', summary='sum', category=1, impl_status_chrome=2,
owner_emails=['feature_owner@example.com'])
self.feature_4.put()
self.feature_3 = core_models.Feature(
name='feature c', summary='sum', owner=['feature_owner@example.com'],
category=1, impl_status_chrome=2)
self.feature_3 = core_models.FeatureEntry(
name='feature c', summary='sum', category=1, impl_status_chrome=2,
owner_emails=['feature_owner@example.com'])
self.feature_3.put()
# Legacy entities for testing legacy functions.
self.legacy_feature_2 = core_models.Feature(
name='feature b', summary='sum',
owner=['feature_owner@example.com'], category=1)
self.legacy_feature_2.put()
self.legacy_feature_1 = core_models.Feature(
name='feature a', summary='sum', impl_status_chrome=3,
owner=['feature_owner@example.com'], category=1)
self.legacy_feature_1.put()
self.legacy_feature_4 = core_models.Feature(
name='feature d', summary='sum', category=1, impl_status_chrome=2,
owner=['feature_owner@example.com'])
self.legacy_feature_4.put()
self.legacy_feature_3 = core_models.Feature(
name='feature c', summary='sum', category=1, impl_status_chrome=2,
owner=['feature_owner@example.com'])
self.legacy_feature_3.put()
def tearDown(self):
self.feature_1.key.delete()
self.feature_2.key.delete()
self.feature_3.key.delete()
self.feature_4.key.delete()
for kind in [core_models.Feature, core_models.FeatureEntry]:
for entity in kind.query():
entity.key.delete()
rediscache.flushall()
def test_get_all__normal(self):
@ -79,7 +100,7 @@ class FeatureTest(testing_config.CustomTestCase):
actual = feature_helpers.get_all(update_cache=True)
names = [f['name'] for f in actual]
self.assertEqual(
['feature c', 'feature d', 'feature a', 'feature b'],
['feature b', 'feature a', 'feature d', 'feature c'],
names)
self.feature_1.summary = 'revised summary'
@ -87,7 +108,7 @@ class FeatureTest(testing_config.CustomTestCase):
actual = feature_helpers.get_all(update_cache=True)
names = [f['name'] for f in actual]
self.assertEqual(
['feature a', 'feature c', 'feature d', 'feature b'],
['feature b', 'feature a', 'feature d', 'feature c'],
names)
def test_get_all__category(self):
@ -111,16 +132,16 @@ class FeatureTest(testing_config.CustomTestCase):
def test_get_all__owner(self):
"""We can retrieve a list of all features with a given owner."""
actual = feature_helpers.get_all(
filterby=('owner', 'owner@example.com'), update_cache=True)
filterby=('owner_emails', 'owner@example.com'), update_cache=True)
names = [f['name'] for f in actual]
self.assertEqual(
[],
names)
self.feature_1.owner = ['owner@example.com']
self.feature_1.owner_emails = ['owner@example.com']
self.feature_1.put() # Changes updated field.
actual = feature_helpers.get_all(
filterby=('owner', 'owner@example.com'), update_cache=True)
filterby=('owner_emails', 'owner@example.com'), update_cache=True)
names = [f['name'] for f in actual]
self.assertEqual(
['feature a'],
@ -129,26 +150,26 @@ class FeatureTest(testing_config.CustomTestCase):
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.owner_emails = ['feature_owner@example.com']
self.feature_2.put()
testing_config.sign_in('feature_owner@example.com', 1234567890)
actual = feature_helpers.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)
['feature b', 'feature a', 'feature d', 'feature c'], 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.editor_emails = ['feature_editor@example.com']
self.feature_2.put()
testing_config.sign_in("feature_editor@example.com", 1234567890)
actual = feature_helpers.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)
['feature b', 'feature a', 'feature d', 'feature c'], names)
def test_get_by_ids__empty(self):
"""A request to load zero features returns zero results."""
@ -165,9 +186,9 @@ class FeatureTest(testing_config.CustomTestCase):
self.assertEqual('feature a', actual[0]['name'])
self.assertEqual('feature b', actual[1]['name'])
lookup_key_1 = '%s|%s' % (core_models.Feature.DEFAULT_CACHE_KEY,
lookup_key_1 = '%s|%s' % (core_models.FeatureEntry.DEFAULT_CACHE_KEY,
self.feature_1.key.integer_id())
lookup_key_2 = '%s|%s' % (core_models.Feature.DEFAULT_CACHE_KEY,
lookup_key_2 = '%s|%s' % (core_models.FeatureEntry.DEFAULT_CACHE_KEY,
self.feature_2.key.integer_id())
self.assertEqual('feature a', rediscache.get(lookup_key_1)['name'])
self.assertEqual('feature b', rediscache.get(lookup_key_2)['name'])
@ -175,7 +196,7 @@ class FeatureTest(testing_config.CustomTestCase):
def test_get_by_ids__cache_hit(self):
"""We can load features from rediscache."""
cache_key = '%s|%s' % (
core_models.Feature.DEFAULT_CACHE_KEY, self.feature_1.key.integer_id())
core_models.FeatureEntry.DEFAULT_CACHE_KEY, self.feature_1.key.integer_id())
cached_feature = {
'name': 'fake cached_feature',
'id': self.feature_1.key.integer_id(),
@ -206,9 +227,7 @@ class FeatureTest(testing_config.CustomTestCase):
def test_get_by_ids__cached_correctly(self):
"""We should no longer be able to trigger bug #1647."""
# Cache one to try to trigger the bug.
feature_helpers.get_by_ids([
self.feature_2.key.integer_id(),
])
feature_helpers.get_by_ids([self.feature_2.key.integer_id()])
# Now do the lookup, but it would cache feature_2 at the key for feature_3.
feature_helpers.get_by_ids([
@ -246,8 +265,8 @@ class FeatureTest(testing_config.CustomTestCase):
def test_get_chronological__unlisted(self):
"""Unlisted features are not included in the list."""
self.feature_2.unlisted = True
self.feature_2.put()
self.legacy_feature_2.unlisted = True
self.legacy_feature_2.put()
actual = feature_helpers.get_chronological()
names = [f['name'] for f in actual]
self.assertEqual(
@ -256,8 +275,8 @@ class FeatureTest(testing_config.CustomTestCase):
def test_get_chronological__unlisted_shown(self):
"""Unlisted features are included for users with edit access."""
self.feature_2.unlisted = True
self.feature_2.put()
self.legacy_feature_2.unlisted = True
self.legacy_feature_2.put()
actual = feature_helpers.get_chronological(show_unlisted=True)
names = [f['name'] for f in actual]
self.assertEqual(
@ -266,21 +285,21 @@ class FeatureTest(testing_config.CustomTestCase):
def test_get_in_milestone__normal(self):
"""We can retrieve a list of features."""
self.feature_2.impl_status_chrome = 7
self.feature_2.shipped_milestone = 1
self.feature_2.put()
self.legacy_feature_2.impl_status_chrome = 7
self.legacy_feature_2.shipped_milestone = 1
self.legacy_feature_2.put()
self.feature_1.impl_status_chrome = 5
self.feature_1.shipped_milestone = 1
self.feature_1.put()
self.legacy_feature_1.impl_status_chrome = 5
self.legacy_feature_1.shipped_milestone = 1
self.legacy_feature_1.put()
self.feature_3.impl_status_chrome = 5
self.feature_3.shipped_milestone = 1
self.feature_3.put()
self.legacy_feature_3.impl_status_chrome = 5
self.legacy_feature_3.shipped_milestone = 1
self.legacy_feature_3.put()
self.feature_4.impl_status_chrome = 7
self.feature_4.shipped_milestone = 2
self.feature_4.put()
self.legacy_feature_4.impl_status_chrome = 7
self.legacy_feature_4.shipped_milestone = 2
self.legacy_feature_4.put()
actual = feature_helpers.get_in_milestone(milestone=1)
removed = [f['name'] for f in actual['Removed']]
@ -301,22 +320,22 @@ class FeatureTest(testing_config.CustomTestCase):
def test_get_in_milestone__unlisted(self):
"""Unlisted features should not be listed for users who can't edit."""
self.feature_2.unlisted = True
self.feature_2.impl_status_chrome = 7
self.feature_2.shipped_milestone = 1
self.feature_2.put()
self.legacy_feature_2.unlisted = True
self.legacy_feature_2.impl_status_chrome = 7
self.legacy_feature_2.shipped_milestone = 1
self.legacy_feature_2.put()
self.feature_1.impl_status_chrome = 5
self.feature_1.shipped_milestone = 1
self.feature_1.put()
self.legacy_feature_1.impl_status_chrome = 5
self.legacy_feature_1.shipped_milestone = 1
self.legacy_feature_1.put()
self.feature_3.impl_status_chrome = 5
self.feature_3.shipped_milestone = 1
self.feature_3.put()
self.legacy_feature_3.impl_status_chrome = 5
self.legacy_feature_3.shipped_milestone = 1
self.legacy_feature_3.put()
self.feature_4.impl_status_chrome = 7
self.feature_4.shipped_milestone = 2
self.feature_4.put()
self.legacy_feature_4.impl_status_chrome = 7
self.legacy_feature_4.shipped_milestone = 2
self.legacy_feature_4.put()
actual = feature_helpers.get_in_milestone(milestone=1)
self.assertEqual(
@ -325,22 +344,22 @@ class FeatureTest(testing_config.CustomTestCase):
def test_get_in_milestone__unlisted_shown(self):
"""Unlisted features should be listed for users who can edit."""
self.feature_2.unlisted = True
self.feature_2.impl_status_chrome = 7
self.feature_2.shipped_milestone = 1
self.feature_2.put()
self.legacy_feature_2.unlisted = True
self.legacy_feature_2.impl_status_chrome = 7
self.legacy_feature_2.shipped_milestone = 1
self.legacy_feature_2.put()
self.feature_1.impl_status_chrome = 5
self.feature_1.shipped_milestone = 1
self.feature_1.put()
self.legacy_feature_1.impl_status_chrome = 5
self.legacy_feature_1.shipped_milestone = 1
self.legacy_feature_1.put()
self.feature_3.impl_status_chrome = 5
self.feature_3.shipped_milestone = 1
self.feature_3.put()
self.legacy_feature_3.impl_status_chrome = 5
self.legacy_feature_3.shipped_milestone = 1
self.legacy_feature_3.put()
self.feature_4.impl_status_chrome = 7
self.feature_4.shipped_milestone = 2
self.feature_4.put()
self.legacy_feature_4.impl_status_chrome = 7
self.legacy_feature_4.shipped_milestone = 2
self.legacy_feature_4.put()
actual = feature_helpers.get_in_milestone(
milestone=1, show_unlisted=True)

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

@ -14,16 +14,48 @@
# limitations under the License.
import logging
import re
from typing import Any, Optional
from google.cloud import ndb # type: ignore
from api.converters import feature_to_legacy_json
from api import converters
from framework import rediscache
from framework import users
from internals.core_enums import *
from internals.core_models import Feature
from internals.core_models import Feature, FeatureEntry
import settings
def get_by_ids(feature_ids: list[int],
def _crbug_number(bug_url: Optional[str]) -> Optional[str]:
if bug_url is None:
return None
m = re.search(r'[\/|?id=]([0-9]+)$', bug_url)
if m:
return m.group(1)
return None
def _new_crbug_url(blink_components: Optional[list[str]],
bug_url: Optional[str], impl_status_chrome: int,
owner_emails: list[str]=list()) -> str:
url = 'https://bugs.chromium.org/p/chromium/issues/entry'
if blink_components and len(blink_components) > 0:
params = ['components=' + blink_components[0]]
else:
params = ['components=' + settings.DEFAULT_COMPONENT]
crbug_number = _crbug_number(bug_url)
if crbug_number and impl_status_chrome in (
NO_ACTIVE_DEV,
PROPOSED,
IN_DEVELOPMENT,
BEHIND_A_FLAG,
ORIGIN_TRIAL,
INTERVENTION):
params.append('blocking=' + crbug_number)
if owner_emails:
params.append('cc=' + ','.join(owner_emails))
return url + '?' + '&'.join(params)
def get_by_ids_legacy(feature_ids: list[int],
update_cache: bool=False) -> list[dict[str, Any]]:
"""Return a list of JSON dicts for the specified features.
@ -47,10 +79,12 @@ def get_by_ids(feature_ids: list[int],
for future in futures:
unformatted_feature: Optional[Feature] = future.get_result()
if unformatted_feature and not unformatted_feature.deleted:
feature = feature_to_legacy_json(unformatted_feature)
feature = converters.feature_to_legacy_json(unformatted_feature)
feature['updated_display'] = (
unformatted_feature.updated.strftime("%Y-%m-%d"))
feature['new_crbug_url'] = unformatted_feature.new_crbug_url()
feature['new_crbug_url'] = _new_crbug_url(
unformatted_feature.blink_components, unformatted_feature.bug_url,
unformatted_feature.impl_status_chrome, unformatted_feature.owner)
store_key = Feature.feature_cache_key(
Feature.DEFAULT_CACHE_KEY, unformatted_feature.key.integer_id())
rediscache.set(store_key, feature)
@ -61,7 +95,7 @@ def get_by_ids(feature_ids: list[int],
if feature_id in result_dict]
return result_list
def get_all(limit=None, order='-updated', filterby=None,
def get_all_legacy(limit=None, order='-updated', filterby=None,
update_cache=False, keys_only=False):
"""Return JSON dicts for entities that fit the filterby criteria.
@ -99,13 +133,13 @@ def get_all(limit=None, order='-updated', filterby=None,
feature_list = query.fetch(limit, keys_only=keys_only)
if not keys_only:
feature_list = [
feature_to_legacy_json(f) for f in feature_list]
converters.feature_to_legacy_json(f) for f in feature_list]
rediscache.set(KEY, feature_list)
return feature_list
def get_feature(feature_id: int, update_cache: bool=False) -> Optional[Feature]:
def get_feature_legacy(feature_id: int, update_cache: bool=False) -> Optional[Feature]:
"""Return a JSON dict for a feature.
Because the cache may rarely have stale data, this should only be
@ -117,14 +151,16 @@ def get_feature(feature_id: int, update_cache: bool=False) -> Optional[Feature]:
feature = rediscache.get(KEY)
if feature is None or update_cache:
unformatted_feature = Feature.get_by_id(feature_id)
unformatted_feature: Optional[Feature] = Feature.get_by_id(feature_id)
if unformatted_feature:
if unformatted_feature.deleted:
return None
feature = feature_to_legacy_json(unformatted_feature)
feature = converters.feature_to_legacy_json(unformatted_feature)
feature['updated_display'] = (
unformatted_feature.updated.strftime("%Y-%m-%d"))
feature['new_crbug_url'] = unformatted_feature.new_crbug_url()
feature['new_crbug_url'] = _new_crbug_url(
unformatted_feature.blink_components, unformatted_feature.bug_url,
unformatted_feature.impl_status_chrome, unformatted_feature.owner)
rediscache.set(KEY, feature)
return feature
@ -238,7 +274,7 @@ def get_chronological(limit=None, update_cache: bool=False,
all_features.extend(no_longer_pursuing_features)
all_features = [f for f in all_features if not f.deleted]
feature_list = [feature_to_legacy_json(f) for f in all_features]
feature_list = [converters.feature_to_legacy_json(f) for f in all_features]
Feature._annotate_first_of_milestones(feature_list)
@ -250,7 +286,7 @@ def get_chronological(limit=None, update_cache: bool=False,
def get_in_milestone(milestone: int,
show_unlisted: bool=False) -> dict[str, list[dict[str, Any]]]:
"""Return {reason: [feaure_dict]} with all the reasons a feature can
"""Return {reason: [feature_dict]} with all the reasons a feature can
be part of a milestone.
Because the cache may rarely have stale data, this should only be
@ -386,14 +422,15 @@ def get_in_milestone(milestone: int,
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] = [
feature_to_legacy_json(f) for f in all_features[shipping_type]]
features_by_type[shipping_type] = [converters.feature_to_legacy_json(f)
for f in all_features[shipping_type]]
rediscache.set(cache_key, features_by_type)
for shipping_type in features_by_type:
if not show_unlisted:
features_by_type[shipping_type] = filter_unlisted(features_by_type[shipping_type])
features_by_type[shipping_type] = filter_unlisted(
features_by_type[shipping_type])
return features_by_type
@ -423,3 +460,123 @@ def get_all_with_statuses(statuses, update_cache=False):
rediscache.set(KEY, feature_list)
return feature_list
def get_all(limit: Optional[int]=None,
order: str='-updated', filterby: Optional[tuple[str, Any]]=None,
update_cache: bool=False, keys_only: bool=False) -> list[dict]:
"""Return JSON dicts for entities that fit the filterby criteria.
Because the cache may rarely have stale data, this should only be
used for displaying data read-only, not for populating forms or
procesing a POST to edit data. For editing use case, load the
data from NDB directly.
"""
KEY = '%s|%s|%s|%s' % (
FeatureEntry.DEFAULT_CACHE_KEY, order, limit, keys_only)
# TODO(ericbidelman): Support more than one filter.
if filterby is not None:
s = ('%s%s' % (filterby[0], filterby[1])).replace(' ', '')
KEY += '|%s' % s
feature_list = rediscache.get(KEY)
if feature_list is None or update_cache:
query = FeatureEntry.query().order(-FeatureEntry.updated) #.order('name')
query = query.filter(FeatureEntry.deleted == False)
# TODO(ericbidelman): Support more than one filter.
if filterby:
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(FeatureEntry.owner_emails == comparator,
FeatureEntry.editor_emails == comparator,
FeatureEntry.creator_email == comparator))
else:
query = query.filter(getattr(FeatureEntry, filter_type) == comparator)
feature_list = query.fetch(limit, keys_only=keys_only)
if not keys_only:
feature_list = [
converters.feature_entry_to_json_basic(f) for f in feature_list]
rediscache.set(KEY, feature_list)
return feature_list
def get_feature(
feature_id: int, update_cache: bool=False) -> Optional[FeatureEntry]:
"""Return a JSON dict for a feature.
Because the cache may rarely have stale data, this should only be
used for displaying data read-only, not for populating forms or
procesing a POST to edit data. For editing use case, load the
data from NDB directly.
"""
KEY = Feature.feature_cache_key(FeatureEntry.DEFAULT_CACHE_KEY, feature_id)
feature = rediscache.get(KEY)
if feature is None or update_cache:
unformatted_feature: Optional[FeatureEntry] = (
FeatureEntry.get_by_id(feature_id))
if unformatted_feature:
if unformatted_feature.deleted:
return None
feature = converters.feature_entry_to_json_verbose(unformatted_feature)
feature['updated_display'] = (
unformatted_feature.updated.strftime("%Y-%m-%d"))
feature['new_crbug_url'] = _new_crbug_url(
unformatted_feature.blink_components, unformatted_feature.bug_url,
unformatted_feature.impl_status_chrome,
unformatted_feature.owner_emails)
rediscache.set(KEY, feature)
return feature
def get_by_ids(feature_ids: list[int],
update_cache: bool=False) -> list[dict[str, Any]]:
"""Return a list of JSON dicts for the specified features.
Because the cache may rarely have stale data, this should only be
used for displaying data read-only, not for populating forms or
procesing a POST to edit data. For editing use case, load the
data from NDB directly.
"""
result_dict = {}
futures = []
for feature_id in feature_ids:
lookup_key = FeatureEntry.feature_cache_key(
FeatureEntry.DEFAULT_CACHE_KEY, feature_id)
feature = rediscache.get(lookup_key)
if feature is None or update_cache:
futures.append(FeatureEntry.get_by_id_async(feature_id))
else:
result_dict[feature_id] = feature
for future in futures:
unformatted_feature: Optional[FeatureEntry] = future.get_result()
if unformatted_feature and not unformatted_feature.deleted:
feature = converters.feature_entry_to_json_verbose(unformatted_feature)
if unformatted_feature.updated is not None:
feature['updated_display'] = (
unformatted_feature.updated.strftime("%Y-%m-%d"))
else:
feature['updated_display'] = ''
feature['new_crbug_url'] = _new_crbug_url(
unformatted_feature.blink_components, unformatted_feature.bug_url,
unformatted_feature.impl_status_chrome,
unformatted_feature.owner_emails)
store_key = FeatureEntry.feature_cache_key(
FeatureEntry.DEFAULT_CACHE_KEY, unformatted_feature.key.integer_id())
rediscache.set(store_key, feature)
result_dict[unformatted_feature.key.integer_id()] = feature
result_list = [
result_dict[feature_id] for feature_id in feature_ids
if feature_id in result_dict]
return result_list

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

@ -242,7 +242,7 @@ class FeatureStar(ndb.Model):
if feature_entry.star_count < 0:
logging.error('count would be < 0: %r', (email, feature_id, starred))
return
feature_entry.put() # And, do not call notifiy.
feature_entry.put() # And, do not call notify.
@classmethod
def get_user_stars(self, email):

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

@ -13,11 +13,30 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from framework import cloud_tasks_helpers
from typing import Any, Optional
from api import converters
from framework import cloud_tasks_helpers, rediscache, users
from internals import core_enums
from internals import feature_helpers
from internals import review_models
def _get_changes_as_amendments(f) -> list[review_models.Amendment]:
"""Get all feature changes as Amendment entities."""
# Diff values to see what properties have changed.
amendments = []
for prop_name in f._properties.keys():
if prop_name in (
'created_by', 'updated_by', 'updated', 'created'):
continue
new_val = getattr(f, prop_name, None)
old_val = getattr(f, '_old_' + prop_name, None)
if new_val != old_val:
if (new_val == '' or new_val == False) and old_val is None:
continue
amendments.append(
review_models.Amendment(field_name=prop_name,
old_value=str(old_val), new_value=str(new_val)))
return amendments
def notify_feature_subscribers_of_changes(feature,
amendments: list[review_models.Amendment], is_update: bool) -> None:
"""Async notifies subscribers of new features and property changes to
@ -34,8 +53,33 @@ def notify_feature_subscribers_of_changes(feature,
params = {
'changes': changed_props,
'is_update': is_update,
'feature': feature_helpers.feature_to_legacy_json(feature)
# TODO(danielrsmith): Change converter function
# after switch to using FeatureEntry here.
'feature': converters.feature_to_legacy_json(feature)
}
# Create task to email subscribers.
cloud_tasks_helpers.enqueue_task('/tasks/email-subscribers', params)
def notify_subscribers_and_save_amendments(fe, notify: bool=True) -> None:
"""Notify subscribers of changes to FeatureEntry and save amendments."""
is_update = (fe.key is not None)
amendments = _get_changes_as_amendments(fe)
# Document changes as new Activity entity with amendments only if all true:
# 1. This is an update to an existing feature.
# 2. We used stash_values() to document what fields changed.
# 3. One or more fields were changed.
should_write_activity = (is_update and hasattr(fe, '_values_stashed')
and len(amendments) > 0)
if should_write_activity:
user = users.get_current_user()
email = user.email() if user else None
activity = review_models.Activity(feature_id=fe.key.integer_id(),
author=email, content='')
activity.amendments = amendments
activity.put()
if notify:
notify_feature_subscribers_of_changes(fe, amendments, is_update)

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

@ -281,8 +281,8 @@ class ActivityTest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = core_models.Feature(
name='feature a', summary='sum', owner=['feature_owner@example.com'],
category=1)
name='feature a', summary='sum', category=1,
owner=['feature_owner@example.com'])
self.feature_1.put()
testing_config.sign_in('one@example.com', 123567890)

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

@ -91,6 +91,7 @@ class FeatureCreateHandler(basehandlers.FlaskHandler):
# Remove all feature-related cache.
rediscache.delete_keys_with_prefix(Feature.feature_cache_prefix())
rediscache.delete_keys_with_prefix(FeatureEntry.feature_cache_prefix())
redirect_url = '/guide/edit/' + str(key.integer_id())
return self.redirect(redirect_url)
@ -159,7 +160,7 @@ class FeatureEditHandler(basehandlers.FlaskHandler):
def process_post_data(self, **kwargs):
feature_id = kwargs.get('feature_id', None)
stage_id = kwargs.get('stage_id', 0)
intent_stage_id = kwargs.get('stage_id', 0)
# Validate the user has edit permissions and redirect if needed.
redirect_resp = permissions.validate_feature_edit_permission(
self, feature_id)
@ -168,9 +169,9 @@ class FeatureEditHandler(basehandlers.FlaskHandler):
if feature_id:
# Load feature directly from NDB so as to never get a stale cached copy.
feature = Feature.get_by_id(feature_id)
feature_entry = FeatureEntry.get_by_id(feature_id)
if feature is None:
feature: Feature = Feature.get_by_id(feature_id)
feature_entry: FeatureEntry = FeatureEntry.get_by_id(feature_id)
if feature_entry is None:
self.abort(404, msg='Feature not found')
else:
feature.stash_values()
@ -420,8 +421,8 @@ class FeatureEditHandler(basehandlers.FlaskHandler):
feature.intent_stage = int(self.form.get('intent_stage'))
update_items.append(('intent_stage', int(self.form.get('intent_stage'))))
elif self.form.get('set_stage') == 'on':
feature.intent_stage = stage_id
update_items.append(('intent_stage', stage_id))
feature.intent_stage = intent_stage_id
update_items.append(('intent_stage', intent_stage_id))
if self.touched('category'):
feature.category = int(self.form.get('category'))
@ -582,6 +583,7 @@ class FeatureEditHandler(basehandlers.FlaskHandler):
# Remove all feature-related cache.
rediscache.delete_keys_with_prefix(Feature.feature_cache_prefix())
rediscache.delete_keys_with_prefix(FeatureEntry.feature_cache_prefix())
# Update full-text index.
if feature_entry:

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

@ -23,7 +23,7 @@ from google.cloud import ndb # type: ignore
from framework import rediscache
from internals import core_enums
from internals.core_models import Feature, FeatureEntry, Stage
from internals.core_models import Feature, FeatureEntry, MilestoneSet, Stage
from internals.review_models import Gate
from pages import guide
@ -129,7 +129,8 @@ class FeatureEditHandlerTest(testing_config.CustomTestCase):
core_enums.STAGE_BLINK_ORIGIN_TRIAL,
core_enums.STAGE_BLINK_EXTEND_ORIGIN_TRIAL]
for stage_type in stage_types:
stage = Stage(feature_id=feature_id, stage_type=stage_type)
stage = Stage(feature_id=feature_id, stage_type=stage_type,
milestones=MilestoneSet())
stage.put()
self.request_path = ('/guide/stage/%d/%d' % (

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

@ -14,7 +14,7 @@
# limitations under the License.
# from google.appengine.api import users
from api.converters import feature_to_legacy_json
from api.converters import feature_entry_to_json_verbose
from internals import core_enums
from framework import basehandlers
@ -51,7 +51,7 @@ class IntentEmailPreviewHandler(basehandlers.FlaskHandler):
"""Return a dictionary of data used to render the page."""
page_data = {
'subject_prefix': self.compute_subject_prefix(f, intent_stage),
'feature': feature_to_legacy_json(f),
'feature': feature_entry_to_json_verbose(f),
'sections_to_show': processes.INTENT_EMAIL_SECTIONS.get(
intent_stage, []),
'intent_stage': intent_stage,

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

@ -36,8 +36,8 @@ TESTDATA = testing_config.Testdata(__file__)
class IntentEmailPreviewHandlerTest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = core_models.Feature(
name='feature one', summary='sum', owner=['user1@google.com'],
self.feature_1 = core_models.FeatureEntry(
name='feature one', summary='sum', owner_emails=['user1@google.com'],
category=1, intent_stage=core_enums.INTENT_IMPLEMENT)
self.feature_1.put()
@ -191,11 +191,11 @@ class IntentEmailPreviewTemplateTest(testing_config.CustomTestCase):
def setUp(self):
super(IntentEmailPreviewTemplateTest, self).setUp()
self.feature_1 = core_models.Feature(
name='feature one', summary='sum', owner=['user1@google.com'],
self.feature_1 = core_models.FeatureEntry(
name='feature one', summary='sum', owner_emails=['user1@google.com'],
category=1, intent_stage=core_enums.INTENT_IMPLEMENT)
# Hardcode the key for the template test
self.feature_1.key = ndb.Key('Feature', 234)
self.feature_1.key = ndb.Key('FeatureEntry', 234)
self.feature_1.put()
self.request_path = '/admin/features/launch/%d/%d?intent' % (