chromium-dashboard/internals/feature_helpers.py

583 строки
23 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2022 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License")
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import re
from typing import Any, Optional
from google.cloud import ndb # type: ignore
from api import converters
from framework import rediscache
from framework import users
from internals.core_enums import *
from internals.core_models import Feature, FeatureEntry
import settings
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.
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 = Feature.feature_cache_key(
Feature.DEFAULT_CACHE_KEY, feature_id)
feature = rediscache.get(lookup_key)
if feature is None or update_cache:
futures.append(Feature.get_by_id_async(feature_id))
else:
result_dict[feature_id] = feature
for future in futures:
unformatted_feature: Optional[Feature] = future.get_result()
if unformatted_feature and not unformatted_feature.deleted:
feature = converters.feature_to_legacy_json(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)
store_key = Feature.feature_cache_key(
Feature.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
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.
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' % (Feature.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 = Feature.query().order(-Feature.updated) #.order('name')
query = query.filter(Feature.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(Feature.owner == comparator, Feature.editors == comparator,
Feature.creator == comparator))
else:
query = query.filter(getattr(Feature, filter_type) == comparator)
feature_list = query.fetch(limit, keys_only=keys_only)
if not keys_only:
feature_list = [
converters.feature_to_legacy_json(f) for f in feature_list]
rediscache.set(KEY, feature_list)
return feature_list
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
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(Feature.DEFAULT_CACHE_KEY, feature_id)
feature = rediscache.get(KEY)
if feature is None or update_cache:
unformatted_feature: Optional[Feature] = Feature.get_by_id(feature_id)
if unformatted_feature:
if unformatted_feature.deleted:
return None
feature = converters.feature_to_legacy_json(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)
rediscache.set(KEY, feature)
return feature
def filter_unlisted(feature_list: list[dict]) -> list[dict]:
"""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', [])) or
(email is not None and f.get('creator') == email)):
listed_features.append(f)
return listed_features
def get_chronological(limit=None, update_cache: bool=False,
show_unlisted: bool=False) -> list[dict]:
"""Return a list of JSON dicts for features, ordered by milestone.
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.
"""
cache_key = '%s|%s|%s' % (Feature.DEFAULT_CACHE_KEY,
'cronorder', limit)
feature_list = rediscache.get(cache_key)
logging.info('getting chronological feature list')
# On cache miss, do a db query.
if not feature_list or update_cache:
logging.info('recomputing chronological feature list')
# Features that are in-dev or proposed.
q = Feature.query()
q = q.order(Feature.impl_status_chrome)
q = q.order(Feature.name)
q = q.filter(Feature.impl_status_chrome.IN(
(PROPOSED, IN_DEVELOPMENT)))
pre_release_future = q.fetch_async(None)
# Shipping features. Exclude features that do not have a desktop
# shipping milestone.
q = Feature.query()
q = q.order(-Feature.shipped_milestone)
q = q.order(Feature.name)
q = q.filter(Feature.shipped_milestone != None)
shipping_features_future = q.fetch_async(None)
# Features with an android shipping milestone but no desktop milestone.
q = Feature.query()
q = q.order(-Feature.shipped_android_milestone)
q = q.order(Feature.name)
q = q.filter(Feature.shipped_milestone == None)
android_only_shipping_features_future = q.fetch_async(None)
# Features with no active development.
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.impl_status_chrome == NO_ACTIVE_DEV)
no_active_future = q.fetch_async(None)
# No longer pursuing features.
q = Feature.query()
q = q.order(Feature.name)
q = q.filter(Feature.impl_status_chrome == NO_LONGER_PURSUING)
no_longer_pursuing_features_future = q.fetch_async(None)
logging.info('Waiting on futures')
pre_release = pre_release_future.result()
android_only_shipping_features = (
android_only_shipping_features_future.result())
no_active = no_active_future.result()
no_longer_pursuing_features = no_longer_pursuing_features_future.result()
logging.info('Waiting on shipping_features_future')
shipping_features = shipping_features_future.result()
shipping_features.extend(android_only_shipping_features)
shipping_features = [
f for f in shipping_features
if (IN_DEVELOPMENT < f.impl_status_chrome
< NO_LONGER_PURSUING)]
def getSortingMilestone(feature):
feature._sort_by_milestone = (feature.shipped_milestone or
feature.shipped_android_milestone or
0)
return feature
# Sort the feature list on either Android shipping milestone or desktop
# shipping milestone, depending on which is specified. If a desktop
# milestone is defined, that will take default.
shipping_features = list(map(getSortingMilestone, shipping_features))
# First sort by name, then sort by feature milestone (latest first).
shipping_features.sort(key=lambda f: f.name, reverse=False)
shipping_features.sort(key=lambda f: f._sort_by_milestone, reverse=True)
# Constructor the proper ordering.
all_features = []
all_features.extend(pre_release)
all_features.extend(shipping_features)
all_features.extend(no_active)
all_features.extend(no_longer_pursuing_features)
all_features = [f for f in all_features if not f.deleted]
feature_list = [converters.feature_to_legacy_json(f) for f in all_features]
Feature._annotate_first_of_milestones(feature_list)
rediscache.set(cache_key, feature_list)
if not show_unlisted:
feature_list = filter_unlisted(feature_list)
return feature_list
def get_in_milestone(milestone: int,
show_unlisted: bool=False) -> dict[str, list[dict[str, Any]]]:
"""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
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.
"""
features_by_type = {}
cache_key = '%s|%s|%s' % (
Feature.DEFAULT_CACHE_KEY, 'milestone', milestone)
cached_features_by_type = rediscache.get(cache_key)
if cached_features_by_type:
features_by_type = cached_features_by_type
else:
all_features: dict[str, list[Feature]] = {}
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)
# 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] = [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])
return features_by_type
def get_all_with_statuses(statuses, update_cache=False):
"""Return JSON dicts for entities with the given statuses.
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.
"""
if not statuses:
return []
KEY = '%s|%s' % (Feature.DEFAULT_CACHE_KEY, sorted(statuses))
feature_list = rediscache.get(KEY)
if feature_list is None or update_cache:
# There's no way to do an OR in a single datastore query, and there's a
# very good chance that the self.get_all() results will already be in
# rediscache, so use an array comprehension to grab the features we
# want from the array of everything.
feature_list = [
feature for feature in get_all(update_cache=update_cache)
if feature['browsers']['chrome']['status']['text'] in statuses]
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