diff --git a/cron.yaml b/cron.yaml index 9c49f487e7ee..daececf0360f 100644 --- a/cron.yaml +++ b/cron.yaml @@ -38,3 +38,8 @@ cron: - description: Send origin trial process reminder emails. url: /cron/send-ot-process-reminders schedule: every monday 4:00 + +# TODO(DanielRyanSmith): Add this job when OT creation is fully implemented. +# - description: Check if any origin trials require creation +# url: /cron/create_origin_trials +# schedule: every 5 minutes diff --git a/framework/origin_trials_client.py b/framework/origin_trials_client.py index 1a337ad4986d..e6aa2f7c5f45 100644 --- a/framework/origin_trials_client.py +++ b/framework/origin_trials_client.py @@ -22,13 +22,12 @@ import requests from framework import secrets +from framework import utils +from internals.core_models import Stage from internals.data_types import OriginTrialInfo import settings -CHROMIUM_SCHEDULE_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S' - - def get_trials_list() -> list[dict[str, Any]]: """Get a list of all origin trials. @@ -75,24 +74,16 @@ def _get_trial_end_time(end_milestone: int) -> int: format. """ milestone_plus_two = int(end_milestone) + 2 - try: - response = requests.get( - 'https://chromiumdash.appspot.com/fetch_milestone_schedule' - f'?mstone={milestone_plus_two}') - response.raise_for_status() - except requests.exceptions.RequestException as e: - logging.exception('Failed to get response from Chromium schedule API.') - raise e - response_json = response.json() + mstone_info = utils.get_chromium_milestone_info(milestone_plus_two) # Raise error if the response is not in the expected format. - if ('mstones' not in response_json - or len(response_json['mstones']) == 0 - or 'late_stable_date' not in response_json['mstones'][0]): + if ('mstones' not in mstone_info + or len(mstone_info['mstones']) == 0 + or 'late_stable_date' not in mstone_info['mstones'][0]): raise KeyError('Chromium schedule response not in expected format.') date = datetime.strptime( - response_json['mstones'][0]['late_stable_date'], - CHROMIUM_SCHEDULE_DATE_FORMAT) + mstone_info['mstones'][0]['late_stable_date'], + utils.CHROMIUM_SCHEDULE_DATE_FORMAT) return int(date.replace(tzinfo=timezone.utc).timestamp()) @@ -111,6 +102,88 @@ def _get_ot_access_token() -> str: return credentials.token +def create_origin_trial(ot_stage: Stage) -> str | None: + """Create an origin trial. + + Raises: + requests.exceptions.RequestException: If the request fails to connect or + the HTTP status code is not successful. + """ + if settings.DEV_MODE: + logging.info('Creation request will not be sent to origin trials API in ' + 'local environment.') + return None + key = secrets.get_ot_api_key() + if key is None: + return None + + json = { + 'trial': { + 'display_name': ot_stage.ot_display_name, + 'start_milestone': str(ot_stage.milestones.desktop_first), + 'end_milestone': str(ot_stage.milestones.desktop_last), + 'end_time': { + 'seconds': _get_trial_end_time(ot_stage.milestones.desktop_last) + }, + 'description': ot_stage.ot_description, + 'documentation_url': ot_stage.ot_documentation_url, + 'feedback_url': ot_stage.ot_feedback_submission_url, + 'intent_to_experiment_url': ot_stage.intent_thread_url, + 'chromestatus_url': f'{settings.SITE_URL}feature/{ot_stage.feature_id}', + 'allow_third_party_origins': ot_stage.ot_has_third_party_support, + 'type': ('DEPRECATION' + if ot_stage.ot_is_deprecation_trial else 'ORIGIN_TRIAL'), + } + } + if ot_stage.ot_chromium_trial_name: + json['origin_trial_feature_name'] = ot_stage.ot_chromium_trial_name + access_token = _get_ot_access_token() + headers = {'Authorization': f'Bearer {access_token}'} + url = f'{settings.OT_API_URL}/v1/trials-integration' + + try: + response = requests.post( + url, headers=headers, params={'key': key}, json=json) + logging.info(response.text) + response.raise_for_status() + except requests.exceptions.RequestException as e: + logging.exception( + f'Failed to get response from origin trials API. {response.text}') + raise e + + return response.json()['id'] + +def activate_origin_trial(origin_trial_id: str) -> None: + """Activate an existing origin trial. + + Raises: + requests.exceptions.RequestException: If the request fails to connect or + the HTTP status code is not successful. + """ + if settings.DEV_MODE: + logging.info('Activation request will not be sent to origin trials API in ' + 'local environment.') + return None + key = secrets.get_ot_api_key() + if key is None: + return None + + json = {'id': origin_trial_id} + access_token = _get_ot_access_token() + headers = {'Authorization': f'Bearer {access_token}'} + url = (f'{settings.OT_API_URL}/v1/trials-integration/' + f'{origin_trial_id}:activate') + try: + response = requests.post( + url, headers=headers, params={'key': key}, json=json) + logging.info(response.text) + response.raise_for_status() + except requests.exceptions.RequestException as e: + logging.exception( + f'Failed to get response from origin trials API. {response.text}') + raise e + + def extend_origin_trial(trial_id: str, end_milestone: int, intent_url: str): """Extend an existing origin trial. diff --git a/framework/origin_trials_client_test.py b/framework/origin_trials_client_test.py index 885f2b2ae407..18963f4601b9 100644 --- a/framework/origin_trials_client_test.py +++ b/framework/origin_trials_client_test.py @@ -18,49 +18,65 @@ from unittest import mock from framework import origin_trials_client +from internals.core_models import MilestoneSet, Stage +import settings test_app = flask.Flask(__name__) class OriginTrialsClientTest(testing_config.CustomTestCase): - mock_list_trials_json = { - 'trials': [ - { - 'id': '-5269211564023480319', - 'displayName': 'Example Trial', - 'description': 'A description.', - 'originTrialFeatureName': 'ExampleTrial', - 'status': 'ACTIVE', - 'enabled': True, - 'isPublic': True, - 'chromestatusUrl': 'https://example.com/chromestatus', - 'startMilestone': '123', - 'endMilestone': '456', - 'originalEndMilestone': '450', - 'endTime': '2025-01-01T00:00:00Z', - 'feedbackUrl': 'https://example.com/feedback', - 'documentationUrl': 'https://example.com/docs', - 'intentToExperimentUrl': 'https://example.com/intent', - 'type': 'ORIGIN_TRIAL', - 'allowThirdPartyOrigins': True, - 'trialExtensions': [{}], - }, - { - 'id': '3611886901151137793', - 'displayName': 'Non-public trial', - 'description': 'Another description.', - 'originTrialFeatureName': 'SampleTrial', - 'status': 'COMPLETE', - 'enabled': True, - 'isPublic': False, - 'chromestatusUrl': 'https://example.com/chromestatus2', - 'startMilestone': '100', - 'endMilestone': '200', - 'endTime': '2024-01-01T00:00:00Z', - } - ] - } + def setUp(self): + self.ot_stage = Stage( + feature_id=1, stage_type=150, ot_display_name='Example Trial', + milestones=MilestoneSet(desktop_first=100, desktop_last=106), + ot_documentation_url='https://example.com/docs', + ot_feedback_submission_url='https://example.com/feedback', + intent_thread_url='https://example.com/experiment', + ot_description='OT description', ot_has_third_party_support=True, + ot_is_deprecation_trial=True) + self.ot_stage.put() + self.mock_list_trials_json = { + 'trials': [ + { + 'id': '-5269211564023480319', + 'displayName': 'Example Trial', + 'description': 'A description.', + 'originTrialFeatureName': 'ExampleTrial', + 'status': 'ACTIVE', + 'enabled': True, + 'isPublic': True, + 'chromestatusUrl': 'https://example.com/chromestatus', + 'startMilestone': '123', + 'endMilestone': '456', + 'originalEndMilestone': '450', + 'endTime': '2025-01-01T00:00:00Z', + 'feedbackUrl': 'https://example.com/feedback', + 'documentationUrl': 'https://example.com/docs', + 'intentToExperimentUrl': 'https://example.com/intent', + 'type': 'ORIGIN_TRIAL', + 'allowThirdPartyOrigins': True, + 'trialExtensions': [{}], + }, + { + 'id': '3611886901151137793', + 'displayName': 'Non-public trial', + 'description': 'Another description.', + 'originTrialFeatureName': 'SampleTrial', + 'status': 'COMPLETE', + 'enabled': True, + 'isPublic': False, + 'chromestatusUrl': 'https://example.com/chromestatus2', + 'startMilestone': '100', + 'endMilestone': '200', + 'endTime': '2024-01-01T00:00:00Z', + } + ] + } + + def tearDown(self): + for entity in Stage.query(): + entity.key.delete() @mock.patch('framework.secrets.get_ot_api_key') @mock.patch('requests.get') @@ -131,7 +147,7 @@ def test_extend_origin_trial__no_api_key( def test_extend_origin_trial__with_api_key( self, mock_requests_post, mock_get_trial_end_time, mock_get_ot_access_token, mock_api_key_get): - """If an API key is available, POST should extend trial and return true.""" + """If an API key is available, POST should extend trial.""" mock_requests_post.return_value = mock.MagicMock( status_code=200, json=lambda : {}) mock_get_trial_end_time.return_value = 111222333 @@ -159,3 +175,94 @@ def test_get_trial_end_time(self, mock_requests_get): return_result = origin_trials_client._get_trial_end_time(123) self.assertEqual(return_result, 1682812800) mock_requests_get.assert_called_once() + + @mock.patch('framework.secrets.get_ot_api_key') + @mock.patch('requests.post') + def test_create_origin_trial__no_api_key( + self, mock_requests_post, mock_api_key_get): + """If no API key is available, do not send creation request.""" + mock_api_key_get.return_value = None + origin_trials_client.create_origin_trial(self.ot_stage) + + mock_api_key_get.assert_called_once() + # POST request should not be executed with no API key. + mock_requests_post.assert_not_called() + + @mock.patch('framework.secrets.get_ot_api_key') + @mock.patch('framework.origin_trials_client._get_ot_access_token') + @mock.patch('framework.origin_trials_client._get_trial_end_time') + @mock.patch('requests.post') + def test_create_origin_trial__with_api_key( + self, mock_requests_post, mock_get_trial_end_time, + mock_get_ot_access_token, mock_api_key_get): + """If an API key is available, POST should create trial and return true.""" + mock_requests_post.return_value = mock.MagicMock( + status_code=200, json=lambda : {'id': '-1234567890'}) + mock_get_trial_end_time.return_value = 111222333 + mock_get_ot_access_token.return_value = 'access_token' + mock_api_key_get.return_value = 'api_key_value' + + id = origin_trials_client.create_origin_trial(self.ot_stage) + self.assertEqual(id, '-1234567890') + + mock_api_key_get.assert_called_once() + mock_get_ot_access_token.assert_called_once() + mock_requests_post.assert_called_once_with( + f'{settings.OT_API_URL}/v1/trials-integration', + headers={'Authorization': 'Bearer access_token'}, + params={'key': 'api_key_value'}, + json={ + 'trial': { + 'display_name': 'Example Trial', + 'start_milestone': '100', + 'end_milestone': '106', + 'end_time': { + 'seconds': 111222333 + }, + 'description': 'OT description', + 'documentation_url': 'https://example.com/docs', + 'feedback_url': 'https://example.com/feedback', + 'intent_to_experiment_url': 'https://example.com/experiment', + 'chromestatus_url': f'{settings.SITE_URL}feature/1', + 'allow_third_party_origins': True, + 'type': 'DEPRECATION', + } + } + ) + + @mock.patch('framework.secrets.get_ot_api_key') + @mock.patch('requests.post') + def test_activate_origin_trial__no_api_key( + self, mock_requests_post, mock_api_key_get): + """If no API key is available, do not send activation request.""" + mock_api_key_get.return_value = None + origin_trials_client.create_origin_trial(self.ot_stage) + + mock_api_key_get.assert_called_once() + # POST request should not be executed with no API key. + mock_requests_post.assert_not_called() + + @mock.patch('framework.secrets.get_ot_api_key') + @mock.patch('framework.origin_trials_client._get_ot_access_token') + @mock.patch('framework.origin_trials_client._get_trial_end_time') + @mock.patch('requests.post') + def test_activate_origin_trial__with_api_key( + self, mock_requests_post, mock_get_trial_end_time, + mock_get_ot_access_token, mock_api_key_get): + """If an API key is available, POST should activate trial.""" + mock_requests_post.return_value = mock.MagicMock( + status_code=200, json=lambda : {}) + mock_get_trial_end_time.return_value = 111222333 + mock_get_ot_access_token.return_value = 'access_token' + mock_api_key_get.return_value = 'api_key_value' + + origin_trials_client.activate_origin_trial('-1234567890') + + mock_api_key_get.assert_called_once() + mock_get_ot_access_token.assert_called_once() + mock_requests_post.assert_called_once_with( + f'{settings.OT_API_URL}/v1/trials-integration/-1234567890:activate', + headers={'Authorization': 'Bearer access_token'}, + params={'key': 'api_key_value'}, + json={'id': '-1234567890'} + ) diff --git a/framework/utils.py b/framework/utils.py index 49324519a8a5..a7efb8408b42 100644 --- a/framework/utils.py +++ b/framework/utils.py @@ -15,14 +15,15 @@ import calendar import datetime -import flask import logging +import requests import time import traceback -from framework import users import settings +CHROMIUM_SCHEDULE_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S' + def normalized_name(val): return val.lower().replace(' ', '').replace('/', '') @@ -117,3 +118,15 @@ def get_banner_time(timestamp): def dedupe(list_with_duplicates): """Return a list without duplicates, in the original order.""" return list(dict.fromkeys(list_with_duplicates)) + + +def get_chromium_milestone_info(milestone: int) -> dict: + try: + response = requests.get( + 'https://chromiumdash.appspot.com/fetch_milestone_schedule' + f'?mstone={milestone}') + response.raise_for_status() + except requests.exceptions.RequestException as e: + logging.exception('Failed to get response from Chromium schedule API.') + raise e + return response.json() diff --git a/internals/core_enums.py b/internals/core_enums.py index 7347c6c096a3..77baaf44cf95 100644 --- a/internals/core_enums.py +++ b/internals/core_enums.py @@ -385,6 +385,12 @@ FEATURE_TYPE_DEPRECATION_ID: STAGE_DEP_DEPRECATION_TRIAL, FEATURE_TYPE_ENTERPRISE_ID: None, } +# Set of every origin trial stage type. +ALL_ORIGIN_TRIAL_STAGE_TYPES: set[int] = { + STAGE_BLINK_ORIGIN_TRIAL, + STAGE_FAST_ORIGIN_TRIAL, + STAGE_DEP_DEPRECATION_TRIAL, +} # Origin trial extension stage types for every feature type. STAGE_TYPES_EXTEND_ORIGIN_TRIAL: dict[int, Optional[int]] = { FEATURE_TYPE_INCUBATE_ID: STAGE_BLINK_EXTEND_ORIGIN_TRIAL, diff --git a/internals/maintenance_scripts.py b/internals/maintenance_scripts.py index 532fedca124e..e403bc210405 100644 --- a/internals/maintenance_scripts.py +++ b/internals/maintenance_scripts.py @@ -12,15 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -import datetime +from datetime import date, datetime import logging from typing import Any from google.cloud import ndb # type: ignore +import requests +from api import converters from framework.basehandlers import FlaskHandler +from framework import cloud_tasks_helpers from framework import origin_trials_client +from framework import utils from internals import approval_defs from internals.core_models import FeatureEntry, MilestoneSet, Stage +from internals.data_types import StageDict from internals.review_models import Gate, Vote, Activity from internals.core_enums import * from internals.feature_links import batch_index_feature_entries @@ -150,7 +155,7 @@ class BackfillRespondedOn(FlaskHandler): def update_responded_on(self, gate): """Update gate.responded_on and return True if an update was needed.""" gate_id = gate.key.integer_id() - earliest_response = datetime.datetime.max + earliest_response = datetime.max approvers = approval_defs.get_approvers(gate.gate_type) activities = Activity.get_activities( @@ -169,7 +174,7 @@ def update_responded_on(self, gate): logging.info(f'Set feature {gate.feature_id} gate {gate_id} ' f'to {v.set_on} because of vote') - if earliest_response != datetime.datetime.max: + if earliest_response != datetime.max: gate.responded_on = earliest_response return True else: @@ -500,3 +505,73 @@ def get_template_data(self, **kwargs): ndb.put_multi(batch) return f'{count} Feature entities updated of {len(features_by_id)} available features.' + + +class CreateOriginTrials(FlaskHandler): + + def handle_creation(self, stage: Stage, stage_dict: StageDict) -> str | None: + """Send a flagged creation request for processing to the Origin Trials + API. + """ + request_success = False + attempted_requests = 0 + new_id = None + while not request_success and attempted_requests < 3: + attempted_requests += 1 + try: + new_id = origin_trials_client.create_origin_trial(stage) + request_success = True + except requests.RequestException: + attempted_requests += 1 + if not request_success: + logging.warning('Origin trial could not be created for stage ' + f'{stage.key.integer_id()}') + cloud_tasks_helpers.enqueue_task( + '/tasks/email-ot-creation-request-failed', {'stage': stage_dict}) + return new_id + + def handle_activation(self, stage: Stage, stage_dict: StageDict) -> None: + """Send trial activation request.""" + try: + origin_trials_client.activate_origin_trial(stage.origin_trial_id) + cloud_tasks_helpers.enqueue_task( + '/tasks/email-ot-activated', {'stage': stage_dict}) + except requests.RequestException: + cloud_tasks_helpers.enqueue_task( + '/tasks/email-ot-activation-failed', {'stage': stage_dict}) + + def _get_today(self): + return date.today() + + def prepare_for_activation(self, stage: Stage, stage_dict: StageDict) -> None: + """Set up activation date or activate trial now.""" + mstone_info = utils.get_chromium_milestone_info( + stage.milestones.desktop_first) + date = datetime.strptime( + mstone_info['mstones'][0]['branch_point'], + utils.CHROMIUM_SCHEDULE_DATE_FORMAT).date() + if date <= self._get_today(): + self.handle_activation(stage, stage_dict) + else: + stage.ot_activation_date = date + cloud_tasks_helpers.enqueue_task( + '/tasks/email-ot-creation-processed', {'stage': stage_dict}) + + def get_template_data(self, **kwargs): + """Create any origin trials that are flagged for creation.""" + self.require_cron_header() + + # OT stages that are flagged to process a trial creation. + ot_stages: list[Stage] = Stage.query( + Stage.stage_type.IN(ALL_ORIGIN_TRIAL_STAGE_TYPES), + Stage.ot_action_requested == True).fetch() + for stage in ot_stages: + stage.ot_action_requested = False + stage_dict = converters.stage_to_json_dict(stage) + origin_trial_id = self.handle_creation(stage, stage_dict) + if origin_trial_id: + stage.origin_trial_id = origin_trial_id + self.prepare_for_activation(stage, stage_dict) + stage.put() + + return f'{len(ot_stages)} trial creation request(s) processed.' diff --git a/internals/maintenance_scripts_test.py b/internals/maintenance_scripts_test.py index d281fd6d03b8..6f76c6d407fc 100644 --- a/internals/maintenance_scripts_test.py +++ b/internals/maintenance_scripts_test.py @@ -11,10 +11,15 @@ # 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 requests +from datetime import date, datetime from unittest import mock +from api import converters from internals import maintenance_scripts -import testing_config # Must be imported before the module under test. from internals.core_models import FeatureEntry, Stage, MilestoneSet class AssociateOTsTest(testing_config.CustomTestCase): @@ -207,3 +212,205 @@ def test_associate_ots(self, mock_ot_client): # Check that the extension request was cleared. self.assertFalse(self.extension_stage_1.ot_action_requested) + + +def mock_mstone_return_value_generator(*args, **kwargs): + """Returns mock milestone info based on input.""" + if args == (100,): + return {'mstones': [{'branch_point': '2020-01-01T00:00:00'}]} + if args == (200,): + return {'mstones': [{'branch_point': '2030-01-01T00:00:00'}]} + else: + return {'mstones': [{'branch_point': '2025-01-01T00:00:00'}]} + +class CreateOriginTrialsTest(testing_config.CustomTestCase): + + def setUp(self): + self.feature_1 = FeatureEntry( + id=1, name='feature one', summary='sum', category=1, feature_type=0) + self.feature_1.put() + self.feature_2 = FeatureEntry( + id=2, name='feature one', summary='sum', category=1, feature_type=1) + self.feature_2.put() + self.ot_stage_1 = Stage( + feature_id=1, stage_type=150, ot_display_name='Example Trial', + ot_owner_email='feature_owner@google.com', + ot_chromium_trial_name='ExampleTrial', + milestones=MilestoneSet(desktop_first=100, desktop_last=106), + ot_documentation_url='https://example.com/docs', + ot_feedback_submission_url='https://example.com/feedback', + intent_thread_url='https://example.com/experiment', + ot_description='OT description', ot_has_third_party_support=True, + ot_is_deprecation_trial=False) + self.ot_stage_1.put() + self.ot_stage_1_dict = converters.stage_to_json_dict(self.ot_stage_1) + + self.ot_stage_2 = Stage( + feature_id=2, stage_type=250, ot_display_name='Example Trial 2', + ot_owner_email='feature_owner2@google.com', + ot_chromium_trial_name='ExampleTrial2', + milestones=MilestoneSet(desktop_first=200, desktop_last=206), + ot_documentation_url='https://example.com/docs2', + ot_feedback_submission_url='https://example.com/feedback2', + intent_thread_url='https://example.com/experiment2', + ot_description='OT description2', ot_has_third_party_support=True, + ot_is_deprecation_trial=False) + self.ot_stage_2.put() + self.ot_stage_2_dict = converters.stage_to_json_dict(self.ot_stage_2) + + self.ot_stage_3 = Stage( + feature_id=3, stage_type=450, ot_display_name='Example Trial 3', + ot_owner_email='feature_owner2@google.com', + ot_chromium_trial_name='ExampleTrial3', + milestones=MilestoneSet(desktop_first=200, desktop_last=206), + ot_documentation_url='https://example.com/docs3', + ot_feedback_submission_url='https://example.com/feedback3', + intent_thread_url='https://example.com/experiment3', + ot_description='OT description3', ot_has_third_party_support=True, + ot_is_deprecation_trial=True) + self.ot_stage_3 + self.handler = maintenance_scripts.CreateOriginTrials() + + def tearDown(self): + for kind in [FeatureEntry, Stage]: + for entity in kind.query(): + entity.key.delete() + + @mock.patch('framework.cloud_tasks_helpers.enqueue_task') + @mock.patch('internals.maintenance_scripts.CreateOriginTrials._get_today') + @mock.patch('framework.utils.get_chromium_milestone_info') + @mock.patch('framework.origin_trials_client.activate_origin_trial') + @mock.patch('framework.origin_trials_client.create_origin_trial') + def test_create_trials( + self, + mock_create_origin_trial, + mock_activate_origin_trial, + mock_get_chromium_milestone_info, + mock_today, + mock_enqueue_task): + """Origin trials are created and activated if it is after branch point.""" + self.ot_stage_1.ot_action_requested = True + self.ot_stage_1.put() + self.ot_stage_2.ot_action_requested = True + self.ot_stage_2.put() + + mock_today.return_value = date(2020, 6, 1) # 2020-06-01 + mock_get_chromium_milestone_info.side_effect = mock_mstone_return_value_generator + mock_create_origin_trial.side_effect = ['111222333', '-444555666'] + + result = self.handler.get_template_data() + self.assertEqual('2 trial creation request(s) processed.', result) + # Check that different email notifications were sent. + mock_enqueue_task.assert_has_calls([ + mock.call( + '/tasks/email-ot-activated', {'stage': self.ot_stage_1_dict}), + mock.call( + '/tasks/email-ot-creation-processed', + {'stage': self.ot_stage_2_dict}) + ], any_order=True) + # Activation was handled, so a delayed activation date should not be set. + self.assertIsNone(self.ot_stage_1.ot_activation_date) + # OT 2 should have delayed activation date set. + self.assertEqual(date(2030, 1, 1), self.ot_stage_2.ot_activation_date) + # Action requests should be cleared. + self.assertFalse(self.ot_stage_1.ot_action_requested) + self.assertFalse(self.ot_stage_2.ot_action_requested) + # New origin trial ID should be associated with the stages.ss + self.assertIsNotNone(self.ot_stage_1.origin_trial_id) + self.assertIsNotNone(self.ot_stage_2.origin_trial_id) + # OT 3 had no action request, so it should not have changed. + self.assertIsNone(self.ot_stage_3.origin_trial_id) + + @mock.patch('framework.cloud_tasks_helpers.enqueue_task') + @mock.patch('internals.maintenance_scripts.CreateOriginTrials._get_today') + @mock.patch('framework.utils.get_chromium_milestone_info') + @mock.patch('framework.origin_trials_client.activate_origin_trial') + @mock.patch('framework.origin_trials_client.create_origin_trial') + def test_create_trials__failed( + self, + mock_create_origin_trial, + mock_activate_origin_trial, + mock_get_chromium_milestone_info, + mock_today, + mock_enqueue_task): + self.ot_stage_1.ot_action_requested = True + self.ot_stage_1.put() + self.ot_stage_2.ot_action_requested = True + self.ot_stage_2.put() + + mock_today.return_value = date(2020, 6, 1) # 2020-06-01 + mock_get_chromium_milestone_info.side_effect = mock_mstone_return_value_generator + # Create trial request is failing. + mock_create_origin_trial.side_effect = requests.RequestException( + mock.Mock(status=503), 'Unavailable') + + result = self.handler.get_template_data() + self.assertEqual('2 trial creation request(s) processed.', result) + # Failure notications should be sent to the OT support team. + mock_enqueue_task.assert_has_calls([ + mock.call( + '/tasks/email-ot-creation-request-failed', + {'stage': self.ot_stage_1_dict}), + mock.call( + '/tasks/email-ot-creation-request-failed', + {'stage': self.ot_stage_2_dict}) + ], any_order=True) + # Creation wasn't handled, so activation dates shouldn't be set. + self.assertIsNone(self.ot_stage_1.ot_activation_date) + self.assertIsNone(self.ot_stage_2.ot_activation_date) + # Action requests should be cleared. + self.assertFalse(self.ot_stage_1.ot_action_requested) + self.assertFalse(self.ot_stage_2.ot_action_requested) + # No actions were successful, so no OT IDs should be added to stages. + self.assertIsNone(self.ot_stage_1.origin_trial_id) + self.assertIsNone(self.ot_stage_2.origin_trial_id) + self.assertIsNone(self.ot_stage_3.origin_trial_id) + + @mock.patch('framework.cloud_tasks_helpers.enqueue_task') + @mock.patch('internals.maintenance_scripts.CreateOriginTrials._get_today') + @mock.patch('framework.utils.get_chromium_milestone_info') + @mock.patch('framework.origin_trials_client.activate_origin_trial') + @mock.patch('framework.origin_trials_client.create_origin_trial') + def test_create_trials__failed_activation( + self, + mock_create_origin_trial, + mock_activate_origin_trial, + mock_get_chromium_milestone_info, + mock_today, + mock_enqueue_task): + self.ot_stage_1.ot_action_requested = True + self.ot_stage_1.put() + self.ot_stage_2.ot_action_requested = True + self.ot_stage_2.put() + + mock_today.return_value = date(2020, 6, 1) # 2020-06-01 + mock_get_chromium_milestone_info.side_effect = mock_mstone_return_value_generator + mock_create_origin_trial.side_effect = ['111222333', '-444555666'] + # Activate trial request is failing. + mock_activate_origin_trial.side_effect = requests.RequestException( + mock.Mock(status=503), 'Unavailable') + + result = self.handler.get_template_data() + self.assertEqual('2 trial creation request(s) processed.', result) + # One trial activation should have failed, and one should be processed. + mock_enqueue_task.assert_has_calls([ + mock.call( + '/tasks/email-ot-activation-failed', + {'stage': self.ot_stage_1_dict}), + mock.call( + '/tasks/email-ot-creation-processed', + {'stage': self.ot_stage_2_dict}) + ], any_order=True) + + # Activation failed, but a delayed activation date should not be set. + self.assertIsNone(self.ot_stage_1.ot_activation_date) + # OT 2 should have delayed activation date set. + self.assertEqual(date(2030, 1, 1), self.ot_stage_2.ot_activation_date) + # Action requests should be cleared. + self.assertFalse(self.ot_stage_1.ot_action_requested) + self.assertFalse(self.ot_stage_2.ot_action_requested) + # New origin trial ID should be associated with the stages. + self.assertIsNotNone(self.ot_stage_1.origin_trial_id) + self.assertIsNotNone(self.ot_stage_2.origin_trial_id) + # OT 3 had no action request, so it should not have changed. + self.assertIsNone(self.ot_stage_3.origin_trial_id) diff --git a/main.py b/main.py index c5cd41123204..312b7a0df4c1 100644 --- a/main.py +++ b/main.py @@ -278,6 +278,8 @@ class Route: Route('/cron/associate_origin_trials', maintenance_scripts.AssociateOTs), Route('/cron/send-ot-process-reminders', reminders.SendOTReminderEmailsHandler), + # TODO(DanielRyanSmith): Add this route when OT creation is fully implemented. + # Route('/cron/create_origin_trials', maintenance_scripts.CreateOriginTrials), Route('/admin/find_stop_words', search_fulltext.FindStopWords),