Remove the rest of old schema references (#2910)

* Remove the rest of old schema references

* Keep maintenance scripts

* move script Route
This commit is contained in:
Daniel Smith 2023-04-12 22:46:41 -07:00 коммит произвёл GitHub
Родитель ece3f7f471
Коммит ad9386cdb9
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 8 добавлений и 1428 удалений

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

@ -157,7 +157,6 @@ export class ChromedashAdminBlinkPage extends LitElement {
html`<div id="component-count">listing ${this.components.length} components</div>`
}
</div>
<a href="/admin/subscribers" class="view_owners_linke">List by owner &amp; their features </a>
</div>
<div class="layout horizontal subheader_toggles">
<!-- <paper-toggle-button> doesn't working here. Related links:

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

@ -1,232 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2023 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
from typing import Any, Optional
from google.cloud import ndb # type: ignore
from api.legacy_converters import feature_to_legacy_json
from framework import rediscache
from framework import users
from internals.core_enums import *
from internals.legacy_models import Feature
from internals.feature_helpers import filter_unlisted, _new_crbug_url
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 = 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 = [
feature_to_legacy_json(f) for f in feature_list]
rediscache.set(KEY, feature_list)
return feature_list
def filter_unlisted_legacy(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_legacy(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 = [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_legacy(feature_list)
return feature_list

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

@ -1,83 +0,0 @@
# Copyright 2023 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 testing_config # Must be imported before the module under test.
from internals.core_enums import *
from internals import legacy_helpers
from internals.legacy_models import Feature
class LegacyHelpersTest(testing_config.CustomTestCase):
def setUp(self):
# Legacy entities for testing legacy functions.
self.legacy_feature_2 = Feature(
name='feature b', summary='sum',
owner=['feature_owner@example.com'], category=1,
creator="someuser@example.com")
self.legacy_feature_2.put()
self.legacy_feature_1 = 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 = 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 = 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):
for kind in [Feature]:
for entity in kind.query():
entity.key.delete()
def test_get_chronological__normal(self):
"""We can retrieve a list of features."""
actual = legacy_helpers.get_chronological_legacy()
names = [f['name'] for f in actual]
self.assertEqual(
['feature c', 'feature d', 'feature a', 'feature b'],
names)
self.assertEqual(True, actual[0]['first_of_milestone'])
self.assertEqual(False, hasattr(actual[1], 'first_of_milestone'))
self.assertEqual(True, actual[2]['first_of_milestone'])
self.assertEqual(False, hasattr(actual[3], 'first_of_milestone'))
def test_get_chronological__unlisted(self):
"""Unlisted features are not included in the list."""
self.legacy_feature_2.unlisted = True
self.legacy_feature_2.put()
actual = legacy_helpers.get_chronological_legacy(update_cache=True)
names = [f['name'] for f in actual]
self.assertEqual(
['feature c', 'feature d', 'feature a'],
names)
def test_get_chronological__unlisted_shown(self):
"""Unlisted features are included for users with edit access."""
self.legacy_feature_2.unlisted = True
self.legacy_feature_2.put()
actual = legacy_helpers.get_chronological_legacy(update_cache=True, show_unlisted=True)
names = [f['name'] for f in actual]
self.assertEqual(
['feature c', 'feature d', 'feature a', 'feature b'],
names)

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

@ -1,450 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2023 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 needed to reference a class within its own class method.
# https://stackoverflow.com/a/33533514
from __future__ import annotations
import datetime
import logging
from typing import Any
from google.cloud import ndb # type: ignore
from framework import rediscache
from internals.core_enums import *
from internals import fetchchannels
import settings
##################################
### Legacy core models ###
##################################
SIMPLE_TYPES = (int, float, bool, dict, str, list)
class DictModel(ndb.Model):
# def to_dict(self):
# return dict([(p, str(getattr(self, p))) for p in self.properties()])
def is_saved(self):
if self.key:
return True
return False
def to_dict(self):
output = {}
for key, prop in self._properties.items():
# Skip obsolete values that are still in our datastore
if not hasattr(self, key):
continue
value = getattr(self, key)
if value is None or isinstance(value, SIMPLE_TYPES):
output[key] = value
elif isinstance(value, datetime.date):
# Convert date/datetime to ms-since-epoch ("new Date()").
#ms = time.mktime(value.utctimetuple())
#ms += getattr(value, 'microseconds', 0) / 1000
#output[key] = int(ms)
output[key] = str(value)
elif isinstance(value, ndb.GeoPt):
output[key] = {'lat': value.lat, 'lon': value.lon}
elif isinstance(value, ndb.Model):
output[key] = value.to_dict()
elif isinstance(value, ndb.model.User):
output[key] = value.email()
else:
raise ValueError('cannot encode ' + repr(prop))
return output
class Feature(DictModel):
"""Container for a feature."""
DEFAULT_CACHE_KEY = 'features'
def __init__(self, *args, **kwargs):
# Initialise Feature.blink_components with a default value. If
# name is present in kwargs then it would mean constructor is
# being called for creating a new feature rather than for fetching
# an existing feature.
if 'name' in kwargs:
if 'blink_components' not in kwargs:
kwargs['blink_components'] = [settings.DEFAULT_COMPONENT]
super(Feature, self).__init__(*args, **kwargs)
@classmethod
def feature_cache_key(cls, cache_key, feature_id):
return '%s|%s' % (cache_key, feature_id)
@classmethod
def feature_cache_prefix(cls):
return '%s|*' % (Feature.DEFAULT_CACHE_KEY)
@classmethod
def _first_of_milestone_v2(self, feature_list, milestone, start=0):
for i in range(start, len(feature_list)):
f = feature_list[i]
desktop_milestone = f['browsers']['chrome'].get('desktop', None)
android_milestone = f['browsers']['chrome'].get('android', None)
status = f['browsers']['chrome']['status'].get('text', None)
if (str(desktop_milestone) == str(milestone) or status == str(milestone)):
return i
elif (desktop_milestone == None and
str(android_milestone) == str(milestone)):
return i
return -1
@classmethod
def _annotate_first_of_milestones(self, feature_list):
try:
omaha_data = fetchchannels.get_omaha_data()
win_versions = omaha_data[0]['versions']
# Find the latest canary major version from the list of windows versions.
canary_versions = [
x for x in win_versions
if x.get('channel') and x.get('channel').startswith('canary')]
LATEST_VERSION = int(canary_versions[0].get('version').split('.')[0])
milestones = list(range(1, LATEST_VERSION + 1))
milestones.reverse()
versions = [
IMPLEMENTATION_STATUS[PROPOSED],
IMPLEMENTATION_STATUS[IN_DEVELOPMENT],
IMPLEMENTATION_STATUS[DEPRECATED],
]
versions.extend(milestones)
versions.append(IMPLEMENTATION_STATUS[NO_ACTIVE_DEV])
versions.append(IMPLEMENTATION_STATUS[NO_LONGER_PURSUING])
last_good_idx = 0
for i, ver in enumerate(versions):
idx = Feature._first_of_milestone_v2(
feature_list, ver, start=last_good_idx)
if idx != -1:
feature_list[idx]['first_of_milestone'] = True
last_good_idx = idx
except Exception as e:
logging.error(e)
def put(self, **kwargs) -> Any:
key = super(Feature, self).put(**kwargs)
# Invalidate rediscache for the individual feature view.
cache_key = Feature.feature_cache_key(
Feature.DEFAULT_CACHE_KEY, self.key.integer_id())
rediscache.delete(cache_key)
return key
# Metadata.
created = ndb.DateTimeProperty(auto_now_add=True)
updated = ndb.DateTimeProperty(auto_now=True)
accurate_as_of = ndb.DateTimeProperty(auto_now=False)
updated_by = ndb.UserProperty()
created_by = ndb.UserProperty()
# General info.
category = ndb.IntegerProperty(required=True)
creator = ndb.StringProperty()
name = ndb.StringProperty(required=True)
feature_type = ndb.IntegerProperty(default=FEATURE_TYPE_INCUBATE_ID)
intent_stage = ndb.IntegerProperty(default=INTENT_NONE)
summary = ndb.StringProperty(required=True)
unlisted = ndb.BooleanProperty(default=False)
enterprise_feature_categories = ndb.StringProperty(repeated=True)
# TODO(jrobbins): Add an entry_state enum to track app-specific lifecycle
# info for a feature entry as distinct from process-specific stage.
deleted = ndb.BooleanProperty(default=False)
motivation = ndb.StringProperty()
star_count = ndb.IntegerProperty(default=0)
search_tags = ndb.StringProperty(repeated=True)
comments = ndb.StringProperty()
owner = ndb.StringProperty(repeated=True)
editors = ndb.StringProperty(repeated=True)
cc_recipients = ndb.StringProperty(repeated=True)
footprint = ndb.IntegerProperty() # Deprecated
breaking_change = ndb.BooleanProperty(default=False)
# Tracability to intent discussion threads
intent_to_implement_url = ndb.StringProperty()
intent_to_implement_subject_line = ndb.StringProperty()
intent_to_ship_url = ndb.StringProperty()
intent_to_ship_subject_line = ndb.StringProperty()
ready_for_trial_url = ndb.StringProperty()
intent_to_experiment_url = ndb.StringProperty()
intent_to_experiment_subject_line = ndb.StringProperty()
intent_to_extend_experiment_url = ndb.StringProperty()
intent_to_extend_experiment_subject_line = ndb.StringProperty()
# Currently, only one is needed.
i2e_lgtms = ndb.StringProperty(repeated=True)
i2s_lgtms = ndb.StringProperty(repeated=True)
# Chromium details.
bug_url = ndb.StringProperty()
launch_bug_url = ndb.StringProperty()
initial_public_proposal_url = ndb.StringProperty()
blink_components = ndb.StringProperty(repeated=True)
devrel = ndb.StringProperty(repeated=True)
impl_status_chrome = ndb.IntegerProperty(required=True, default=NO_ACTIVE_DEV)
shipped_milestone = ndb.IntegerProperty()
shipped_android_milestone = ndb.IntegerProperty()
shipped_ios_milestone = ndb.IntegerProperty()
shipped_webview_milestone = ndb.IntegerProperty()
requires_embedder_support = ndb.BooleanProperty(default=False)
# DevTrial details.
devtrial_instructions = ndb.StringProperty()
flag_name = ndb.StringProperty()
interop_compat_risks = ndb.StringProperty()
ergonomics_risks = ndb.StringProperty()
activation_risks = ndb.StringProperty()
security_risks = ndb.StringProperty()
webview_risks = ndb.StringProperty()
debuggability = ndb.StringProperty()
all_platforms = ndb.BooleanProperty()
all_platforms_descr = ndb.StringProperty()
wpt = ndb.BooleanProperty()
wpt_descr = ndb.StringProperty()
dt_milestone_desktop_start = ndb.IntegerProperty()
dt_milestone_android_start = ndb.IntegerProperty()
dt_milestone_ios_start = ndb.IntegerProperty()
# Webview DT is currently not offered in the UI because there is no way
# to set flags.
dt_milestone_webview_start = ndb.IntegerProperty()
# Note: There are no dt end milestones because a dev trail implicitly
# ends when the feature ships or is abandoned.
visibility = ndb.IntegerProperty(required=False, default=1) # Deprecated
# Standards details.
standardization = ndb.IntegerProperty(required=True,
default=EDITORS_DRAFT) # Deprecated
standard_maturity = ndb.IntegerProperty(required=True, default=UNSET_STD)
spec_link = ndb.StringProperty()
api_spec = ndb.BooleanProperty(default=False)
spec_mentors = ndb.StringProperty(repeated=True)
security_review_status = ndb.IntegerProperty(default=REVIEW_PENDING)
privacy_review_status = ndb.IntegerProperty(default=REVIEW_PENDING)
tag_review = ndb.StringProperty()
tag_review_status = ndb.IntegerProperty(default=REVIEW_PENDING)
prefixed = ndb.BooleanProperty()
explainer_links = ndb.StringProperty(repeated=True)
ff_views = ndb.IntegerProperty(required=True, default=NO_PUBLIC_SIGNALS)
# Deprecated
ie_views = ndb.IntegerProperty(required=True, default=NO_PUBLIC_SIGNALS)
safari_views = ndb.IntegerProperty(required=True, default=NO_PUBLIC_SIGNALS)
web_dev_views = ndb.IntegerProperty(required=True, default=DEV_NO_SIGNALS)
ff_views_link = ndb.StringProperty()
ie_views_link = ndb.StringProperty() # Deprecated
safari_views_link = ndb.StringProperty()
web_dev_views_link = ndb.StringProperty()
ff_views_notes = ndb.StringProperty()
ie_views_notes = ndb.StringProperty() # Deprecated
safari_views_notes = ndb.StringProperty()
web_dev_views_notes = ndb.StringProperty()
other_views_notes = ndb.StringProperty()
doc_links = ndb.StringProperty(repeated=True)
measurement = ndb.StringProperty()
availability_expectation = ndb.TextProperty()
adoption_expectation = ndb.TextProperty()
adoption_plan = ndb.TextProperty()
sample_links = ndb.StringProperty(repeated=True)
non_oss_deps = ndb.StringProperty()
experiment_goals = ndb.StringProperty()
experiment_timeline = ndb.StringProperty()
ot_milestone_desktop_start = ndb.IntegerProperty()
ot_milestone_desktop_end = ndb.IntegerProperty()
ot_milestone_android_start = ndb.IntegerProperty()
ot_milestone_android_end = ndb.IntegerProperty()
ot_milestone_webview_start = ndb.IntegerProperty()
ot_milestone_webview_end = ndb.IntegerProperty()
experiment_risks = ndb.StringProperty()
experiment_extension_reason = ndb.StringProperty()
ongoing_constraints = ndb.StringProperty()
origin_trial_feedback_url = ndb.StringProperty()
anticipated_spec_changes = ndb.StringProperty()
finch_url = ndb.StringProperty()
# Flag set to avoid migrating data that has already been migrated.
stages_migrated = ndb.BooleanProperty(default=False)
####################################
### Legacy review models ###
####################################
class Approval(ndb.Model):
"""Describes the current state of one approval on a feature."""
# Not used: PREPARING = 0
NA = 1
REVIEW_REQUESTED = 2
REVIEW_STARTED = 3
NEEDS_WORK = 4
APPROVED = 5
DENIED = 6
NO_RESPONSE = 7
INTERNAL_REVIEW = 8
APPROVAL_VALUES = {
# Not used: PREPARING: 'preparing',
NA: 'na',
REVIEW_REQUESTED: 'review_requested',
REVIEW_STARTED: 'review_started',
NEEDS_WORK: 'needs_work',
APPROVED: 'approved',
DENIED: 'denied',
NO_RESPONSE: 'no_response',
INTERNAL_REVIEW: 'internal_review',
}
FINAL_STATES = [NA, APPROVED, DENIED]
feature_id = ndb.IntegerProperty(required=True)
field_id = ndb.IntegerProperty(required=True)
state = ndb.IntegerProperty(required=True)
set_on = ndb.DateTimeProperty(required=True)
set_by = ndb.StringProperty(required=True)
@classmethod
def get_approvals(
cls, feature_id=None, field_id=None, states=None, set_by=None,
limit=None) -> list[Approval]:
"""Return the requested approvals."""
query = Approval.query().order(Approval.set_on)
if feature_id is not None:
query = query.filter(Approval.feature_id == feature_id)
if field_id is not None:
query = query.filter(Approval.field_id == field_id)
if states is not None:
query = query.filter(Approval.state.IN(states))
if set_by is not None:
query = query.filter(Approval.set_by == set_by)
# Query with STRONG consistency because ndb defaults to
# EVENTUAL consistency and we run this query immediately after
# saving the user's change that we want included in the query.
approvals = query.fetch(limit, read_consistency=ndb.STRONG)
return approvals
@classmethod
def is_valid_state(cls, new_state):
"""Return true if new_state is valid."""
return new_state in cls.APPROVAL_VALUES
@classmethod
def set_approval(cls, feature_id, field_id, new_state, set_by_email):
"""Add or update an approval value."""
if not cls.is_valid_state(new_state):
raise ValueError('Invalid approval state')
now = datetime.datetime.now()
existing_list = cls.get_approvals(
feature_id=feature_id, field_id=field_id, set_by=set_by_email)
if existing_list:
existing = existing_list[0]
existing.set_on = now
existing.state = new_state
existing.put()
logging.info('existing approval is %r', existing.key.integer_id())
return
new_appr = Approval(
feature_id=feature_id, field_id=field_id, state=new_state,
set_on=now, set_by=set_by_email)
new_appr.put()
logging.info('new_appr is %r', new_appr.key.integer_id())
@classmethod
def clear_request(cls, feature_id, field_id):
"""After the review requirement has been satisfied, remove the request."""
review_requests = cls.get_approvals(
feature_id=feature_id, field_id=field_id, states=[cls.REVIEW_REQUESTED])
for rr in review_requests:
rr.key.delete()
# Note: We keep REVIEW_REQUEST Vote entities.
class ApprovalConfig(ndb.Model):
"""Allows customization of an approval field for one feature."""
feature_id = ndb.IntegerProperty(required=True)
field_id = ndb.IntegerProperty(required=True)
owners = ndb.StringProperty(repeated=True)
next_action = ndb.DateProperty()
additional_review = ndb.BooleanProperty(default=False)
@classmethod
def get_configs(cls, feature_id):
"""Return approval configs for all approval fields."""
query = ApprovalConfig.query(ApprovalConfig.feature_id == feature_id)
configs = query.fetch(None)
return configs
@classmethod
def set_config(
cls, feature_id, field_id, owners, next_action, additional_review):
"""Add or update an approval config object."""
config = ApprovalConfig(feature_id=feature_id, field_id=field_id)
for existing in cls.get_configs(feature_id):
if existing.field_id == field_id:
config = existing
config.owners = owners or []
config.next_action = next_action
config.additional_review = additional_review
config.put()
class Comment(ndb.Model):
"""A review comment on a feature."""
feature_id = ndb.IntegerProperty(required=True)
field_id = ndb.IntegerProperty() # The approval field_id, or general comment.
created = ndb.DateTimeProperty(auto_now_add=True)
author = ndb.StringProperty()
content = ndb.StringProperty()
deleted_by = ndb.StringProperty()
migrated = ndb.BooleanProperty()
# If the user set an approval value, we capture that here so that we can
# display a change log. This could be generalized to a list of separate
# Amendment entities, but that complexity is not needed yet.
old_approval_state = ndb.IntegerProperty()
new_approval_state = ndb.IntegerProperty()
@classmethod
def get_comments(cls, feature_id, field_id=None):
"""Return review comments for an approval."""
query = Comment.query().order(Comment.created)
query = query.filter(Comment.feature_id == feature_id)
if field_id:
query = query.filter(Comment.field_id == field_id)
comments = query.fetch(None)
return comments

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

@ -1,105 +0,0 @@
# Copyright 2023 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 testing_config # Must be imported before the module under test.
import datetime
from internals.legacy_models import Approval
from internals.core_models import FeatureEntry
class ApprovalTest(testing_config.CustomTestCase):
def setUp(self):
self.feature_1 = FeatureEntry(
name='feature a', summary='sum', category=1, impl_status_chrome=3)
self.feature_1.put()
self.feature_1_id = self.feature_1.key.integer_id()
self.appr_1 = Approval(
feature_id=self.feature_1_id, field_id=1,
state=Approval.REVIEW_REQUESTED,
set_on=datetime.datetime.now() - datetime.timedelta(1),
set_by='one@example.com')
self.appr_1.put()
self.appr_2 = Approval(
feature_id=self.feature_1_id, field_id=1,
state=Approval.APPROVED,
set_on=datetime.datetime.now(),
set_by='two@example.com')
self.appr_2.put()
self.appr_3 = Approval(
feature_id=self.feature_1_id, field_id=1,
state=Approval.APPROVED,
set_on=datetime.datetime.now() + datetime.timedelta(1),
set_by='three@example.com')
self.appr_3.put()
def tearDown(self):
self.feature_1.key.delete()
for appr in Approval.query().fetch():
appr.key.delete()
def test_get_approvals(self):
"""We can retrieve Approval entities."""
actual = Approval.get_approvals(feature_id=self.feature_1_id)
self.assertEqual(3, len(actual))
self.assertEqual(Approval.REVIEW_REQUESTED, actual[0].state)
self.assertEqual(Approval.APPROVED, actual[1].state)
self.assertEqual(
sorted(actual, key=lambda appr: appr.set_on),
actual)
actual = Approval.get_approvals(field_id=1)
self.assertEqual(Approval.REVIEW_REQUESTED, actual[0].state)
self.assertEqual(Approval.APPROVED, actual[1].state)
actual = Approval.get_approvals(
states={Approval.REVIEW_REQUESTED,
Approval.REVIEW_STARTED})
self.assertEqual(1, len(actual))
actual = Approval.get_approvals(set_by='one@example.com')
self.assertEqual(1, len(actual))
self.assertEqual(Approval.REVIEW_REQUESTED, actual[0].state)
def test_is_valid_state(self):
"""We know what approval states are valid."""
self.assertTrue(
Approval.is_valid_state(Approval.REVIEW_REQUESTED))
self.assertFalse(Approval.is_valid_state(None))
self.assertFalse(Approval.is_valid_state('not an int'))
self.assertFalse(Approval.is_valid_state(999))
def test_set_approval(self):
"""We can set an Approval entity."""
Approval.set_approval(
self.feature_1_id, 2, Approval.REVIEW_REQUESTED,
'owner@example.com')
self.assertEqual(
4,
len(Approval.query().fetch(None)))
def test_clear_request(self):
"""We can clear a review request so that it is no longer pending."""
self.appr_1.state = Approval.REVIEW_REQUESTED
self.appr_1.put()
Approval.clear_request(self.feature_1_id, 1)
remaining_apprs = Approval.get_approvals(
feature_id=self.feature_1_id, field_id=1,
states=[Approval.REVIEW_REQUESTED])
self.assertEqual([], remaining_apprs)

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

@ -1,4 +1,4 @@
# Copyright 2022 Google Inc.
# Copyright 2023 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License")
# you may not use this file except in compliance with the License.
@ -18,8 +18,7 @@ from google.cloud import ndb # type: ignore
from framework.basehandlers import FlaskHandler
from internals import approval_defs
from internals.core_models import FeatureEntry, Stage
from internals.legacy_models import Feature
from internals.review_models import Gate, Vote
from internals.review_models import Gate
from internals.core_enums import *
@ -103,73 +102,3 @@ class WriteMissingGates(FlaskHandler):
ndb.put_multi(gates_to_write)
return f'{len(gates_to_write)} missing gates created for stages.'
class MigrateLGTMFields(FlaskHandler):
def get_template_data(self, **kwargs) -> str:
"""Migrates old Feature subject lgtms to their respective votes."""
self.require_cron_header()
count = 0
votes_to_create = []
vote_dict = self.get_vote_dict()
features = Feature.query()
for f in features:
if not f.i2e_lgtms:
continue
for email in f.i2e_lgtms:
if self.has_existing_vote(email, GATE_API_ORIGIN_TRIAL, f, vote_dict):
continue
# i2e_lgtms (Intent to Experiment)'s gate_type is GATE_API_ORIGIN_TRIAL.
votes_to_create.append(self.create_new_vote(
email, GATE_API_ORIGIN_TRIAL, f))
count += 1
for f in features:
if not f.i2s_lgtms:
continue
for email in f.i2s_lgtms:
if self.has_existing_vote(email, GATE_API_SHIP, f, vote_dict):
continue
# i2s_lgtms (Intent to Ship)'s gate_type is GATE_API_SHIP.
votes_to_create.append(self.create_new_vote(
email, GATE_API_SHIP, f))
count += 1
# Only create 100 votes at a time.
votes_to_create = votes_to_create[:100]
for new_vote in votes_to_create:
approval_defs.set_vote(new_vote.feature_id, new_vote.gate_type,
new_vote.state, new_vote.set_by)
return f'{len(votes_to_create)} of {count} lgtms fields migrated.'
def get_vote_dict(self):
vote_dict = {}
votes = Vote.query().fetch(None)
for vote in votes:
if vote.feature_id in vote_dict:
vote_dict[vote.feature_id].append(vote)
else:
vote_dict[vote.feature_id] = [vote]
return vote_dict
def has_existing_vote(self, email, gate_type, f, vote_dict):
f_id = f.key.integer_id()
if f_id not in vote_dict:
return False
for v in vote_dict[f_id]:
# Check if set by the same reviewer and the same gate_type.
if v.set_by == email and v.gate_type == gate_type:
return True
return False
def create_new_vote(self, email, gate_type, f):
f_id = f.key.integer_id()
vote = Vote(feature_id=f_id, gate_type=gate_type,
state=Vote.APPROVED, set_by=email)
return vote

15
main.py
Просмотреть файл

@ -39,13 +39,12 @@ from framework import csp
from framework import sendemail
from internals import detect_intent
from internals import fetchmetrics
from internals import maintenance_scripts
from internals import notifier
from internals import data_backup
from internals import inactive_users
from internals import search_fulltext
from internals import schema_migration
from internals import reminders
from pages import blink_handler
from pages import featurelist
from pages import guide
from pages import intentpreview
@ -191,7 +190,6 @@ spa_page_routes = [
]
mpa_page_routes: list[Route] = [
Route('/admin/subscribers', blink_handler.SubscribersHandler),
Route('/admin/users/new', users.UserListHandler),
Route('/admin/features/launch/<int:feature_id>',
@ -234,12 +232,11 @@ internals_routes: list[Route] = [
Route('/tasks/email-reviewers', notifier.FeatureReviewHandler),
Route('/tasks/email-comments', notifier.FeatureCommentHandler),
Route('/admin/schema_migration_gate_status',
schema_migration.EvaluateGateStatus),
Route('/admin/schema_migration_missing_gates',
schema_migration.WriteMissingGates),
Route('/admin/schema_migration_lgtm_fields',
schema_migration.MigrateLGTMFields),
# Maintenance scripts.
Route('/scripts/evaluate_gate_status',
maintenance_scripts.EvaluateGateStatus),
Route('/scripts/write_missing_gates',
maintenance_scripts.WriteMissingGates),
]
dev_routes: list[Route] = []

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

@ -1,65 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2017 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 collections
from framework import basehandlers
from framework import permissions
from internals import legacy_helpers
from internals import user_models
from api.channels_api import construct_chrome_channels_details
class SubscribersHandler(basehandlers.FlaskHandler):
TEMPLATE_PATH = 'admin/subscribers.html'
@permissions.require_admin_site
def get_template_data(self, **kwargs):
users = user_models.FeatureOwner.query().order(
user_models.FeatureOwner.name).fetch(None)
feature_list = legacy_helpers.get_chronological_legacy()
milestone = self.get_int_arg('milestone')
if milestone is not None:
feature_list = [
f for f in feature_list
if (f['shipped_milestone'] or
f['shipped_android_milestone']) == milestone]
list_features_per_owner = 'showFeatures' in self.request.args
for user in users:
# user.subscribed_components = [key.get() for key in user.blink_components]
user.owned_components = [
key.get() for key in user.primary_blink_components]
for component in user.owned_components:
component.features = []
if list_features_per_owner:
component.features = [
f for f in feature_list
if component.name in f['blink_components']]
details = construct_chrome_channels_details()
template_data = {
'subscribers': users,
'channels': collections.OrderedDict([
('stable', details['stable']),
('beta', details['beta']),
('dev', details['dev']),
('canary', details['canary']),
]),
'selected_milestone': milestone
}
return template_data

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

@ -1,103 +0,0 @@
# 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 testing_config # Must be imported before the module under test.
from unittest import mock
import flask
import werkzeug
import html5lib
import settings
from google.cloud import ndb # type: ignore
from pages import blink_handler
from internals import user_models
test_app = flask.Flask(__name__,
template_folder=settings.get_flask_template_path())
# Load testdata to be used across all of the CustomTestCases
TESTDATA = testing_config.Testdata(__file__)
class SubscribersTemplateTest(testing_config.CustomTestCase):
HANDLER_CLASS = blink_handler.SubscribersHandler
def setUp(self):
# need to patch in setup because the details are retreived here.
# unable to use method decorator for setUp.
self.mock_chrome_details_patch = mock.patch(
'pages.blink_handler.construct_chrome_channels_details')
mock_chrome_details = self.mock_chrome_details_patch.start()
mock_chrome_details.return_value = {
"stable": {"mstone": 1},
"beta": {"mstone": 2},
"dev": {"mstone": 3},
"canary": {"mstone": 4},
}
self.request_path = self.HANDLER_CLASS.TEMPLATE_PATH
self.handler = self.HANDLER_CLASS()
self.app_admin = user_models.AppUser(email='admin@example.com')
self.app_admin.is_admin = True
self.app_admin.put()
testing_config.sign_in('admin@example.com', 123567890)
self.component_1 = user_models.BlinkComponent(name='Blink')
self.component_1.put()
self.component_2 = user_models.BlinkComponent(name='Blink>Accessibility')
self.component_2.put()
self.component_owner_1 = user_models.FeatureOwner(
name='owner_1', email='owner_1@example.com',
primary_blink_components=[self.component_1.key, self.component_2.key])
self.component_owner_1.key = ndb.Key('FeatureOwner', 111)
self.component_owner_1.put()
self.watcher_1 = user_models.FeatureOwner(
name='watcher_1', email='watcher_1@example.com',
watching_all_features=True)
self.watcher_1.key = ndb.Key('FeatureOwner', 222)
self.watcher_1.put()
with test_app.test_request_context(self.request_path):
self.template_data = self.handler.get_template_data()
self.template_data.update(self.handler.get_common_data())
self.template_data['nonce'] = 'fake nonce'
self.template_data['xsrf_token'] = ''
self.template_data['xsrf_token_expires'] = 0
self.full_template_path = self.handler.get_template_path(self.template_data)
self.maxDiff = None
def tearDown(self):
self.mock_chrome_details_patch.stop()
self.watcher_1.key.delete()
self.component_owner_1.key.delete()
self.component_1.key.delete()
self.component_2.key.delete()
testing_config.sign_out()
self.app_admin.key.delete()
def test_html_rendering(self):
"""We can render the template with valid html."""
with test_app.app_context():
template_text = self.handler.render(
self.template_data, self.full_template_path)
parser = html5lib.HTMLParser(strict=True)
document = parser.parse(template_text)
# TESTDATA.make_golden(template_text, 'SubscribersTemplateTest_test_html_rendering.html')
self.assertMultiLineEqual(
TESTDATA['SubscribersTemplateTest_test_html_rendering.html'], template_text)

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

@ -1,225 +0,0 @@
<!DOCTYPE html>
<!--
Copyright 2016 Google Inc. All Rights Reserved.
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.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>Local testing</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<meta name="theme-color" content="#366597">
<link rel="stylesheet" href="/static/css/base.css?v=Undeployed" />
<link rel="icon" sizes="192x192" href="/static/img/crstatus_192.png">
<!-- iOS: run in full-screen mode and display upper status bar as translucent -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/static/img/crstatus_128.png">
<link rel="apple-touch-icon-precomposed" href="/static/img/crstatus_128.png">
<link rel="shortcut icon" href="/static/img/crstatus_128.png">
<link rel="preconnect" href="https://www.google-analytics.com" crossorigin>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/main.css?v=Undeployed">
<link rel="stylesheet" href="/static/css/subscribers.css?v=Undeployed">
<script src="https://accounts.google.com/gsi/client" async defer nonce="fake nonce"></script>
<script nonce="fake nonce">
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("loginStatus") == 'False') {
alert('Please log in.');
}
</script>
<script nonce="fake nonce" src="/static/js/metric.min.js?v=Undeployed"></script>
<script nonce="fake nonce" src="/static/js/cs-client.min.js?v=Undeployed"></script>
<script nonce="fake nonce">
window.csClient = new ChromeStatusClient(
'', 0);
</script>
<script type="module" nonce="fake nonce" defer
src="/static/dist/components.js?v=Undeployed"></script>
</head>
<body class="loading" data-path="/admin/subscribers.html?">
<div id="app-content-container">
<div>
<div class="main-toolbar">
<div class="toolbar-content">
<chromedash-header
appTitle="Local testing"
currentPage="/admin/subscribers.html?"
googleSignInClientId="914217904764-enfcea61q4hqe7ak8kkuteglrbhk8el1.apps.googleusercontent.com">
</chromedash-header>
</div>
</div>
<div id="content">
<div id="spinner">
<img src="/static/img/ring.svg">
</div>
<chromedash-banner
message=""
timestamp="None">
</chromedash-banner>
<div id="rollout">
<a href="/newfeatures">Try out our new features page</a>
</div>
<div id="content-flex-wrapper">
<div id="content-component-wrapper">
<div id="subheader">
<div>
<h2>Feature owners</h2>
</div>
<a href="/admin/blink">Edit component owners →</a>
</div>
<div id="channels" class="layout horizontal center">
<div>
<h3 class="channels-title" title="Select a version to only show features for that milestone">Show features in:</h3>
<!-- <a href="?showFeatures">show all</a> | <a href="/admin/subscribers">hide all</a> -->
</div>
<div class="chrome_version layout horizontal center chrome_version--stable " title="Show only features for the current stable">
<a href="?showFeatures&amp;milestone=1" class="layout horizontal center">
<div class="chrome-logo"></div>
<!-- <span>stable</span> -->
<span class="milestone_number">1</span>
</a>
</div>
<div class="chrome_version layout horizontal center chrome_version--beta " title="Show only features for the current beta">
<a href="?showFeatures&amp;milestone=2" class="layout horizontal center">
<div class="chrome-logo"></div>
<!-- <span>beta</span> -->
<span class="milestone_number">2</span>
</a>
</div>
<div class="chrome_version layout horizontal center chrome_version--dev " title="Show only features for the current dev">
<a href="?showFeatures&amp;milestone=3" class="layout horizontal center">
<div class="chrome-logo"></div>
<!-- <span>dev</span> -->
<span class="milestone_number">3</span>
</a>
</div>
<div class="chrome_version layout horizontal center chrome_version--canary " title="Show only features for the current canary">
<a href="?showFeatures&amp;milestone=4" class="layout horizontal center">
<div class="chrome-logo"></div>
<!-- <span>canary</span> -->
<span class="milestone_number">4</span>
</a>
</div>
</div>
<section>
<ul id="users_list">
<li>
<div>
<h3 class="user_name">owner_1</h3>
<h4><a href="mailto:owner_1@example.com">owner_1@example.com</a></h4>
</div>
<div class="component_list">
<div class="component">
<h4><a href="/admin/blink#Blink" class="component_name">Blink</a></h4>
</div>
<div class="component">
<h4><a href="/admin/blink#Blink&gt;Accessibility" class="component_name">Blink&gt;Accessibility</a></h4>
</div>
</div>
</li>
<li>
<div>
<h3 class="user_name">watcher_1</h3>
<h4><a href="mailto:watcher_1@example.com">watcher_1@example.com</a></h4>
</div>
<div class="component_list">
</div>
</li>
</ul>
</section>
</div>
</div>
</div>
</div>
<chromedash-footer></chromedash-footer>
</div>
<chromedash-toast msg="Welcome to chromestatus.com!"></chromedash-toast>
<script nonce="fake nonce">
(function() {
'use strict';
document.body.classList.remove('loading');
})();
</script>
<script src="https://www.googletagmanager.com/gtag/js?id=UA-179341418-1"
async nonce="fake nonce"></script>
<script type="module" nonce="fake nonce" src="/static/js/shared.min.js?v=Undeployed"></script>
</body>
</html>

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

@ -1,82 +0,0 @@
{% extends "_base.html" %}
{% block css %}
<link rel="stylesheet" href="/static/css/subscribers.css?v={{app_version}}">
{% endblock %}
{% block drawer %}
{% endblock %}
{% block subheader %}
<div id="subheader">
<div>
<h2>Feature owners</h2>
</div>
<a href="/admin/blink">Edit component owners →</a>
</div>
{% endblock %}
{% block content %}
<div id="channels" class="layout horizontal center">
<div>
<h3 class="channels-title" title="Select a version to only show features for that milestone">Show features in:</h3>
<!-- <a href="?showFeatures">show all</a> | <a href="/admin/subscribers">hide all</a> -->
</div>
{% for key,channel in channels.items() %}
<div class="chrome_version layout horizontal center chrome_version--{{key}} {% if selected_milestone == channel.mstone %}highlight{% endif %}" title="Show only features for the current {{key}}">
<a href="?showFeatures&amp;milestone={{channel.mstone}}" class="layout horizontal center">
<div class="chrome-logo"></div>
<!-- <span>{{key}}</span> -->
<span class="milestone_number">{{channel.mstone}}</span>
</a>
</div>
{% endfor %}
</div>
{% if selected_milestone %}
<div class="filtering">Showing features in M{{selected_milestone}}. <a href="?showFeatures"><b>CLEAR MILESTONE ✘</b></a></div>
{% endif %}
<section>
<ul id="users_list">
{% for s in subscribers %}
<li>
<div>
<h3 class="user_name">{{s.name}}</h3>
<h4><a href="mailto:{{s.email}}">{{s.email}}</a></h4>
</div>
<div class="component_list">
{% for component in s.owned_components %}
<div class="component">
<h4><a href="/admin/blink#{{component.name}}" class="component_name">{{component.name}}</a></h4>
{% if component.features|length %}
<ol class="component_features">
{% for feature in component.features %}
<li class="feature_name"><a href="/feature/{{feature.id}}">{{feature.name}}</a></li>
{% endfor %}
</ol>
{% endif %}
</div>
{% endfor %}
</div>
</li>
{% endfor %}
</ul>
</section>
{% endblock %}
{% block js %}
<script nonce="{{nonce}}">
(function() {
'use strict';
document.body.classList.remove('loading');
})();
</script>
{% endblock %}