2022-12-16 04:47:21 +03:00
|
|
|
# -*- 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
|
2024-04-24 21:21:25 +03:00
|
|
|
from typing import Any, Tuple
|
2024-08-23 17:38:07 +03:00
|
|
|
|
|
|
|
from chromestatus_openapi.models import (GetVotesResponse, GetGateResponse, PostGateRequest, SuccessMessage)
|
2023-12-12 22:12:14 +03:00
|
|
|
from google.cloud import ndb
|
2024-04-03 23:57:18 +03:00
|
|
|
from google.cloud.ndb.tasklets import Future # for type checking only
|
2022-12-16 04:47:21 +03:00
|
|
|
|
|
|
|
from api import converters
|
2024-08-23 17:38:07 +03:00
|
|
|
from framework import basehandlers, permissions
|
2023-11-04 02:50:43 +03:00
|
|
|
from framework.users import User
|
2023-02-16 21:22:53 +03:00
|
|
|
from internals import approval_defs, notifier_helpers
|
2023-12-12 22:12:14 +03:00
|
|
|
from internals.core_enums import *
|
|
|
|
from internals.core_models import FeatureEntry, Stage
|
2024-08-30 20:15:18 +03:00
|
|
|
from internals.review_models import Gate, Vote, Amendment, Activity
|
2022-12-16 04:47:21 +03:00
|
|
|
|
|
|
|
|
2023-11-04 02:50:43 +03:00
|
|
|
def get_user_feature_and_gate(handler, kwargs) -> Tuple[
|
|
|
|
User, FeatureEntry, Gate, int, int]:
|
|
|
|
"""Get common parameters from the request."""
|
|
|
|
feature_id: int = kwargs['feature_id']
|
|
|
|
gate_id: int = kwargs['gate_id']
|
|
|
|
fe: FeatureEntry = handler.get_specified_feature(feature_id=feature_id)
|
|
|
|
|
|
|
|
user: User = handler.get_current_user(required=True)
|
|
|
|
gate: Gate = Gate.get_by_id(gate_id)
|
|
|
|
if not gate:
|
|
|
|
handler.abort(404, msg='Gate not found')
|
|
|
|
if gate.feature_id != feature_id:
|
|
|
|
handler.abort(400, msg='Mismatched feature and gate')
|
|
|
|
|
|
|
|
return user, fe, gate, feature_id, gate_id
|
|
|
|
|
|
|
|
|
2022-12-16 04:47:21 +03:00
|
|
|
class VotesAPI(basehandlers.APIHandler):
|
|
|
|
"""Users may see the set of votes on a feature, and add their own,
|
|
|
|
if allowed."""
|
|
|
|
|
|
|
|
def do_get(self, **kwargs) -> dict[str, list[dict[str, Any]]]:
|
|
|
|
"""Return a list of all vote values for a given feature."""
|
|
|
|
feature_id = kwargs['feature_id']
|
2023-01-24 23:39:50 +03:00
|
|
|
gate_id = kwargs.get('gate_id', None)
|
2022-12-16 04:47:21 +03:00
|
|
|
# Note: We assume that anyone may view approvals.
|
|
|
|
votes = Vote.get_votes(feature_id=feature_id, gate_id=gate_id)
|
|
|
|
dicts = [converters.vote_value_to_json_dict(v) for v in votes]
|
2024-08-23 17:38:07 +03:00
|
|
|
return GetVotesResponse.from_dict({'votes': dicts}).to_dict()
|
2022-12-16 04:47:21 +03:00
|
|
|
|
|
|
|
def do_post(self, **kwargs) -> dict[str, str]:
|
|
|
|
"""Set a user's vote value for the specified feature and gate."""
|
2023-11-04 02:50:43 +03:00
|
|
|
user, fe, gate, feature_id, gate_id = get_user_feature_and_gate(
|
|
|
|
self, kwargs)
|
2023-02-08 22:58:37 +03:00
|
|
|
new_state = self.get_int_param('state', validator=Vote.is_valid_state)
|
2023-01-12 23:03:09 +03:00
|
|
|
|
2023-09-12 23:30:26 +03:00
|
|
|
old_votes = Vote.get_votes(
|
|
|
|
feature_id=feature_id, gate_id=gate_id, set_by=user.email())
|
|
|
|
old_state = old_votes[0].state if old_votes else Vote.NO_RESPONSE
|
2023-11-04 02:50:43 +03:00
|
|
|
self.require_permissions(user, fe, gate, new_state)
|
2022-12-16 04:47:21 +03:00
|
|
|
|
|
|
|
# Note: We no longer write Approval entities.
|
2024-04-24 21:21:25 +03:00
|
|
|
old_gate_state = gate.state
|
|
|
|
new_gate_state = approval_defs.set_vote(feature_id, None, new_state,
|
2022-12-16 04:47:21 +03:00
|
|
|
user.email(), gate_id)
|
|
|
|
|
2024-04-24 21:21:25 +03:00
|
|
|
recently_approved = (old_gate_state not in (Vote.APPROVED, Vote.NA) and
|
|
|
|
new_gate_state in (Vote.APPROVED, Vote.NA))
|
|
|
|
# Notify that trial extension has been approved.
|
|
|
|
if gate.gate_type == GATE_API_EXTEND_ORIGIN_TRIAL and recently_approved:
|
|
|
|
stage = Stage.get_by_id(gate.stage_id)
|
|
|
|
notifier_helpers.send_trial_extension_approved_notification(
|
|
|
|
fe, stage, gate_id)
|
|
|
|
|
2023-10-27 03:26:06 +03:00
|
|
|
if new_state in (Vote.REVIEW_REQUESTED, Vote.NA_REQUESTED):
|
2024-08-30 20:15:18 +03:00
|
|
|
old_assignees = gate.assignee_emails[:]
|
2023-11-07 00:56:02 +03:00
|
|
|
approval_defs.auto_assign_reviewer(gate)
|
2024-08-30 20:15:18 +03:00
|
|
|
new_assignees = gate.assignee_emails[:]
|
|
|
|
if old_assignees != new_assignees:
|
|
|
|
amendment = Amendment(
|
|
|
|
field_name='review_assignee',
|
|
|
|
old_value=', '.join(old_assignees),
|
|
|
|
new_value=', '.join(new_assignees))
|
|
|
|
activity = Activity(
|
|
|
|
feature_id=fe.key.integer_id(), gate_id=gate_id,
|
|
|
|
author=user.email(), amendments=[amendment])
|
|
|
|
activity.put()
|
|
|
|
# Note: We don't notify assignee about autoassignment, because they
|
|
|
|
# will get a notification of review state change anyway.
|
|
|
|
|
2023-09-12 23:30:26 +03:00
|
|
|
notifier_helpers.notify_approvers_of_reviews(
|
2023-11-04 02:50:43 +03:00
|
|
|
fe, gate, new_state, user.email())
|
2023-02-20 03:14:31 +03:00
|
|
|
else:
|
|
|
|
notifier_helpers.notify_subscribers_of_vote_changes(
|
2023-11-04 02:50:43 +03:00
|
|
|
fe, gate, user.email(), new_state, old_state)
|
2023-02-16 21:22:53 +03:00
|
|
|
|
2022-12-16 04:47:21 +03:00
|
|
|
# Callers don't use the JSON response for this API call.
|
2024-08-23 17:38:07 +03:00
|
|
|
return SuccessMessage(message='Done').to_dict()
|
2022-12-16 04:47:21 +03:00
|
|
|
|
2023-02-08 22:58:37 +03:00
|
|
|
def require_permissions(self, user, feature, gate, new_state):
|
|
|
|
"""Abort the request if the user lacks permission to set this vote."""
|
2023-10-27 03:26:06 +03:00
|
|
|
is_requesting_review = new_state in (Vote.REVIEW_REQUESTED, Vote.NA_REQUESTED)
|
2023-02-08 22:58:37 +03:00
|
|
|
is_editor = permissions.can_edit_feature(user, feature.key.integer_id())
|
|
|
|
approvers = approval_defs.get_approvers(gate.gate_type)
|
2023-08-26 00:42:07 +03:00
|
|
|
is_approver = permissions.can_review_gate(user, feature, gate, approvers)
|
2023-02-08 22:58:37 +03:00
|
|
|
|
|
|
|
if is_requesting_review and is_editor:
|
|
|
|
return
|
|
|
|
|
|
|
|
if is_approver:
|
|
|
|
return
|
|
|
|
|
|
|
|
if is_requesting_review:
|
|
|
|
self.abort(403, msg='User may not request a review')
|
|
|
|
else:
|
|
|
|
self.abort(403, msg='User is not an approver')
|
|
|
|
|
2022-12-16 04:47:21 +03:00
|
|
|
|
|
|
|
class GatesAPI(basehandlers.APIHandler):
|
|
|
|
"""Get gates for a feature."""
|
|
|
|
|
|
|
|
def do_get(self, **kwargs) -> dict[str, Any]:
|
|
|
|
"""Return a list of all gates associated with the given feature."""
|
|
|
|
feature_id = kwargs.get('feature_id', None)
|
|
|
|
gates: list[Gate] = Gate.query(Gate.feature_id == feature_id).fetch()
|
|
|
|
|
|
|
|
# No gates associated with this feature.
|
|
|
|
if len(gates) == 0:
|
|
|
|
return {
|
|
|
|
'gates': [],
|
|
|
|
}
|
|
|
|
|
|
|
|
dicts = [converters.gate_value_to_json_dict(g) for g in gates]
|
2023-11-07 00:12:31 +03:00
|
|
|
for g in dicts:
|
|
|
|
approvers = approval_defs.get_approvers(g['gate_type'])
|
|
|
|
g['possible_assignee_emails'] = approvers
|
2022-12-16 04:47:21 +03:00
|
|
|
|
2024-08-23 17:38:07 +03:00
|
|
|
return GetGateResponse.from_dict({
|
2022-12-16 04:47:21 +03:00
|
|
|
'gates': dicts,
|
2024-08-23 17:38:07 +03:00
|
|
|
}).to_dict()
|
2023-11-04 02:50:43 +03:00
|
|
|
|
|
|
|
def do_post(self, **kwargs) -> dict[str, str]:
|
|
|
|
"""Set the assignees for a gate."""
|
|
|
|
user, fe, gate, feature_id, gate_id = get_user_feature_and_gate(
|
|
|
|
self, kwargs)
|
2024-08-23 17:38:07 +03:00
|
|
|
request = PostGateRequest.from_dict(self.request.json)
|
|
|
|
assignees = request.assignees
|
2023-11-04 02:50:43 +03:00
|
|
|
|
|
|
|
self.require_permissions(user, fe, gate)
|
|
|
|
self.validate_assignees(assignees, fe, gate)
|
|
|
|
old_assignees = gate.assignee_emails
|
|
|
|
gate.assignee_emails = assignees
|
|
|
|
gate.put()
|
|
|
|
notifier_helpers.notify_assignees(
|
|
|
|
fe, gate, user.email(), old_assignees, assignees)
|
|
|
|
|
|
|
|
# Callers don't use the JSON response for this API call.
|
2024-08-23 17:38:07 +03:00
|
|
|
return SuccessMessage(message='Done').to_dict()
|
2023-11-04 02:50:43 +03:00
|
|
|
|
|
|
|
def require_permissions(self, user, feature, gate):
|
|
|
|
"""Abort the request if the user lacks permission to set assignees."""
|
|
|
|
is_editor = permissions.can_edit_feature(user, feature.key.integer_id())
|
|
|
|
approvers = approval_defs.get_approvers(gate.gate_type)
|
|
|
|
is_approver = permissions.can_review_gate(user, feature, gate, approvers)
|
|
|
|
|
|
|
|
if not is_editor and not is_approver:
|
|
|
|
self.abort(403, msg='User lacks permission to assign reviews')
|
|
|
|
|
|
|
|
def validate_assignees(self, assignees, fe, gate):
|
|
|
|
"""Each assignee must have permission to review."""
|
|
|
|
approvers = approval_defs.get_approvers(gate.gate_type)
|
|
|
|
for email in assignees:
|
|
|
|
reviewer = User(email=email)
|
|
|
|
is_approver = permissions.can_review_gate(reviewer, fe, gate, approvers)
|
|
|
|
if not is_approver:
|
|
|
|
self.abort(400, 'Assignee is not a reviewer')
|
2023-12-12 22:12:14 +03:00
|
|
|
|
|
|
|
|
2024-04-03 23:57:18 +03:00
|
|
|
class PendingGatesAPI(basehandlers.APIHandler):
|
|
|
|
|
|
|
|
def get_stage_ids_of_gates_pending_my_approval(self) -> list[int] | Future:
|
|
|
|
"""Return a list of stage_id needing approval by current user."""
|
|
|
|
user = self.get_current_user()
|
|
|
|
if not user:
|
|
|
|
return []
|
|
|
|
|
|
|
|
approvable_gate_types = approval_defs.fields_approvable_by(user)
|
|
|
|
if not approvable_gate_types:
|
|
|
|
logging.info('User has no approvable_gate_types')
|
|
|
|
return []
|
|
|
|
query = Gate.query(
|
|
|
|
Gate.state.IN(Gate.PENDING_STATES),
|
|
|
|
Gate.gate_type.IN(approvable_gate_types))
|
|
|
|
future_stage_ids = query.fetch_async(projection=['stage_id'])
|
|
|
|
return future_stage_ids
|
|
|
|
|
|
|
|
def do_get(self, **kwargs):
|
|
|
|
"""Return a list of gates on features that have some active gates."""
|
|
|
|
# 1. Get the feature IDs of any active gates. Also, prefetch approvers.
|
|
|
|
future_projections = self.get_stage_ids_of_gates_pending_my_approval()
|
|
|
|
prefetched_approvers = {
|
|
|
|
gate_type: approval_defs.get_approvers(gate_type)
|
|
|
|
for gate_type in approval_defs.APPROVAL_FIELDS_BY_ID}
|
|
|
|
if type(future_projections) == list:
|
|
|
|
stage_ids = set(future_projections)
|
|
|
|
else:
|
|
|
|
projections = future_projections.get_result()
|
|
|
|
stage_ids = set(proj.stage_id for proj in projections)
|
|
|
|
if not stage_ids:
|
2024-08-23 17:38:07 +03:00
|
|
|
return GetGateResponse.from_dict({'gates': []}).to_dict()
|
2024-04-03 23:57:18 +03:00
|
|
|
|
|
|
|
# 2. Fetch all the gates on those stages.
|
|
|
|
gates: list[Gate] = Gate.query(Gate.stage_id.IN(stage_ids)).fetch()
|
|
|
|
|
|
|
|
# 3. Convert to dicts and add possible assignees.
|
|
|
|
dicts = [converters.gate_value_to_json_dict(g) for g in gates]
|
|
|
|
for g in dicts:
|
|
|
|
g['possible_assignee_emails'] = prefetched_approvers.get(g['gate_type'], [])
|
|
|
|
|
2024-08-23 17:38:07 +03:00
|
|
|
return GetGateResponse.from_dict({'gates': dicts}).to_dict()
|
2024-04-03 23:57:18 +03:00
|
|
|
|
|
|
|
|
2023-12-12 22:12:14 +03:00
|
|
|
class XfnGatesAPI(basehandlers.APIHandler):
|
|
|
|
|
|
|
|
def do_get(self, **kwargs):
|
|
|
|
"""Reject unneeded GET requests without triggering Error Reporting."""
|
|
|
|
self.abort(405, valid_methods=['POST'])
|
|
|
|
|
|
|
|
def do_post(self, **kwargs) -> dict[str, str]:
|
|
|
|
"""Add a full set of cross-functional gates to a stage."""
|
|
|
|
feature_id: int = kwargs['feature_id']
|
|
|
|
fe: FeatureEntry | None = self.get_specified_feature(feature_id=feature_id)
|
|
|
|
if fe is None:
|
|
|
|
self.abort(404, msg=f'Feature {feature_id} not found')
|
|
|
|
stage_id: int = kwargs['stage_id']
|
|
|
|
stage: Stage | None = Stage.get_by_id(stage_id)
|
|
|
|
if stage is None:
|
|
|
|
self.abort(404, msg=f'Stage {stage_id} not found')
|
|
|
|
|
|
|
|
user: User = self.get_current_user(required=True)
|
|
|
|
is_editor = fe and permissions.can_edit_feature(user, fe.key.integer_id())
|
|
|
|
is_approver = approval_defs.fields_approvable_by(user)
|
|
|
|
if not is_editor and not is_approver:
|
|
|
|
self.abort(403, msg='User lacks permission to create gates')
|
|
|
|
|
|
|
|
count = self.create_xfn_gates(feature_id, stage_id)
|
2024-08-23 17:38:07 +03:00
|
|
|
return SuccessMessage(message=f'Created {count} gates').to_dict()
|
2023-12-12 22:12:14 +03:00
|
|
|
|
|
|
|
def get_needed_gate_types(self) -> list[int]:
|
|
|
|
"""Return a list of gate types normally used to ship a new feature."""
|
|
|
|
needed_gate_tuples = STAGES_AND_GATES_BY_FEATURE_TYPE[
|
|
|
|
FEATURE_TYPE_INCUBATE_ID]
|
|
|
|
for stage_type, gate_types in needed_gate_tuples:
|
|
|
|
if stage_type == STAGE_BLINK_SHIPPING:
|
|
|
|
return gate_types
|
|
|
|
raise ValueError('Could not find expected list of gate types')
|
|
|
|
|
|
|
|
def create_xfn_gates(self, feature_id, stage_id) -> int:
|
|
|
|
"""Create all new incubation gates on a PSA stage"""
|
|
|
|
logging.info('Creating xfn gates')
|
|
|
|
existing_gates = Gate.query(
|
|
|
|
Gate.feature_id == feature_id, Gate.stage_id == stage_id).fetch()
|
|
|
|
existing_gate_types = set([eg.gate_type for eg in existing_gates])
|
|
|
|
logging.info('Found existing: %r', existing_gate_types)
|
|
|
|
new_gates = []
|
|
|
|
for gate_type in self.get_needed_gate_types():
|
|
|
|
if gate_type not in existing_gate_types:
|
|
|
|
logging.info(f'Creating gate type {gate_type}')
|
|
|
|
gate = Gate(
|
|
|
|
feature_id=feature_id, stage_id=stage_id, gate_type=gate_type,
|
|
|
|
state=Gate.PREPARING)
|
|
|
|
new_gates.append(gate)
|
|
|
|
|
|
|
|
ndb.put_multi(new_gates)
|
|
|
|
num_new = len(new_gates)
|
|
|
|
logging.info(f'Created {num_new} gates')
|
|
|
|
return num_new
|