diff --git a/docs/experimenter/openapi-schema.json b/docs/experimenter/openapi-schema.json index f02631531f..39ab27da84 100644 --- a/docs/experimenter/openapi-schema.json +++ b/docs/experimenter/openapi-schema.json @@ -177,7 +177,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ] } }, @@ -273,7 +274,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ] } }, @@ -621,7 +623,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ] } }, @@ -699,7 +702,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ] } }, @@ -783,7 +787,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ] } }, @@ -888,7 +893,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ] } }, @@ -1287,7 +1293,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ], "type": "string" }, diff --git a/docs/experimenter/swagger-ui.html b/docs/experimenter/swagger-ui.html index 051c376407..e01793b621 100644 --- a/docs/experimenter/swagger-ui.html +++ b/docs/experimenter/swagger-ui.html @@ -189,7 +189,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ] } }, @@ -285,7 +286,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ] } }, @@ -633,7 +635,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ] } }, @@ -711,7 +714,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ] } }, @@ -795,7 +799,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ] } }, @@ -900,7 +905,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ] } }, @@ -1299,7 +1305,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Disabled" ], "type": "string" }, diff --git a/experimenter/experimenter/experiments/constants.py b/experimenter/experimenter/experiments/constants.py index a196bc42f4..e723e3997f 100644 --- a/experimenter/experimenter/experiments/constants.py +++ b/experimenter/experimenter/experiments/constants.py @@ -462,6 +462,10 @@ class Status(models.TextChoices): LIVE = "Live" COMPLETE = "Complete" + # This status applies only to rollouts, and indicates that the rollout has been + # disabled. It is not a valid status for experiments. + DISABLED = "Disabled" + class PublishStatus(models.TextChoices): IDLE = "Idle" REVIEW = "Review" diff --git a/experimenter/experimenter/experiments/migrations/0334_alter_nimbuschangelog_new_status_and_more.py b/experimenter/experimenter/experiments/migrations/0334_alter_nimbuschangelog_new_status_and_more.py new file mode 100644 index 0000000000..59350a3869 --- /dev/null +++ b/experimenter/experimenter/experiments/migrations/0334_alter_nimbuschangelog_new_status_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.15 on 2026-07-15 18:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiments', '0333_nimbusrolloutplantemplate_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='nimbuschangelog', + name='new_status', + field=models.CharField(choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Disabled', 'Disabled')], max_length=255), + ), + migrations.AlterField( + model_name='nimbuschangelog', + name='new_status_next', + field=models.CharField(blank=True, choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Disabled', 'Disabled')], max_length=255, null=True), + ), + migrations.AlterField( + model_name='nimbuschangelog', + name='old_status', + field=models.CharField(blank=True, choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Disabled', 'Disabled')], max_length=255, null=True), + ), + migrations.AlterField( + model_name='nimbuschangelog', + name='old_status_next', + field=models.CharField(blank=True, choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Disabled', 'Disabled')], max_length=255, null=True), + ), + migrations.AlterField( + model_name='nimbusexperiment', + name='status', + field=models.CharField(choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Disabled', 'Disabled')], default='Draft', max_length=255, verbose_name='Status'), + ), + migrations.AlterField( + model_name='nimbusexperiment', + name='status_next', + field=models.CharField(blank=True, choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Disabled', 'Disabled')], max_length=255, null=True), + ), + ] diff --git a/experimenter/experimenter/experiments/models.py b/experimenter/experimenter/experiments/models.py index b0ce1b5b56..60e3003219 100644 --- a/experimenter/experimenter/experiments/models.py +++ b/experimenter/experimenter/experiments/models.py @@ -545,13 +545,27 @@ class Meta: class Filters: IS_LAUNCH_QUEUED = Q( - status=NimbusConstants.Status.DRAFT, - status_next=NimbusConstants.Status.LIVE, + Q( + status=NimbusConstants.Status.DRAFT, + status_next=NimbusConstants.Status.LIVE, + ) + | Q( + is_rollout=True, + status=NimbusConstants.Status.DISABLED, + status_next=NimbusConstants.Status.LIVE, + ), publish_status=NimbusConstants.PublishStatus.APPROVED, ) IS_LAUNCHING = Q( - status=NimbusConstants.Status.DRAFT, - status_next=NimbusConstants.Status.LIVE, + Q( + status=NimbusConstants.Status.DRAFT, + status_next=NimbusConstants.Status.LIVE, + ) + | Q( + is_rollout=True, + status=NimbusConstants.Status.DISABLED, + status_next=NimbusConstants.Status.LIVE, + ), publish_status=NimbusConstants.PublishStatus.WAITING, ) IS_UPDATE_QUEUED = Q( @@ -565,13 +579,27 @@ class Filters: publish_status=NimbusConstants.PublishStatus.WAITING, ) IS_END_QUEUED = Q( - status=NimbusConstants.Status.LIVE, - status_next=NimbusConstants.Status.COMPLETE, + Q( + status=NimbusConstants.Status.LIVE, + status_next=NimbusConstants.Status.COMPLETE, + ) + | Q( + is_rollout=True, + status=NimbusConstants.Status.LIVE, + status_next=NimbusConstants.Status.DISABLED, + ), publish_status=NimbusConstants.PublishStatus.APPROVED, ) IS_ENDING = Q( - status=NimbusConstants.Status.LIVE, - status_next=NimbusConstants.Status.COMPLETE, + Q( + status=NimbusConstants.Status.LIVE, + status_next=NimbusConstants.Status.COMPLETE, + ) + | Q( + is_rollout=True, + status=NimbusConstants.Status.LIVE, + status_next=NimbusConstants.Status.DISABLED, + ), publish_status=NimbusConstants.PublishStatus.WAITING, ) SHOULD_ALLOCATE_BUCKETS = Q( @@ -1285,7 +1313,10 @@ def advance_rollout_phase(self): today = timezone.now().date() phase_ids = [phase.id for phase in phases] - if self.rollout_phase_id is None: + if self.rollout_phase_next_id is not None: + current_phase = self.rollout_phase + next_phase = self.rollout_phase_next + elif self.rollout_phase_id is None: current_phase = None next_phase = phases[0] else: @@ -1297,7 +1328,9 @@ def advance_rollout_phase(self): if next_phase is not None and not next_phase.population_percent: return - if current_phase is not None: + if current_phase is not None and ( + current_phase.end_date is None or current_phase.end_date > today + ): current_phase.end_date = today if current_phase.actual_start_date: current_phase.start_date = current_phase.actual_start_date @@ -1312,8 +1345,52 @@ def advance_rollout_phase(self): next_phase.save() self.rollout_phase = next_phase self.rollout_phase_next = None + self.population_percent = next_phase.population_percent self.save() + def stage_rollout_phase_advance(self, copy_current_if_missing=False): + phases = list(self.rollout_phases.all()) + if not phases: + return None + + current_phase = self.rollout_phase + if self.rollout_phase_next_id is not None: + next_phase = self.rollout_phase_next + elif self.rollout_phase_id is None: + next_phase = phases[0] + else: + phase_ids = [phase.id for phase in phases] + current_index = phase_ids.index(self.rollout_phase_id) + next_index = current_index + 1 + next_phase = phases[next_index] if next_index < len(phases) else None + + if next_phase is None and copy_current_if_missing and current_phase is not None: + next_phase = self.rollout_phases.create( + population_percent=current_phase.population_percent + ) + + if next_phase is None or not next_phase.population_percent: + return None + + self.rollout_phase_next = next_phase + self.population_percent = next_phase.population_percent + self.save(update_fields=["rollout_phase_next", "population_percent"]) + return next_phase + + def end_current_rollout_phase(self): + current_phase = self.rollout_phase + if current_phase is None: + return + + today = timezone.now().date() + if current_phase.end_date is not None and current_phase.end_date <= today: + return + + current_phase.end_date = today + if current_phase.actual_start_date: + current_phase.start_date = current_phase.actual_start_date + current_phase.save() + @property def is_missing_takeaway_info(self): return ( @@ -3457,6 +3534,7 @@ class Messages: REJECTED_FROM_KINTO = "Rejected from Remote Settings" LIVE = "Experiment is live" COMPLETED = "Experiment is complete" + DISABLED = "Rollout is disabled" RESULTS_UPDATED = "Experiment results updated" MONITORING_DATA_UPDATED = "Experiment monitoring data updated" HOLDBACK_ENROLLMENT_UPDATED = "Holdback enrollment period updated" diff --git a/experimenter/experimenter/experiments/tests/test_models.py b/experimenter/experimenter/experiments/tests/test_models.py index 3518975cb8..1aea684423 100644 --- a/experimenter/experimenter/experiments/tests/test_models.py +++ b/experimenter/experimenter/experiments/tests/test_models.py @@ -321,6 +321,44 @@ def test_update_queue_returns_experiments_that_should_update_by_application(self [experiment_should_update], ) + def test_end_queue_includes_disabling_rollouts(self): + disabling_rollout = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.LIVE, + status_next=NimbusExperiment.Status.DISABLED, + publish_status=NimbusExperiment.PublishStatus.APPROVED, + application=NimbusExperiment.Application.DESKTOP, + is_rollout=True, + ) + + self.assertEqual( + list( + NimbusExperiment.objects.end_queue( + [NimbusExperiment.Application.DESKTOP], + settings.KINTO_COLLECTION_NIMBUS_DESKTOP, + ) + ), + [disabling_rollout], + ) + + def test_launch_queue_includes_reenabling_rollouts(self): + reenabling_rollout = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.DISABLED, + status_next=NimbusExperiment.Status.LIVE, + publish_status=NimbusExperiment.PublishStatus.APPROVED, + application=NimbusExperiment.Application.DESKTOP, + is_rollout=True, + ) + + self.assertEqual( + list( + NimbusExperiment.objects.launch_queue( + [NimbusExperiment.Application.DESKTOP], + settings.KINTO_COLLECTION_NIMBUS_DESKTOP, + ) + ), + [reenabling_rollout], + ) + def test_update_queue_filters_by_collection(self): test_feature = NimbusFeatureConfigFactory.create( slug="test-feature", @@ -515,6 +553,44 @@ def test_waiting_to_update_only_returns_updating_experiments(self): [pausing], ) + def test_waiting_to_end_includes_disabling_rollouts(self): + disabling_rollout = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.LIVE, + status_next=NimbusExperiment.Status.DISABLED, + publish_status=NimbusExperiment.PublishStatus.WAITING, + application=NimbusExperiment.Application.DESKTOP, + is_rollout=True, + ) + + self.assertEqual( + list( + NimbusExperiment.objects.waiting_to_end_queue( + [NimbusExperiment.Application.DESKTOP], + settings.KINTO_COLLECTION_NIMBUS_DESKTOP, + ) + ), + [disabling_rollout], + ) + + def test_waiting_to_launch_includes_reenabling_rollouts(self): + reenabling_rollout = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.DISABLED, + status_next=NimbusExperiment.Status.LIVE, + publish_status=NimbusExperiment.PublishStatus.WAITING, + application=NimbusExperiment.Application.DESKTOP, + is_rollout=True, + ) + + self.assertEqual( + list( + NimbusExperiment.objects.waiting_to_launch_queue( + [NimbusExperiment.Application.DESKTOP], + settings.KINTO_COLLECTION_NIMBUS_DESKTOP, + ) + ), + [reenabling_rollout], + ) + def test_waiting_to_update_filters_by_collection(self): test_feature = NimbusFeatureConfigFactory.create( slug="test-feature", @@ -6980,9 +7056,46 @@ def test_advance_from_no_phase_starts_first_phase(self): self.assertEqual(self.experiment.rollout_phase, self.phases[0]) self.assertIsNone(self.experiment.rollout_phase_next) + self.assertEqual( + self.experiment.population_percent, self.phases[0].population_percent + ) self.assertEqual(self.phases[0].actual_start_date, today) self.assertIsNone(self.phases[0].start_date) + def test_stage_advance_updates_proposed_population_without_advancing_phase(self): + self.experiment.rollout_phase = self.phases[0] + self.experiment.population_percent = self.phases[0].population_percent + self.experiment.save() + + next_phase = self.experiment.stage_rollout_phase_advance() + self.experiment.refresh_from_db() + self.phases[0].refresh_from_db() + self.phases[1].refresh_from_db() + + self.assertEqual(next_phase, self.phases[1]) + self.assertEqual(self.experiment.rollout_phase, self.phases[0]) + self.assertEqual(self.experiment.rollout_phase_next, self.phases[1]) + self.assertEqual( + self.experiment.population_percent, self.phases[1].population_percent + ) + self.assertIsNone(self.phases[0].end_date) + self.assertIsNone(self.phases[1].actual_start_date) + + def test_advance_commits_staged_phase(self): + self.experiment.rollout_phase = self.phases[0] + self.experiment.save() + self.experiment.stage_rollout_phase_advance() + + self.experiment.advance_rollout_phase() + self.experiment.refresh_from_db() + self.phases[0].refresh_from_db() + self.phases[1].refresh_from_db() + + self.assertEqual(self.experiment.rollout_phase, self.phases[1]) + self.assertIsNone(self.experiment.rollout_phase_next) + self.assertEqual(self.phases[0].end_date, timezone.now().date()) + self.assertEqual(self.phases[1].actual_start_date, timezone.now().date()) + def test_advance_stamps_dates_and_moves_pointer(self): today = timezone.now().date() planned_start = datetime.date(2026, 1, 1) @@ -7001,6 +7114,9 @@ def test_advance_stamps_dates_and_moves_pointer(self): self.assertEqual(self.phases[1].start_date, planned_start) self.assertEqual(self.experiment.rollout_phase, self.phases[1]) self.assertIsNone(self.experiment.rollout_phase_next) + self.assertEqual( + self.experiment.population_percent, self.phases[1].population_percent + ) def test_advance_does_not_start_phase_with_zero_population(self): self.phases[1].population_percent = 0 @@ -7063,6 +7179,96 @@ def test_advance_with_no_phases_is_noop(self): self.assertIsNone(experiment.rollout_phase) self.assertIsNone(experiment.rollout_phase_next) + def test_stage_advance_with_no_phases_is_noop(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + + next_phase = experiment.stage_rollout_phase_advance() + + self.assertIsNone(next_phase) + self.assertIsNone(experiment.rollout_phase_next) + + def test_stage_advance_reuses_already_staged_phase(self): + self.experiment.rollout_phase = self.phases[0] + self.experiment.rollout_phase_next = self.phases[2] + self.experiment.save() + + next_phase = self.experiment.stage_rollout_phase_advance() + + self.assertEqual(next_phase, self.phases[2]) + self.assertEqual(self.experiment.rollout_phase_next, self.phases[2]) + self.assertEqual( + self.experiment.population_percent, self.phases[2].population_percent + ) + + def test_stage_advance_past_last_phase_without_copy_is_noop(self): + self.experiment.rollout_phase = self.phases[2] + self.experiment.save() + + next_phase = self.experiment.stage_rollout_phase_advance() + + self.assertIsNone(next_phase) + self.assertIsNone(self.experiment.rollout_phase_next) + + def test_stage_advance_does_not_stage_zero_population_phase(self): + self.phases[1].population_percent = 0 + self.phases[1].save() + self.experiment.rollout_phase = self.phases[0] + self.experiment.save() + + next_phase = self.experiment.stage_rollout_phase_advance() + + self.assertIsNone(next_phase) + self.assertIsNone(self.experiment.rollout_phase_next) + + def test_advance_does_not_overwrite_completed_current_phase_end_date(self): + previous_end_date = timezone.now().date() - datetime.timedelta(days=1) + self.phases[0].end_date = previous_end_date + self.phases[0].save() + self.experiment.rollout_phase = self.phases[0] + self.experiment.rollout_phase_next = self.phases[1] + self.experiment.save() + + self.experiment.advance_rollout_phase() + self.phases[0].refresh_from_db() + + self.assertEqual(self.phases[0].end_date, previous_end_date) + + def test_end_current_rollout_phase_is_noop_without_current_phase(self): + self.experiment.rollout_phase = None + self.experiment.save() + + self.experiment.end_current_rollout_phase() + + self.assertIsNone(self.experiment.rollout_phase) + + def test_end_current_rollout_phase_is_noop_when_already_ended(self): + previous_end_date = timezone.now().date() - datetime.timedelta(days=1) + self.phases[0].end_date = previous_end_date + self.phases[0].save() + self.experiment.rollout_phase = self.phases[0] + self.experiment.save() + + self.experiment.end_current_rollout_phase() + self.phases[0].refresh_from_db() + + self.assertEqual(self.phases[0].end_date, previous_end_date) + + def test_end_current_rollout_phase_sets_dates(self): + actual_start_date = timezone.now().date() - datetime.timedelta(days=7) + self.phases[0].actual_start_date = actual_start_date + self.phases[0].save() + self.experiment.rollout_phase = self.phases[0] + self.experiment.save() + + self.experiment.end_current_rollout_phase() + self.phases[0].refresh_from_db() + + self.assertEqual(self.phases[0].start_date, actual_start_date) + self.assertEqual(self.phases[0].end_date, timezone.now().date()) + def test_clone_resets_phase_pointers(self): self.experiment.rollout_phase = self.phases[1] self.experiment.rollout_phase_next = self.phases[2] diff --git a/experimenter/experimenter/kinto/tasks.py b/experimenter/experimenter/kinto/tasks.py index a1ff30b7da..1fadd0f61a 100644 --- a/experimenter/experimenter/kinto/tasks.py +++ b/experimenter/experimenter/kinto/tasks.py @@ -203,6 +203,8 @@ def handle_launching_experiments(applications, records, collection): published_record.pop("last_modified") with transaction.atomic(): + if experiment.is_rollout and experiment.rollout_phase_next_id: + experiment.advance_rollout_phase() experiment.status = NimbusExperiment.Status.LIVE experiment.status_next = None experiment.publish_status = NimbusExperiment.PublishStatus.IDLE @@ -246,6 +248,11 @@ def handle_updating_experiments(applications, records, collection): if published_record != stored_record: logger.info(f"{experiment} is updated in Kinto".format(experiment=experiment)) with transaction.atomic(): + next_status = experiment.status_next + if experiment.is_rollout and experiment.rollout_phase_next_id: + experiment.advance_rollout_phase() + if next_status == NimbusExperiment.Status.LIVE: + experiment.status = next_status experiment.publish_status = NimbusExperiment.PublishStatus.IDLE experiment.status_next = None experiment.published_dto = published_record @@ -289,38 +296,54 @@ def handle_ending_experiments(applications, records, collection): applications, collection ): if experiment.slug not in records: - logger.info( - f"{experiment.slug} status is being updated to complete".format( - experiment=experiment - ) + is_disabling_rollout = ( + experiment.is_rollout + and experiment.status_next == NimbusExperiment.Status.DISABLED ) + next_status = ( + NimbusExperiment.Status.DISABLED + if is_disabling_rollout + else NimbusExperiment.Status.COMPLETE + ) + logger.info(f"{experiment.slug} status is being updated to {next_status}") with transaction.atomic(): - experiment.status = NimbusExperiment.Status.COMPLETE + experiment.status = next_status experiment.status_next = None experiment.publish_status = NimbusExperiment.PublishStatus.IDLE experiment.is_rollout_dirty = False experiment.save() + if is_disabling_rollout: + experiment.end_current_rollout_phase() + generate_nimbus_changelog( experiment, get_kinto_user(), - message=NimbusChangeLog.Messages.COMPLETED, + message=( + NimbusChangeLog.Messages.DISABLED + if is_disabling_rollout + else NimbusChangeLog.Messages.COMPLETED + ), ) - experiment.update_computed_end_date() + if not is_disabling_rollout: + experiment.update_computed_end_date() - _send_slack_alert_success_message( - experiment, - NimbusConstants.AlertType.END_EXPERIMENT_REQUEST, - SlackConstants.SLACK_EXPERIMENT_ENDED_MESSAGE, - SlackConstants.SLACK_OPERATION_EXPERIMENT_ENDING, - lambda slug: SlackConstants.SLACK_LOG_EXPERIMENT_ENDING_SENT.format( - experiment=slug - ), - ) + if not is_disabling_rollout: + _send_slack_alert_success_message( + experiment, + NimbusConstants.AlertType.END_EXPERIMENT_REQUEST, + SlackConstants.SLACK_EXPERIMENT_ENDED_MESSAGE, + SlackConstants.SLACK_OPERATION_EXPERIMENT_ENDING, + lambda slug: SlackConstants.SLACK_LOG_EXPERIMENT_ENDING_SENT.format( + experiment=slug + ), + ) - logger.info(f"{experiment.slug} ended") + logger.info( + f"{experiment.slug} {'disabled' if is_disabling_rollout else 'ended'}" + ) def handle_waiting_experiments(applications, records, collection): diff --git a/experimenter/experimenter/kinto/tests/test_tasks.py b/experimenter/experimenter/kinto/tests/test_tasks.py index d57e719fa0..3c43d95428 100644 --- a/experimenter/experimenter/kinto/tests/test_tasks.py +++ b/experimenter/experimenter/kinto/tests/test_tasks.py @@ -19,6 +19,7 @@ from experimenter.experiments.tests.factories import ( NimbusExperimentFactory, NimbusFeatureConfigFactory, + NimbusRolloutPhaseFactory, ) from experimenter.kinto import tasks from experimenter.kinto.client import KINTO_REVIEW_STATUS, KINTO_ROLLBACK_STATUS @@ -1109,9 +1110,37 @@ def test_launching_experiment_live_when_record_is_in_main( self.assertEqual( launching_experiment.computed_end_date, launching_experiment.proposed_end_date ) - self.assertEqual( - launching_experiment.computed_end_date, launching_experiment.proposed_end_date + + def test_remote_settings_launch_approval_commits_staged_rollout_phase(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LAUNCH_APPROVE_WAITING, + application=NimbusExperiment.Application.DESKTOP, + is_rollout=True, ) + phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=25 + ) + experiment.stage_rollout_phase_advance() + + self.assertIsNone(experiment.rollout_phase) + self.assertEqual(experiment.rollout_phase_next, phase) + + tasks.handle_launching_experiments( + [NimbusExperiment.Application.DESKTOP], + { + experiment.slug: { + "id": experiment.slug, + "last_modified": 1, + } + }, + experiment.kinto_collection, + ) + + experiment.refresh_from_db() + phase.refresh_from_db() + self.assertEqual(experiment.rollout_phase, phase) + self.assertIsNone(experiment.rollout_phase_next) + self.assertEqual(phase.actual_start_date, timezone.now().date()) @mock.patch("experimenter.kinto.tasks.send_threaded_success_message") def test_launching_experiment_live_handles_slack_notification_error( @@ -1190,6 +1219,114 @@ def test_updating_experiment_handles_slack_notification_error( ) self.assertIn("updated_field", updating_experiment.published_dto) + def test_remote_settings_update_approval_commits_staged_rollout_phase(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_APPROVE_WAITING, + application=NimbusExperiment.Application.DESKTOP, + is_rollout=True, + published_dto={"id": "previous-record"}, + ) + current_phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=10 + ) + next_phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=25 + ) + experiment.rollout_phase = current_phase + experiment.population_percent = current_phase.population_percent + experiment.save() + experiment.stage_rollout_phase_advance() + + self.assertEqual(experiment.rollout_phase, current_phase) + self.assertEqual(experiment.rollout_phase_next, next_phase) + + tasks.handle_updating_experiments( + [NimbusExperiment.Application.DESKTOP], + { + experiment.slug: { + "id": experiment.slug, + "last_modified": 1, + } + }, + experiment.kinto_collection, + ) + + experiment.refresh_from_db() + current_phase.refresh_from_db() + next_phase.refresh_from_db() + self.assertEqual(experiment.rollout_phase, next_phase) + self.assertIsNone(experiment.rollout_phase_next) + self.assertEqual(current_phase.end_date, timezone.now().date()) + self.assertEqual(next_phase.actual_start_date, timezone.now().date()) + + def test_remote_settings_disable_approval_disables_and_ends_current_phase(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.LIVE, + status_next=NimbusExperiment.Status.DISABLED, + publish_status=NimbusExperiment.PublishStatus.WAITING, + application=NimbusExperiment.Application.DESKTOP, + is_rollout=True, + published_dto={"id": "previous-record"}, + ) + current_phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, + population_percent=25, + actual_start_date=timezone.now().date(), + ) + experiment.rollout_phase = current_phase + experiment.save() + + tasks.handle_ending_experiments( + [NimbusExperiment.Application.DESKTOP], + {}, + experiment.kinto_collection, + ) + + experiment.refresh_from_db() + current_phase.refresh_from_db() + self.assertEqual(experiment.status, NimbusExperiment.Status.DISABLED) + self.assertIsNone(experiment.status_next) + self.assertEqual(experiment.publish_status, NimbusExperiment.PublishStatus.IDLE) + self.assertEqual(current_phase.end_date, timezone.now().date()) + + def test_remote_settings_reenable_approval_advances_phase_and_sets_live(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.DISABLED, + status_next=NimbusExperiment.Status.LIVE, + publish_status=NimbusExperiment.PublishStatus.WAITING, + application=NimbusExperiment.Application.DESKTOP, + is_rollout=True, + published_dto={"id": "previous-record"}, + ) + current_phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=10 + ) + next_phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=25 + ) + experiment.rollout_phase = current_phase + experiment.rollout_phase_next = next_phase + experiment.save() + + tasks.handle_launching_experiments( + [NimbusExperiment.Application.DESKTOP], + { + experiment.slug: { + "id": experiment.slug, + "last_modified": 1, + } + }, + experiment.kinto_collection, + ) + + experiment.refresh_from_db() + next_phase.refresh_from_db() + self.assertEqual(experiment.status, NimbusExperiment.Status.LIVE) + self.assertIsNone(experiment.status_next) + self.assertEqual(experiment.rollout_phase, next_phase) + self.assertIsNone(experiment.rollout_phase_next) + self.assertEqual(next_phase.actual_start_date, timezone.now().date()) + @mock.patch("experimenter.kinto.tasks.send_threaded_success_message") def test_updating_paused_experiment_sends_enrollment_ended_notification( self, mock_send_message diff --git a/experimenter/experimenter/nimbus_ui/constants.py b/experimenter/experimenter/nimbus_ui/constants.py index 453aed3748..083b6e93c7 100644 --- a/experimenter/experimenter/nimbus_ui/constants.py +++ b/experimenter/experimenter/nimbus_ui/constants.py @@ -26,6 +26,9 @@ class NimbusUIConstants: "Cannot perform this action: experiment must be in state {required_state}, " "but is currently in state {current_state}." ) + ERROR_INVALID_PAUSED_TRANSITION = ( + "Cannot perform this action: only rollouts may be paused." + ) RISK_MESSAGE_URL = "https://mozilla-hub.atlassian.net/wiki/spaces/FIREFOX/pages/208308555/Message+Consult+Creation" REVIEW_URL = "https://experimenter.info/getting-started/for-reviewers" diff --git a/experimenter/experimenter/nimbus_ui/new/forms.py b/experimenter/experimenter/nimbus_ui/new/forms.py index 73a28d50f8..290f4d1413 100644 --- a/experimenter/experimenter/nimbus_ui/new/forms.py +++ b/experimenter/experimenter/nimbus_ui/new/forms.py @@ -1,4 +1,5 @@ from collections import defaultdict +from datetime import UTC, datetime from decimal import Decimal import markus @@ -13,6 +14,7 @@ from experimenter.base.models import Country, Language, Locale from experimenter.experiments.changelog_utils import generate_nimbus_changelog +from experimenter.experiments.constants import NimbusConstants from experimenter.experiments.models import ( NimbusBranch, NimbusBranchFeatureValue, @@ -25,6 +27,10 @@ NimbusRolloutPlanTemplate, Tag, ) +from experimenter.kinto.tasks import ( + nimbus_check_kinto_push_queue_by_collection, + nimbus_synchronize_preview_experiments_in_kinto, +) from experimenter.nimbus_ui.constants import NimbusUIConstants from experimenter.targeting.constants import NimbusTargetingConfig @@ -875,6 +881,412 @@ def get_changelog_message(self): return f"{self.request.user} updated collaborators" +class UpdateStatusForm(NimbusChangeLogFormMixin, forms.ModelForm): + status = None + status_next = None + publish_status = None + is_paused = None + + required_status = None + required_status_next = None + required_publish_status = None + required_is_paused = None + + class Meta: + model = NimbusExperiment + fields = [] + + def clean(self): + cleaned_data = super().clean() + + required_state = ( + self.required_status, + self.required_status_next, + self.required_publish_status, + self.required_is_paused, + ) + current_state = ( + self.instance.status, + self.instance.status_next, + self.instance.publish_status, + self.instance.is_paused, + ) + + state_mismatch = ( + self.required_status != self.instance.status + or self.required_status_next != self.instance.status_next + or self.required_publish_status != self.instance.publish_status + or ( + self.required_is_paused is not None + and self.required_is_paused != self.instance.is_paused + ) + ) + + if state_mismatch: + raise forms.ValidationError( + NimbusUIConstants.ERROR_INVALID_STATE_TRANSITION.format( + required_state=required_state, + current_state=current_state, + ) + ) + + if not self.instance.is_rollout and NimbusConstants.Status.DISABLED in ( + *required_state, + *current_state, + self.status, + self.status_next, + ): + raise forms.ValidationError(NimbusUIConstants.ERROR_INVALID_PAUSED_TRANSITION) + + return cleaned_data + + @transaction.atomic + def save(self, commit=True): + self.instance.status = self.status + self.instance.status_next = self.status_next + previous_publish_status = self.instance.publish_status + self.instance.publish_status = self.publish_status + + if self.status == NimbusExperiment.Status.DRAFT: + self.instance.published_dto = None + + if ( + previous_publish_status == NimbusExperiment.PublishStatus.REVIEW + and self.publish_status != NimbusExperiment.PublishStatus.REVIEW + ): + last_review_request = self.instance.changes.latest_review_request() + if last_review_request is not None: + delta = datetime.now(UTC) - last_review_request.changed_on + delta_ms = int(delta.total_seconds() * 1000) + metrics.timing( + "review_timing", + value=delta_ms, + tags=[f"status:{self.publish_status}"], + ) + + return super().save(commit=commit) + + +# Draft to Live transitions + + +class DraftReviewRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.DRAFT + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + + status = NimbusExperiment.Status.DRAFT + status_next = NimbusExperiment.Status.LIVE + publish_status = NimbusExperiment.PublishStatus.REVIEW + + def get_changelog_message(self): + return f"{self.request.user} requested rollout launch without Preview" + + +class DraftReviewApproveRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.DRAFT + required_status_next = NimbusExperiment.Status.LIVE + required_publish_status = NimbusExperiment.PublishStatus.REVIEW + + status = NimbusExperiment.Status.DRAFT + status_next = NimbusExperiment.Status.LIVE + publish_status = NimbusExperiment.PublishStatus.APPROVED + + def get_changelog_message(self): + return f"{self.request.user} approved the review." + + @transaction.atomic + def save(self, commit=True): + experiment = super().save(commit=commit) + experiment.stage_rollout_phase_advance() + experiment.allocate_bucket_range() + nimbus_check_kinto_push_queue_by_collection.apply_async( + countdown=5, args=[experiment.kinto_collection] + ) + + return experiment + + +class DraftReviewRejectForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.DRAFT + required_status_next = NimbusExperiment.Status.LIVE + required_publish_status = NimbusExperiment.PublishStatus.REVIEW + + status = NimbusExperiment.Status.DRAFT + status_next = None + publish_status = NimbusExperiment.PublishStatus.IDLE + + changelog_message = forms.CharField( + required=False, label="Changelog Message", max_length=1000 + ) + + cancel_message = forms.CharField( + required=False, label="Cancel Message", max_length=1000 + ) + + def get_changelog_message(self): + if self.cleaned_data.get("changelog_message"): + return ( + f"{self.request.user} rejected the review with reason: " + f"{self.cleaned_data['changelog_message']}" + ) + return f"{self.request.user} {self.cleaned_data['cancel_message']}" + + +# Preview to Live transitions + + +class PreviewReviewRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.PREVIEW + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + + status = NimbusExperiment.Status.DRAFT + status_next = NimbusExperiment.Status.LIVE + publish_status = NimbusExperiment.PublishStatus.REVIEW + + def get_changelog_message(self): + return f"{self.request.user} requested rollout launch from Preview" + + +# Preview to Draft transitions + + +class DraftToPreviewRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.DRAFT + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + + status = NimbusExperiment.Status.PREVIEW + status_next = None + publish_status = NimbusExperiment.PublishStatus.IDLE + + def get_changelog_message(self): + return f"{self.request.user} launched rollout to Preview" + + @transaction.atomic + def save(self, commit=True): + experiment = super().save(commit=commit) + experiment.allocate_bucket_range() + nimbus_synchronize_preview_experiments_in_kinto.apply_async(countdown=5) + return experiment + + +class PreviewToDraftRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.PREVIEW + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + + status = NimbusExperiment.Status.DRAFT + status_next = None + publish_status = NimbusExperiment.PublishStatus.IDLE + + def get_changelog_message(self): + return f"{self.request.user} moved the rollout back to Draft" + + @transaction.atomic + def save(self, commit=True): + experiment = super().save(commit=commit) + nimbus_synchronize_preview_experiments_in_kinto.apply_async(countdown=5) + return experiment + + +# Phase advance transitions + + +class AdvancePhaseReviewRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.LIVE + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + + status = NimbusExperiment.Status.LIVE + status_next = NimbusExperiment.Status.LIVE + publish_status = NimbusExperiment.PublishStatus.REVIEW + + def get_changelog_message(self): + return f"{self.request.user} requested review to advance rollout phase" + + +class AdvancePhaseReviewApproveRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.LIVE + required_status_next = NimbusExperiment.Status.LIVE + required_publish_status = NimbusExperiment.PublishStatus.REVIEW + required_is_paused = False + + status = NimbusExperiment.Status.LIVE + status_next = NimbusExperiment.Status.LIVE + publish_status = NimbusExperiment.PublishStatus.APPROVED + + def get_changelog_message(self): + return f"{self.request.user} approved the advance rollout phase review request" + + @transaction.atomic + def save(self, commit=True): + experiment = super().save(commit=commit) + experiment.stage_rollout_phase_advance() + experiment.allocate_bucket_range() + nimbus_check_kinto_push_queue_by_collection.apply_async( + countdown=5, args=[experiment.kinto_collection] + ) + + return experiment + + +class AdvancePhaseReviewRejectRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.LIVE + required_status_next = NimbusExperiment.Status.LIVE + required_publish_status = NimbusExperiment.PublishStatus.REVIEW + + status = NimbusExperiment.Status.LIVE + status_next = None + publish_status = NimbusExperiment.PublishStatus.IDLE + + changelog_message = forms.CharField( + required=False, label="Changelog Message", max_length=1000 + ) + + cancel_message = forms.CharField( + required=False, label="Cancel Message", max_length=1000 + ) + + def get_changelog_message(self): + if self.cleaned_data.get("changelog_message"): + return ( + f"{self.request.user} rejected the review with reason: " + f"{self.cleaned_data['changelog_message']}" + ) + return f"{self.request.user} {self.cleaned_data['cancel_message']}" + + +# Live to disabled transitions + + +class LiveToDisabledReviewRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.LIVE + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + + status = NimbusExperiment.Status.LIVE + status_next = NimbusExperiment.Status.DISABLED + publish_status = NimbusExperiment.PublishStatus.REVIEW + + def get_changelog_message(self): + return f"{self.request.user} requested review to disable rollout" + + +class LiveToDisabledReviewApproveRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.LIVE + required_status_next = NimbusExperiment.Status.DISABLED + required_publish_status = NimbusExperiment.PublishStatus.REVIEW + required_is_paused = False + + status = NimbusExperiment.Status.LIVE + status_next = NimbusExperiment.Status.DISABLED + publish_status = NimbusExperiment.PublishStatus.APPROVED + + def get_changelog_message(self): + return f"{self.request.user} approved the disable rollout review request" + + @transaction.atomic + def save(self, commit=True): + experiment = super().save(commit=commit) + nimbus_check_kinto_push_queue_by_collection.apply_async( + countdown=5, args=[experiment.kinto_collection] + ) + + return experiment + + +class LiveToDisabledReviewRejectRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.LIVE + required_status_next = NimbusExperiment.Status.DISABLED + required_publish_status = NimbusExperiment.PublishStatus.REVIEW + + status = NimbusExperiment.Status.LIVE + status_next = None + publish_status = NimbusExperiment.PublishStatus.IDLE + + changelog_message = forms.CharField( + required=False, label="Changelog Message", max_length=1000 + ) + + cancel_message = forms.CharField( + required=False, label="Cancel Message", max_length=1000 + ) + + def get_changelog_message(self): + if self.cleaned_data.get("changelog_message"): + return ( + f"{self.request.user} rejected the review with reason: " + f"{self.cleaned_data['changelog_message']}" + ) + return f"{self.request.user} {self.cleaned_data['cancel_message']}" + + +# Disabled to Live transitions + + +class DisabledToLiveReviewRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.DISABLED + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + + status = NimbusExperiment.Status.DISABLED + status_next = NimbusExperiment.Status.LIVE + publish_status = NimbusExperiment.PublishStatus.REVIEW + + def get_changelog_message(self): + return f"{self.request.user} requested review to re-enable rollout" + + +class DisabledToLiveReviewApproveRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.DISABLED + required_status_next = NimbusExperiment.Status.LIVE + required_publish_status = NimbusExperiment.PublishStatus.REVIEW + + status = NimbusExperiment.Status.DISABLED + status_next = NimbusExperiment.Status.LIVE + publish_status = NimbusExperiment.PublishStatus.APPROVED + + def get_changelog_message(self): + return f"{self.request.user} approved the re-enable rollout review request" + + @transaction.atomic + def save(self, commit=True): + experiment = super().save(commit=commit) + experiment.stage_rollout_phase_advance(copy_current_if_missing=True) + experiment.allocate_bucket_range() + nimbus_check_kinto_push_queue_by_collection.apply_async( + countdown=5, args=[experiment.kinto_collection] + ) + return experiment + + +class DisabledToLiveReviewRejectRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.DISABLED + required_status_next = NimbusExperiment.Status.LIVE + required_publish_status = NimbusExperiment.PublishStatus.REVIEW + + status = NimbusExperiment.Status.DISABLED + status_next = None + publish_status = NimbusExperiment.PublishStatus.IDLE + + changelog_message = forms.CharField( + required=False, label="Changelog Message", max_length=1000 + ) + cancel_message = forms.CharField( + required=False, label="Cancel Message", max_length=1000 + ) + + def get_changelog_message(self): + if self.cleaned_data.get("changelog_message"): + return ( + f"{self.request.user} rejected the review with reason: " + f"{self.cleaned_data['changelog_message']}" + ) + return f"{self.request.user} {self.cleaned_data['cancel_message']}" + + class RolloutPhaseForm(forms.ModelForm): start_date = forms.DateField( required=False, diff --git a/experimenter/experimenter/nimbus_ui/new/views.py b/experimenter/experimenter/nimbus_ui/new/views.py index fbeae8f0f5..2a63c0a7e5 100644 --- a/experimenter/experimenter/nimbus_ui/new/views.py +++ b/experimenter/experimenter/nimbus_ui/new/views.py @@ -1,20 +1,37 @@ from django import forms +from django.conf import settings from django.http import HttpResponse, HttpResponseRedirect from django.urls import reverse from django.views.generic import DetailView from django.views.generic.edit import UpdateView +from experimenter.experiments.constants import EXTERNAL_URLS, RISK_QUESTIONS from experimenter.experiments.models import NimbusExperiment, Tag from experimenter.nimbus_ui.filtersets import ( TagSearchFilterSet, UserSearchFilterSet, ) from experimenter.nimbus_ui.new.forms import ( + AdvancePhaseReviewApproveRolloutForm, + AdvancePhaseReviewRejectRolloutForm, + AdvancePhaseReviewRolloutForm, CollaboratorsForm, + DisabledToLiveReviewApproveRolloutForm, + DisabledToLiveReviewRejectRolloutForm, + DisabledToLiveReviewRolloutForm, DocumentationLinkCreateForm, DocumentationLinkDeleteForm, + DraftReviewApproveRolloutForm, + DraftReviewRejectForm, + DraftReviewRolloutForm, + DraftToPreviewRolloutForm, + LiveToDisabledReviewApproveRolloutForm, + LiveToDisabledReviewRejectRolloutForm, + LiveToDisabledReviewRolloutForm, NimbusExperimentCreateForm, NimbusExperimentSidebarCloneForm, + PreviewReviewRolloutForm, + PreviewToDraftRolloutForm, RolloutAudienceForm, RolloutFeaturesForm, RolloutOverviewForm, @@ -46,6 +63,12 @@ def get_form_kwargs(self): return kwargs +class RenderResponseMixin: + def form_valid(self, form): + super().form_valid(form) + return self.render_to_response(self.get_context_data(form=form)) + + class NimbusExperimentViewMixin: model = NimbusExperiment context_object_name = "experiment" @@ -65,6 +88,63 @@ def get_context_data(self, **kwargs): return context +def build_experiment_context(experiment): + outcome_doc_base_url = "https://mozilla.github.io/metric-hub/outcomes/" + primary_outcome_links = [ + ( + outcome, + f"{outcome_doc_base_url}{experiment.application.replace('-', '_')}/{outcome}", + ) + for outcome in experiment.primary_outcomes + ] + secondary_outcome_links = [ + ( + outcome, + f"{outcome_doc_base_url}{experiment.application.replace('-', '_')}/{outcome}", + ) + for outcome in experiment.secondary_outcomes + ] + + segment_doc_base_url = "https://mozilla.github.io/metric-hub/segments/" + segment_links = [ + ( + segment, + # ruff prefers this implicit syntax for concatenating strings + f"{segment_doc_base_url}" + f"{experiment.application.replace('-', '_')}/" + f"#{segment}", + ) + for segment in experiment.segments + ] + context = { + "RISK_QUESTIONS": RISK_QUESTIONS, + "EXTERNAL_URLS": EXTERNAL_URLS, + "primary_outcome_links": primary_outcome_links, + "secondary_outcome_links": secondary_outcome_links, + "segment_links": segment_links, + "uses_secure_collection": ( + experiment.kinto_collection == settings.KINTO_COLLECTION_NIMBUS_SECURE + ), + } + return context + + +class NimbusExperimentDetailView( + NimbusExperimentViewMixin, + CloneExperimentFormMixin, + UpdateView, +): + template_name = "nimbus_experiments/detail.html" + fields = [] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + experiment_context = build_experiment_context(self.object) + context.update(experiment_context) + + return context + + class RenderParentDBResponseMixin: def form_valid(self, form): super().form_valid(form) @@ -305,6 +385,88 @@ class NewRemoveSubscriberView(NewSubscriberView): add = False +class StatusUpdateView(RequestFormMixin, RenderResponseMixin, NimbusExperimentDetailView): + fields = None + + def get_template_names(self): + if self.request.headers.get("HX-Request"): + fragment = self.request.GET.get("fragment") or self.request.POST.get( + "fragment" + ) + + if fragment == "progress_card": + return ["nimbus_experiments/launch_controls_v2.html"] + + return [self.template_name] + + def get_context_data(self, *, form=None, **kwargs): + context = super().get_context_data(form=form, **kwargs) + if self.request.method in ("POST", "PUT") and form and not form.is_valid(): + context["update_status_form_errors"] = form.errors["__all__"] + + return context + + +class DraftToPreviewRolloutView(StatusUpdateView): + form_class = DraftToPreviewRolloutForm + + +class DraftReviewRolloutView(StatusUpdateView): + form_class = DraftReviewRolloutForm + + +class DraftReviewApproveRolloutView(StatusUpdateView): + form_class = DraftReviewApproveRolloutForm + + +class DraftReviewRejectView(StatusUpdateView): + form_class = DraftReviewRejectForm + + +class PreviewReviewRolloutView(StatusUpdateView): + form_class = PreviewReviewRolloutForm + + +class PreviewToDraftRolloutView(StatusUpdateView): + form_class = PreviewToDraftRolloutForm + + +class AdvancePhaseReviewRolloutView(StatusUpdateView): + form_class = AdvancePhaseReviewRolloutForm + + +class AdvancePhaseReviewApproveRolloutView(StatusUpdateView): + form_class = AdvancePhaseReviewApproveRolloutForm + + +class AdvancePhaseReviewRejectRolloutView(StatusUpdateView): + form_class = AdvancePhaseReviewRejectRolloutForm + + +class LiveToDisabledReviewRolloutView(StatusUpdateView): + form_class = LiveToDisabledReviewRolloutForm + + +class LiveToDisabledReviewApproveRolloutView(StatusUpdateView): + form_class = LiveToDisabledReviewApproveRolloutForm + + +class LiveToDisabledReviewRejectRolloutView(StatusUpdateView): + form_class = LiveToDisabledReviewRejectRolloutForm + + +class DisabledToLiveReviewRolloutView(StatusUpdateView): + form_class = DisabledToLiveReviewRolloutForm + + +class DisabledToLiveReviewApproveRolloutView(StatusUpdateView): + form_class = DisabledToLiveReviewApproveRolloutForm + + +class DisabledToLiveReviewRejectRolloutView(StatusUpdateView): + form_class = DisabledToLiveReviewRejectRolloutForm + + class NewRolloutScheduleUpdateView(NewCardUpdateView): form_class = RolloutScheduleForm display_template = "new/rollouts/schedule/card.html" diff --git a/experimenter/experimenter/nimbus_ui/tests/test_new_forms.py b/experimenter/experimenter/nimbus_ui/tests/test_new_forms.py index 49cb2b440c..61039fb609 100644 --- a/experimenter/experimenter/nimbus_ui/tests/test_new_forms.py +++ b/experimenter/experimenter/nimbus_ui/tests/test_new_forms.py @@ -1,9 +1,11 @@ import datetime import json +from unittest.mock import patch from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone +from parameterized import parameterized from experimenter.base.tests.factories import ( CountryFactory, @@ -20,17 +22,33 @@ NimbusDocumentationLinkFactory, NimbusExperimentFactory, NimbusFeatureConfigFactory, + NimbusRolloutPhaseFactory, NimbusVersionedSchemaFactory, TagFactory, ) from experimenter.nimbus_ui.constants import NimbusUIConstants from experimenter.nimbus_ui.new.forms import ( + AdvancePhaseReviewApproveRolloutForm, + AdvancePhaseReviewRejectRolloutForm, + AdvancePhaseReviewRolloutForm, CollaboratorsForm, + DisabledToLiveReviewApproveRolloutForm, + DisabledToLiveReviewRejectRolloutForm, + DisabledToLiveReviewRolloutForm, DocumentationLinkCreateForm, DocumentationLinkDeleteForm, + DraftReviewApproveRolloutForm, + DraftReviewRejectForm, + DraftReviewRolloutForm, + DraftToPreviewRolloutForm, + LiveToDisabledReviewApproveRolloutForm, + LiveToDisabledReviewRejectRolloutForm, + LiveToDisabledReviewRolloutForm, NimbusBranchFeatureValueForm, NimbusExperimentCreateForm, NimbusExperimentSidebarCloneForm, + PreviewReviewRolloutForm, + PreviewToDraftRolloutForm, RolloutAudienceForm, RolloutFeaturesForm, RolloutOverviewForm, @@ -993,6 +1011,570 @@ def test_form_queryset_ordered_by_name(self): self.assertEqual(tag_names, ["A Tag", "M Tag", "Z Tag"]) +class TestRolloutStatusForms(RequestFormTestCase): + def setUp(self): + super().setUp() + self.mock_preview_task = patch( + "experimenter.nimbus_ui.new.forms." + "nimbus_synchronize_preview_experiments_in_kinto.apply_async" + ).start() + self.mock_allocate_bucket_range = patch( + "experimenter.experiments.models.NimbusExperiment.allocate_bucket_range" + ).start() + self.mock_kinto_push_queue = patch( + "experimenter.nimbus_ui.new.forms." + "nimbus_check_kinto_push_queue_by_collection.apply_async" + ).start() + self.addCleanup(self.mock_preview_task.stop) + self.addCleanup(self.mock_allocate_bucket_range.stop) + self.addCleanup(self.mock_kinto_push_queue.stop) + + @parameterized.expand( + [ + # Draft -> Preview. + ( + DraftToPreviewRolloutForm, + NimbusExperiment.Status.DRAFT, + None, + NimbusExperiment.PublishStatus.IDLE, + NimbusExperiment.Status.PREVIEW, + None, + NimbusExperiment.PublishStatus.IDLE, + "launched rollout to Preview", + ), + # Preview -> Draft. + ( + PreviewToDraftRolloutForm, + NimbusExperiment.Status.PREVIEW, + None, + NimbusExperiment.PublishStatus.IDLE, + NimbusExperiment.Status.DRAFT, + None, + NimbusExperiment.PublishStatus.IDLE, + "moved the rollout back to Draft", + ), + # Draft -> launch review. + ( + DraftReviewRolloutForm, + NimbusExperiment.Status.DRAFT, + None, + NimbusExperiment.PublishStatus.IDLE, + NimbusExperiment.Status.DRAFT, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + "requested rollout launch without Preview", + ), + # Approve launch review from Draft. + ( + DraftReviewApproveRolloutForm, + NimbusExperiment.Status.DRAFT, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + NimbusExperiment.Status.DRAFT, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.APPROVED, + "approved the review", + ), + # Reject launch review from Draft. + ( + DraftReviewRejectForm, + NimbusExperiment.Status.DRAFT, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + NimbusExperiment.Status.DRAFT, + None, + NimbusExperiment.PublishStatus.IDLE, + "rejected the review", + ), + # Preview -> launch review. + ( + PreviewReviewRolloutForm, + NimbusExperiment.Status.PREVIEW, + None, + NimbusExperiment.PublishStatus.IDLE, + NimbusExperiment.Status.DRAFT, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + "requested rollout launch from Preview", + ), + # Live -> phase-advance review. + ( + AdvancePhaseReviewRolloutForm, + NimbusExperiment.Status.LIVE, + None, + NimbusExperiment.PublishStatus.IDLE, + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + "requested review to advance rollout phase", + ), + # Approve phase-advance review. + ( + AdvancePhaseReviewApproveRolloutForm, + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.APPROVED, + "approved the advance rollout phase review request", + ), + # Reject phase-advance review. + ( + AdvancePhaseReviewRejectRolloutForm, + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + NimbusExperiment.Status.LIVE, + None, + NimbusExperiment.PublishStatus.IDLE, + "rejected the review", + ), + # Live -> disable review. + ( + LiveToDisabledReviewRolloutForm, + NimbusExperiment.Status.LIVE, + None, + NimbusExperiment.PublishStatus.IDLE, + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.DISABLED, + NimbusExperiment.PublishStatus.REVIEW, + "requested review to disable rollout", + ), + # Approve disable review. + ( + LiveToDisabledReviewApproveRolloutForm, + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.DISABLED, + NimbusExperiment.PublishStatus.REVIEW, + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.DISABLED, + NimbusExperiment.PublishStatus.APPROVED, + "approved the disable rollout review request", + ), + # Reject disable review. + ( + LiveToDisabledReviewRejectRolloutForm, + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.DISABLED, + NimbusExperiment.PublishStatus.REVIEW, + NimbusExperiment.Status.LIVE, + None, + NimbusExperiment.PublishStatus.IDLE, + "rejected the review", + ), + # Disabled -> re-enable review. + ( + DisabledToLiveReviewRolloutForm, + NimbusExperiment.Status.DISABLED, + None, + NimbusExperiment.PublishStatus.IDLE, + NimbusExperiment.Status.DISABLED, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + "requested review to re-enable rollout", + ), + # Approve re-enable review. + ( + DisabledToLiveReviewApproveRolloutForm, + NimbusExperiment.Status.DISABLED, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + NimbusExperiment.Status.DISABLED, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.APPROVED, + "approved the re-enable rollout review request", + ), + # Reject re-enable review. + ( + DisabledToLiveReviewRejectRolloutForm, + NimbusExperiment.Status.DISABLED, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + NimbusExperiment.Status.DISABLED, + None, + NimbusExperiment.PublishStatus.IDLE, + "rejected the review", + ), + ] + ) + def test_valid_transition( + self, + form_class, + initial_status, + initial_status_next, + initial_publish_status, + expected_status, + expected_status_next, + expected_publish_status, + expected_changelog_message, + ): + experiment = NimbusExperimentFactory.create( + status=initial_status, + status_next=initial_status_next, + publish_status=initial_publish_status, + is_paused=False, + is_rollout=True, + ) + form = form_class( + data={"changelog_message": "rejected the review"}, + instance=experiment, + request=self.request, + ) + + self.assertTrue(form.is_valid(), form.errors) + + experiment = form.save() + self.assertEqual(experiment.status, expected_status) + self.assertEqual(experiment.status_next, expected_status_next) + self.assertEqual(experiment.publish_status, expected_publish_status) + + changelog = experiment.changes.latest("changed_on") + self.assertEqual(changelog.changed_by, self.user) + self.assertIn(expected_changelog_message, changelog.message) + + @parameterized.expand( + [ + # Draft -> Preview. + (DraftToPreviewRolloutForm,), + # Preview -> Draft. + (PreviewToDraftRolloutForm,), + # Draft -> launch review. + (DraftReviewRolloutForm,), + # Approve launch review from Draft. + (DraftReviewApproveRolloutForm,), + # Reject launch review from Draft. + (DraftReviewRejectForm,), + # Preview -> launch review. + (PreviewReviewRolloutForm,), + # Live -> phase-advance review. + (AdvancePhaseReviewRolloutForm,), + # Approve phase-advance review. + (AdvancePhaseReviewApproveRolloutForm,), + # Reject phase-advance review. + (AdvancePhaseReviewRejectRolloutForm,), + # Live -> disable review. + (LiveToDisabledReviewRolloutForm,), + # Approve disable review. + (LiveToDisabledReviewApproveRolloutForm,), + # Reject disable review. + (LiveToDisabledReviewRejectRolloutForm,), + # Disabled -> re-enable review. + (DisabledToLiveReviewRolloutForm,), + # Approve re-enable review. + (DisabledToLiveReviewApproveRolloutForm,), + # Reject re-enable review. + (DisabledToLiveReviewRejectRolloutForm,), + ] + ) + def test_invalid_publish_status_rejects_transition(self, form_class): + invalid_publish_status = ( + NimbusExperiment.PublishStatus.REVIEW + if form_class.required_publish_status == NimbusExperiment.PublishStatus.IDLE + else NimbusExperiment.PublishStatus.IDLE + ) + experiment = NimbusExperimentFactory.create( + status=form_class.required_status, + status_next=form_class.required_status_next, + publish_status=invalid_publish_status, + is_paused=False, + is_rollout=True, + ) + form = form_class(data={}, instance=experiment, request=self.request) + + self.assertFalse(form.is_valid()) + self.assertIn( + "Cannot perform this action: experiment must be in state", + form.errors["__all__"][0], + ) + + @parameterized.expand( + [ + # Reject launch review from Draft. + (DraftReviewRejectForm,), + # Reject phase-advance review. + (AdvancePhaseReviewRejectRolloutForm,), + # Reject disable review. + (LiveToDisabledReviewRejectRolloutForm,), + # Reject re-enable review. + (DisabledToLiveReviewRejectRolloutForm,), + ] + ) + def test_reject_transition_uses_cancel_message_when_reason_is_blank(self, form_class): + experiment = NimbusExperimentFactory.create( + status=form_class.required_status, + status_next=form_class.required_status_next, + publish_status=form_class.required_publish_status, + is_rollout=True, + ) + form = form_class( + data={"cancel_message": "cancelled the review"}, + instance=experiment, + request=self.request, + ) + + self.assertTrue(form.is_valid(), form.errors) + + experiment = form.save() + + self.assertIn( + "cancelled the review", + experiment.changes.latest("changed_on").message, + ) + + def test_disabled_transition_is_rejected_for_non_rollout(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.LIVE, + status_next=None, + publish_status=NimbusExperiment.PublishStatus.IDLE, + is_rollout=False, + ) + form = LiveToDisabledReviewRolloutForm( + data={}, instance=experiment, request=self.request + ) + + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors["__all__"], + [NimbusUIConstants.ERROR_INVALID_PAUSED_TRANSITION], + ) + + @parameterized.expand( + [ + # Approve phase advance review. + (AdvancePhaseReviewApproveRolloutForm,), + # Approve disable review. + (LiveToDisabledReviewApproveRolloutForm,), + ] + ) + def test_approval_rejects_paused_rollout_when_active_is_required(self, form_class): + experiment = NimbusExperimentFactory.create( + status=form_class.required_status, + status_next=form_class.required_status_next, + publish_status=form_class.required_publish_status, + is_paused=True, + is_rollout=True, + ) + form = form_class(data={}, instance=experiment, request=self.request) + + self.assertFalse(form.is_valid()) + self.assertIn( + "Cannot perform this action: experiment must be in state", + form.errors["__all__"][0], + ) + + def test_draft_to_preview_side_effects(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.DRAFT, + status_next=None, + publish_status=NimbusExperiment.PublishStatus.IDLE, + is_paused=False, + is_rollout=True, + ) + form = DraftToPreviewRolloutForm( + data={}, instance=experiment, request=self.request + ) + self.assertTrue(form.is_valid(), form.errors) + + form.save() + + self.mock_allocate_bucket_range.assert_called_once() + self.mock_preview_task.assert_called_once_with(countdown=5) + + def test_preview_to_draft_resets_published_dto_and_syncs_preview(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.PREVIEW, + status_next=None, + publish_status=NimbusExperiment.PublishStatus.IDLE, + is_paused=False, + is_rollout=True, + published_dto={"slug": "test-rollout"}, + ) + form = PreviewToDraftRolloutForm( + data={}, instance=experiment, request=self.request + ) + self.assertTrue(form.is_valid(), form.errors) + + experiment = form.save() + + self.assertIsNone(experiment.published_dto) + self.mock_preview_task.assert_called_once_with(countdown=5) + + @patch("experimenter.nimbus_ui.new.forms.metrics") + def test_draft_review_approval_stages_phase_and_queues_kinto(self, mock_metrics): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LAUNCH_REVIEW_REQUESTED, + is_rollout=True, + population_percent=0, + ) + first_phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=10 + ) + form = DraftReviewApproveRolloutForm( + data={}, instance=experiment, request=self.request + ) + + self.assertTrue(form.is_valid(), form.errors) + + experiment = form.save() + + self.assertIsNone(experiment.rollout_phase) + self.assertEqual(experiment.rollout_phase_next, first_phase) + self.assertEqual(experiment.population_percent, first_phase.population_percent) + self.assertEqual( + experiment.publish_status, NimbusExperiment.PublishStatus.APPROVED + ) + self.mock_allocate_bucket_range.assert_called_once() + self.mock_kinto_push_queue.assert_called_once_with( + countdown=5, args=[experiment.kinto_collection] + ) + mock_metrics.timing.assert_called_once() + + def test_approve_phase_advance_stages_population_and_queues_kinto(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.LIVE, + status_next=NimbusExperiment.Status.LIVE, + publish_status=NimbusExperiment.PublishStatus.REVIEW, + is_paused=False, + is_rollout=True, + population_percent=10, + ) + current_phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=10 + ) + next_phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=25 + ) + experiment.rollout_phase = current_phase + experiment.save() + form = AdvancePhaseReviewApproveRolloutForm( + data={}, instance=experiment, request=self.request + ) + + self.assertTrue(form.is_valid(), form.errors) + + experiment = form.save() + + next_phase.refresh_from_db() + self.assertEqual(experiment.rollout_phase, current_phase) + self.assertEqual(experiment.rollout_phase_next, next_phase) + self.assertEqual(experiment.population_percent, next_phase.population_percent) + self.assertIsNone(next_phase.actual_start_date) + self.assertEqual(experiment.status, NimbusExperiment.Status.LIVE) + self.assertEqual(experiment.status_next, NimbusExperiment.Status.LIVE) + self.assertEqual( + experiment.publish_status, NimbusExperiment.PublishStatus.APPROVED + ) + self.mock_allocate_bucket_range.assert_called_once() + self.mock_kinto_push_queue.assert_called_once_with( + countdown=5, args=[experiment.kinto_collection] + ) + + def test_reject_phase_advance_clears_pending_transition(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.LIVE, + status_next=NimbusExperiment.Status.LIVE, + publish_status=NimbusExperiment.PublishStatus.REVIEW, + is_rollout=True, + ) + form = AdvancePhaseReviewRejectRolloutForm( + data={}, instance=experiment, request=self.request + ) + + self.assertTrue(form.is_valid(), form.errors) + + experiment = form.save() + + self.assertEqual(experiment.status, NimbusExperiment.Status.LIVE) + self.assertIsNone(experiment.status_next) + self.assertEqual(experiment.publish_status, NimbusExperiment.PublishStatus.IDLE) + + def test_disable_approval_preserves_population_and_queues_kinto(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.LIVE, + status_next=NimbusExperiment.Status.DISABLED, + publish_status=NimbusExperiment.PublishStatus.REVIEW, + is_paused=False, + is_rollout=True, + population_percent=25, + ) + form = LiveToDisabledReviewApproveRolloutForm( + data={}, instance=experiment, request=self.request + ) + + self.assertTrue(form.is_valid(), form.errors) + + experiment = form.save() + + self.assertEqual(experiment.population_percent, 25) + self.mock_allocate_bucket_range.assert_not_called() + self.mock_kinto_push_queue.assert_called_once_with( + countdown=5, args=[experiment.kinto_collection] + ) + + def test_reenable_approval_stages_existing_next_phase(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.DISABLED, + status_next=NimbusExperiment.Status.LIVE, + publish_status=NimbusExperiment.PublishStatus.REVIEW, + is_rollout=True, + population_percent=0, + ) + current_phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=10 + ) + next_phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=50 + ) + experiment.rollout_phase = current_phase + experiment.save() + form = DisabledToLiveReviewApproveRolloutForm( + data={}, instance=experiment, request=self.request + ) + + self.assertTrue(form.is_valid(), form.errors) + + experiment = form.save() + + self.assertEqual(experiment.rollout_phase, current_phase) + self.assertEqual(experiment.rollout_phase_next, next_phase) + self.assertEqual(experiment.population_percent, next_phase.population_percent) + self.mock_allocate_bucket_range.assert_called_once() + self.mock_kinto_push_queue.assert_called_once_with( + countdown=5, args=[experiment.kinto_collection] + ) + + def test_reenable_approval_copies_current_phase_when_next_is_missing(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.DISABLED, + status_next=NimbusExperiment.Status.LIVE, + publish_status=NimbusExperiment.PublishStatus.REVIEW, + is_rollout=True, + population_percent=0, + ) + current_phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=25 + ) + experiment.rollout_phase = current_phase + experiment.save() + form = DisabledToLiveReviewApproveRolloutForm( + data={}, instance=experiment, request=self.request + ) + + self.assertTrue(form.is_valid(), form.errors) + + experiment = form.save() + + self.assertEqual(experiment.rollout_phase, current_phase) + self.assertIsNotNone(experiment.rollout_phase_next) + self.assertNotEqual(experiment.rollout_phase_next, current_phase) + self.assertEqual( + experiment.rollout_phase_next.population_percent, + current_phase.population_percent, + ) + self.assertEqual(experiment.rollout_phases.count(), 2) + + class TestRolloutPhaseForm(TestCase): def test_end_date_before_start_date_is_invalid(self): form = RolloutPhaseForm( diff --git a/experimenter/experimenter/nimbus_ui/tests/test_new_views.py b/experimenter/experimenter/nimbus_ui/tests/test_new_views.py index 9c62317dbb..3c1e4772e0 100644 --- a/experimenter/experimenter/nimbus_ui/tests/test_new_views.py +++ b/experimenter/experimenter/nimbus_ui/tests/test_new_views.py @@ -1,9 +1,10 @@ import datetime from decimal import Decimal +from unittest.mock import patch from django.conf import settings from django.test import TestCase -from django.urls import reverse +from django.urls import resolve, reverse from django.utils import timezone from parameterized import parameterized @@ -45,6 +46,186 @@ def setUp(self): self.client.defaults[settings.OPENIDC_EMAIL_HEADER] = self.user.email +class TestRolloutStatusUpdateViews(AuthTestCase): + def setUp(self): + super().setUp() + self.mock_preview_task = patch( + "experimenter.nimbus_ui.new.forms." + "nimbus_synchronize_preview_experiments_in_kinto.apply_async" + ).start() + self.mock_allocate_bucket_range = patch( + "experimenter.experiments.models.NimbusExperiment.allocate_bucket_range" + ).start() + self.mock_kinto_push_queue = patch( + "experimenter.nimbus_ui.new.forms." + "nimbus_check_kinto_push_queue_by_collection.apply_async" + ).start() + self.addCleanup(self.mock_preview_task.stop) + self.addCleanup(self.mock_allocate_bucket_range.stop) + self.addCleanup(self.mock_kinto_push_queue.stop) + + @parameterized.expand( + [ + # Draft -> Preview + ( + "nimbus-ui-new-draft-to-preview-rollout", + NimbusExperiment.Status.DRAFT, + NimbusExperiment.Status.PREVIEW, + None, + NimbusExperiment.PublishStatus.IDLE, + ), + # Draft -> Review + ( + "nimbus-ui-new-draft-to-review-rollout", + NimbusExperiment.Status.DRAFT, + NimbusExperiment.Status.DRAFT, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + ), + # Preview -> Draft + ( + "nimbus-ui-new-preview-to-draft-rollout", + NimbusExperiment.Status.PREVIEW, + NimbusExperiment.Status.DRAFT, + None, + NimbusExperiment.PublishStatus.IDLE, + ), + # Live -> Live + ( + "nimbus-ui-new-advance-phase-review-rollout", + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + ), + # Live -> Disabled + ( + "nimbus-ui-new-live-to-disabled-rollout", + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.DISABLED, + NimbusExperiment.PublishStatus.REVIEW, + ), + # Disabled -> Live + ( + "nimbus-ui-new-disabled-to-live-rollout", + NimbusExperiment.Status.DISABLED, + NimbusExperiment.Status.DISABLED, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + ), + ] + ) + def test_valid_submission( + self, + url_name, + initial_status, + expected_status, + expected_status_next, + expected_publish_status, + ): + form_class = resolve( + reverse(url_name, kwargs={"slug": "test"}) + ).func.view_class.form_class + experiment = NimbusExperimentFactory.create( + status=initial_status, + status_next=form_class.required_status_next, + publish_status=form_class.required_publish_status, + is_rollout=True, + ) + + response = self.client.post(reverse(url_name, kwargs={"slug": experiment.slug})) + + self.assertEqual(response.status_code, 200) + experiment.refresh_from_db() + self.assertEqual(experiment.status, expected_status) + self.assertEqual(experiment.status_next, expected_status_next) + self.assertEqual(experiment.publish_status, expected_publish_status) + + @parameterized.expand( + [ + # Approve Draft -> Live review. + ( + "nimbus-ui-new-draft-review-to-approve-rollout", + NimbusExperiment.Status.DRAFT, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.APPROVED, + ), + # Reject Draft -> Live review. + ( + "nimbus-ui-new-draft-review-to-reject-rollout", + NimbusExperiment.Status.DRAFT, + None, + NimbusExperiment.PublishStatus.IDLE, + ), + ] + ) + def test_draft_review_submission( + self, + url_name, + expected_status, + expected_status_next, + expected_publish_status, + ): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.DRAFT, + status_next=NimbusExperiment.Status.LIVE, + publish_status=NimbusExperiment.PublishStatus.REVIEW, + is_rollout=True, + ) + + response = self.client.post(reverse(url_name, kwargs={"slug": experiment.slug})) + + self.assertEqual(response.status_code, 200) + experiment.refresh_from_db() + self.assertEqual(experiment.status, expected_status) + self.assertEqual(experiment.status_next, expected_status_next) + self.assertEqual(experiment.publish_status, expected_publish_status) + + def test_htmx_progress_card_renders_fragment(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.LIVE, + status_next=None, + publish_status=NimbusExperiment.PublishStatus.IDLE, + is_paused=False, + is_rollout=True, + ) + + response = self.client.get( + reverse( + "nimbus-ui-new-live-to-disabled-rollout", + kwargs={"slug": experiment.slug}, + ), + {"fragment": "progress_card"}, + HTTP_HX_REQUEST="true", + ) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "nimbus_experiments/launch_controls_v2.html") + + def test_invalid_submission_adds_status_form_errors_to_context(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.DRAFT, + status_next=None, + publish_status=NimbusExperiment.PublishStatus.IDLE, + is_paused=False, + is_rollout=True, + ) + + response = self.client.post( + reverse( + "nimbus-ui-new-live-to-disabled-rollout", + kwargs={"slug": experiment.slug}, + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertIn( + "Cannot perform this action: experiment must be in state", + response.context["update_status_form_errors"][0], + ) + + class NewViewTestMixin: def assertResponseUsesForm(self, response, form_class): self.assertIsInstance(response.context["form"], form_class) diff --git a/experimenter/experimenter/nimbus_ui/urls.py b/experimenter/experimenter/nimbus_ui/urls.py index ede19e9654..5d24be50bc 100644 --- a/experimenter/experimenter/nimbus_ui/urls.py +++ b/experimenter/experimenter/nimbus_ui/urls.py @@ -2,6 +2,19 @@ from django.views.generic import RedirectView from experimenter.nimbus_ui.new.views import ( + AdvancePhaseReviewApproveRolloutView, + AdvancePhaseReviewRejectRolloutView, + AdvancePhaseReviewRolloutView, + DisabledToLiveReviewApproveRolloutView, + DisabledToLiveReviewRejectRolloutView, + DisabledToLiveReviewRolloutView, + DraftReviewApproveRolloutView, + DraftReviewRejectView, + DraftReviewRolloutView, + DraftToPreviewRolloutView, + LiveToDisabledReviewApproveRolloutView, + LiveToDisabledReviewRejectRolloutView, + LiveToDisabledReviewRolloutView, NewAddSubscriberView, NewAddTagView, NewAudienceUpdateView, @@ -24,6 +37,8 @@ NewTagSearchView, NewUnsubscribeView, NimbusRolloutDetailView, + PreviewReviewRolloutView, + PreviewToDraftRolloutView, ) from experimenter.nimbus_ui.views import ( ApproveEndEnrollmentView, @@ -309,6 +324,81 @@ ApproveUpdateRolloutView.as_view(), name="nimbus-ui-approve-update-rollout", ), + re_path( + r"^new/(?P[\w-]+)/draft-to-preview-rollout/$", + DraftToPreviewRolloutView.as_view(), + name="nimbus-ui-new-draft-to-preview-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/draft-to-review-rollout/$", + DraftReviewRolloutView.as_view(), + name="nimbus-ui-new-draft-to-review-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/draft-review-to-approve-rollout/$", + DraftReviewApproveRolloutView.as_view(), + name="nimbus-ui-new-draft-review-to-approve-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/draft-review-to-reject-rollout/$", + DraftReviewRejectView.as_view(), + name="nimbus-ui-new-draft-review-to-reject-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/preview-review-rollout/$", + PreviewReviewRolloutView.as_view(), + name="nimbus-ui-new-preview-review-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/preview-to-draft-rollout/$", + PreviewToDraftRolloutView.as_view(), + name="nimbus-ui-new-preview-to-draft-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/advance-phase-review-rollout/$", + AdvancePhaseReviewRolloutView.as_view(), + name="nimbus-ui-new-advance-phase-review-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/approve-advance-phase-review-rollout/$", + AdvancePhaseReviewApproveRolloutView.as_view(), + name="nimbus-ui-new-approve-advance-phase-review-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/reject-advance-phase-review-rollout/$", + AdvancePhaseReviewRejectRolloutView.as_view(), + name="nimbus-ui-new-reject-advance-phase-review-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/live-to-disabled-rollout/$", + LiveToDisabledReviewRolloutView.as_view(), + name="nimbus-ui-new-live-to-disabled-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/live-to-disabled-review-approve-rollout/$", + LiveToDisabledReviewApproveRolloutView.as_view(), + name="nimbus-ui-new-live-to-disabled-review-approve-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/live-to-disabled-review-reject-rollout/$", + LiveToDisabledReviewRejectRolloutView.as_view(), + name="nimbus-ui-new-live-to-disabled-review-reject-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/disabled-to-live-rollout/$", + DisabledToLiveReviewRolloutView.as_view(), + name="nimbus-ui-new-disabled-to-live-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/disabled-to-live-review-approve-rollout/$", + DisabledToLiveReviewApproveRolloutView.as_view(), + name="nimbus-ui-new-disabled-to-live-review-approve-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/disabled-to-live-review-reject-rollout/$", + DisabledToLiveReviewRejectRolloutView.as_view(), + name="nimbus-ui-new-disabled-to-live-review-reject-rollout", + ), re_path( r"^(?P[\w-]+)/results/$", ResultsView.as_view(),