diff --git a/docs/experimenter/openapi-schema.json b/docs/experimenter/openapi-schema.json index 8c8125b910..1611e094e9 100644 --- a/docs/experimenter/openapi-schema.json +++ b/docs/experimenter/openapi-schema.json @@ -177,7 +177,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ] } }, @@ -273,7 +274,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ] } }, @@ -621,7 +623,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ] } }, @@ -699,7 +702,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ] } }, @@ -783,7 +787,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ] } }, @@ -888,7 +893,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ] } }, @@ -1287,7 +1293,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ], "type": "string" }, diff --git a/docs/experimenter/swagger-ui.html b/docs/experimenter/swagger-ui.html index 93c9aee6ba..57af5a4b8c 100644 --- a/docs/experimenter/swagger-ui.html +++ b/docs/experimenter/swagger-ui.html @@ -189,7 +189,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ] } }, @@ -285,7 +286,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ] } }, @@ -633,7 +635,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ] } }, @@ -711,7 +714,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ] } }, @@ -795,7 +799,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ] } }, @@ -900,7 +905,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ] } }, @@ -1299,7 +1305,8 @@ "Draft", "Preview", "Live", - "Complete" + "Complete", + "Paused" ], "type": "string" }, diff --git a/experimenter/experimenter/experiments/constants.py b/experimenter/experimenter/experiments/constants.py index 23a8fd895a..f57230261d 100644 --- a/experimenter/experimenter/experiments/constants.py +++ b/experimenter/experimenter/experiments/constants.py @@ -330,6 +330,10 @@ class Status(models.TextChoices): LIVE = "Live" COMPLETE = "Complete" + # This status applies only to rollouts, and indicates that the rollout has been + # paused. It is not a valid status for experiments. + PAUSED = "Paused" + class PublishStatus(models.TextChoices): IDLE = "Idle" REVIEW = "Review" diff --git a/experimenter/experimenter/experiments/migrations/0332_alter_nimbuschangelog_new_status_and_more.py b/experimenter/experimenter/experiments/migrations/0332_alter_nimbuschangelog_new_status_and_more.py new file mode 100644 index 0000000000..714f941ac1 --- /dev/null +++ b/experimenter/experimenter/experiments/migrations/0332_alter_nimbuschangelog_new_status_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.14 on 2026-06-24 18:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiments', '0331_alter_nimbusalert_alert_type'), + ] + + operations = [ + migrations.AlterField( + model_name='nimbuschangelog', + name='new_status', + field=models.CharField(choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Paused', 'Paused')], 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'), ('Paused', 'Paused')], 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'), ('Paused', 'Paused')], 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'), ('Paused', 'Paused')], max_length=255, null=True), + ), + migrations.AlterField( + model_name='nimbusexperiment', + name='status', + field=models.CharField(choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Paused', 'Paused')], 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'), ('Paused', 'Paused')], max_length=255, null=True), + ), + ] diff --git a/experimenter/experimenter/nimbus_ui/constants.py b/experimenter/experimenter/nimbus_ui/constants.py index 3221efc122..0d533ce0fa 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 64559c222b..0be81e32a3 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 import markus from django import forms @@ -12,6 +13,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, NimbusDocumentationLink, @@ -20,6 +22,7 @@ NimbusExperimentBranchThroughRequired, Tag, ) +from experimenter.kinto.tasks import nimbus_synchronize_preview_experiments_in_kinto from experimenter.nimbus_ui.constants import NimbusUIConstants from experimenter.targeting.constants import NimbusTargetingConfig @@ -668,3 +671,207 @@ def save(self, commit=True): 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.PAUSED in ( + self.required_status, + self.required_status_next, + self.instance.status, + self.instance.status_next, + 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.is_paused is not None: + self.instance.is_paused = self.is_paused + + 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) + + +class DraftToPreviewRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.DRAFT + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + required_is_paused = False + + 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 DraftToLiveRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.DRAFT + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + required_is_paused = False + + status = NimbusExperiment.Status.LIVE + status_next = None + publish_status = NimbusExperiment.PublishStatus.APPROVED + + def get_changelog_message(self): + return f"{self.request.user} launched rollout to Live" + + +class PreviewToLiveRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.PREVIEW + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + required_is_paused = False + + status = NimbusExperiment.Status.LIVE + status_next = None + publish_status = NimbusExperiment.PublishStatus.APPROVED + + def get_changelog_message(self): + return f"{self.request.user} launched rollout to Live" + + +class PreviewToDraftRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.PREVIEW + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + required_is_paused = False + + 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 + + +class LiveToUpdateRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.LIVE + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + required_is_paused = False + + status = NimbusExperiment.Status.LIVE + status_next = NimbusExperiment.Status.LIVE + publish_status = NimbusExperiment.PublishStatus.REVIEW + + def get_changelog_message(self): + return f"{self.request.user} updated rollout population percentages" + + +class LiveToPausedRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.LIVE + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + required_is_paused = False + + status = NimbusExperiment.Status.PAUSED + status_next = None + publish_status = NimbusExperiment.PublishStatus.APPROVED + is_paused = True + + def get_changelog_message(self): + return f"{self.request.user} paused rollout" + + +class PausedToLiveRolloutForm(UpdateStatusForm): + required_status = NimbusExperiment.Status.PAUSED + required_status_next = None + required_publish_status = NimbusExperiment.PublishStatus.IDLE + required_is_paused = True + + status = NimbusExperiment.Status.LIVE + status_next = None + publish_status = NimbusExperiment.PublishStatus.APPROVED + is_paused = False + + def get_changelog_message(self): + return f"{self.request.user} resumed rollout to Live" diff --git a/experimenter/experimenter/nimbus_ui/new/views.py b/experimenter/experimenter/nimbus_ui/new/views.py index c2b5126999..d8236bd804 100644 --- a/experimenter/experimenter/nimbus_ui/new/views.py +++ b/experimenter/experimenter/nimbus_ui/new/views.py @@ -1,9 +1,11 @@ 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, @@ -13,8 +15,15 @@ CollaboratorsForm, DocumentationLinkCreateForm, DocumentationLinkDeleteForm, + DraftToLiveRolloutForm, + DraftToPreviewRolloutForm, + LiveToPausedRolloutForm, + LiveToUpdateRolloutForm, NimbusExperimentCreateForm, NimbusExperimentSidebarCloneForm, + PausedToLiveRolloutForm, + PreviewToDraftRolloutForm, + PreviewToLiveRolloutForm, RolloutAudienceForm, RolloutOverviewForm, RolloutQAStatusForm, @@ -37,6 +46,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" @@ -56,6 +71,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) @@ -267,3 +339,53 @@ class NewAddSubscriberView(NewSubscriberView): 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 DraftToLiveRolloutView(StatusUpdateView): + form_class = DraftToLiveRolloutForm + + +class PreviewToLiveRolloutView(StatusUpdateView): + form_class = PreviewToLiveRolloutForm + + +class PreviewToDraftRolloutView(StatusUpdateView): + form_class = PreviewToDraftRolloutForm + + +class LiveToUpdateRolloutView(StatusUpdateView): + form_class = LiveToUpdateRolloutForm + + +class LiveToPausedRolloutView(StatusUpdateView): + form_class = LiveToPausedRolloutForm + + +class PausedToLiveRolloutView(StatusUpdateView): + form_class = PausedToLiveRolloutForm diff --git a/experimenter/experimenter/nimbus_ui/tests/test_new_forms.py b/experimenter/experimenter/nimbus_ui/tests/test_new_forms.py index 59b0c97b7b..70ea043fe2 100644 --- a/experimenter/experimenter/nimbus_ui/tests/test_new_forms.py +++ b/experimenter/experimenter/nimbus_ui/tests/test_new_forms.py @@ -1,8 +1,10 @@ import datetime +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, @@ -24,8 +26,15 @@ CollaboratorsForm, DocumentationLinkCreateForm, DocumentationLinkDeleteForm, + DraftToLiveRolloutForm, + DraftToPreviewRolloutForm, + LiveToPausedRolloutForm, + LiveToUpdateRolloutForm, NimbusExperimentCreateForm, NimbusExperimentSidebarCloneForm, + PausedToLiveRolloutForm, + PreviewToDraftRolloutForm, + PreviewToLiveRolloutForm, RolloutAudienceForm, RolloutOverviewForm, RolloutQAStatusForm, @@ -721,3 +730,233 @@ def test_form_queryset_ordered_by_name(self): tag_names = [tag.name for tag in form.fields["tags"].queryset] 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.addCleanup(self.mock_preview_task.stop) + self.addCleanup(self.mock_allocate_bucket_range.stop) + + @parameterized.expand( + [ + # Draft -> Preview + ( + DraftToPreviewRolloutForm, + NimbusExperiment.Status.DRAFT, + False, + NimbusExperiment.Status.PREVIEW, + None, + NimbusExperiment.PublishStatus.IDLE, + False, + "launched rollout to Preview", + ), + # Draft -> Live + ( + DraftToLiveRolloutForm, + NimbusExperiment.Status.DRAFT, + False, + NimbusExperiment.Status.LIVE, + None, + NimbusExperiment.PublishStatus.APPROVED, + False, + "launched rollout to Live", + ), + # Preview -> Live + ( + PreviewToLiveRolloutForm, + NimbusExperiment.Status.PREVIEW, + False, + NimbusExperiment.Status.LIVE, + None, + NimbusExperiment.PublishStatus.APPROVED, + False, + "launched rollout to Live", + ), + # Preview -> Draft + ( + PreviewToDraftRolloutForm, + NimbusExperiment.Status.PREVIEW, + False, + NimbusExperiment.Status.DRAFT, + None, + NimbusExperiment.PublishStatus.IDLE, + False, + "moved the rollout back to Draft", + ), + # Live -> Live + ( + LiveToUpdateRolloutForm, + NimbusExperiment.Status.LIVE, + False, + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + False, + "updated rollout population percentages", + ), + # Live -> Paused + ( + LiveToPausedRolloutForm, + NimbusExperiment.Status.LIVE, + False, + NimbusExperiment.Status.PAUSED, + None, + NimbusExperiment.PublishStatus.APPROVED, + True, + "paused rollout", + ), + # Paused -> Live + ( + PausedToLiveRolloutForm, + NimbusExperiment.Status.PAUSED, + True, + NimbusExperiment.Status.LIVE, + None, + NimbusExperiment.PublishStatus.APPROVED, + False, + "resumed rollout to Live", + ), + ] + ) + def test_valid_transition( + self, + form_class, + initial_status, + initial_is_paused, + expected_status, + expected_status_next, + expected_publish_status, + expected_is_paused, + expected_changelog_message, + ): + experiment = NimbusExperimentFactory.create( + status=initial_status, + status_next=None, + publish_status=NimbusExperiment.PublishStatus.IDLE, + is_paused=initial_is_paused, + is_rollout=True, + ) + form = form_class(data={}, 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) + self.assertEqual(experiment.is_paused, expected_is_paused) + + changelog = experiment.changes.latest("changed_on") + self.assertEqual(changelog.changed_by, self.user) + self.assertIn(expected_changelog_message, changelog.message) + + @parameterized.expand( + [ + # Draft -> Preview cannot start from Preview + (DraftToPreviewRolloutForm, NimbusExperiment.Status.PREVIEW, False), + # Draft -> Live cannot start from Preview + (DraftToLiveRolloutForm, NimbusExperiment.Status.PREVIEW, False), + # Preview -> Live cannot start from Draft + (PreviewToLiveRolloutForm, NimbusExperiment.Status.DRAFT, False), + # Preview -> Draft cannot start from Draft + (PreviewToDraftRolloutForm, NimbusExperiment.Status.DRAFT, False), + # Live -> Live cannot start from Paused + (LiveToUpdateRolloutForm, NimbusExperiment.Status.PAUSED, True), + # Live -> Paused cannot start from Paused + (LiveToPausedRolloutForm, NimbusExperiment.Status.PAUSED, True), + # Paused -> Live cannot start from Live + (PausedToLiveRolloutForm, NimbusExperiment.Status.LIVE, False), + ] + ) + def test_invalid_transition(self, form_class, current_status, is_paused): + experiment = NimbusExperimentFactory.create( + status=current_status, + status_next=None, + publish_status=NimbusExperiment.PublishStatus.IDLE, + is_paused=is_paused, + 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_paused_transition_rejected_for_experiment(self): + experiment = NimbusExperimentFactory.create( + status=NimbusExperiment.Status.LIVE, + status_next=None, + publish_status=NimbusExperiment.PublishStatus.IDLE, + is_paused=False, + is_rollout=False, + ) + form = LiveToPausedRolloutForm(data={}, instance=experiment, request=self.request) + + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors["__all__"], + [NimbusUIConstants.ERROR_INVALID_PAUSED_TRANSITION], + ) + + 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_review_timing_metric(self, mock_metrics): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LAUNCH_REVIEW_REQUESTED, + is_rollout=True, + ) + form = DraftToLiveRolloutForm(data={}, instance=experiment, request=self.request) + form.required_status_next = NimbusExperiment.Status.LIVE + form.required_publish_status = NimbusExperiment.PublishStatus.REVIEW + + self.assertTrue(form.is_valid(), form.errors) + + form.save() + + mock_metrics.timing.assert_called_once() diff --git a/experimenter/experimenter/nimbus_ui/tests/test_new_views.py b/experimenter/experimenter/nimbus_ui/tests/test_new_views.py index 47564bceee..9d4f2acd53 100644 --- a/experimenter/experimenter/nimbus_ui/tests/test_new_views.py +++ b/experimenter/experimenter/nimbus_ui/tests/test_new_views.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.conf import settings from django.test import TestCase from django.urls import reverse @@ -35,6 +37,164 @@ 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.addCleanup(self.mock_preview_task.stop) + self.addCleanup(self.mock_allocate_bucket_range.stop) + + @parameterized.expand( + [ + # Draft -> Preview + ( + "nimbus-ui-new-draft-to-preview-rollout", + NimbusExperiment.Status.DRAFT, + False, + NimbusExperiment.Status.PREVIEW, + None, + NimbusExperiment.PublishStatus.IDLE, + False, + ), + # Draft -> Live + ( + "nimbus-ui-new-draft-to-live-rollout", + NimbusExperiment.Status.DRAFT, + False, + NimbusExperiment.Status.LIVE, + None, + NimbusExperiment.PublishStatus.APPROVED, + False, + ), + # Preview -> Live + ( + "nimbus-ui-new-preview-to-live-rollout", + NimbusExperiment.Status.PREVIEW, + False, + NimbusExperiment.Status.LIVE, + None, + NimbusExperiment.PublishStatus.APPROVED, + False, + ), + # Preview -> Draft + ( + "nimbus-ui-new-preview-to-draft-rollout", + NimbusExperiment.Status.PREVIEW, + False, + NimbusExperiment.Status.DRAFT, + None, + NimbusExperiment.PublishStatus.IDLE, + False, + ), + # Live -> Live + ( + "nimbus-ui-new-live-to-update-rollout", + NimbusExperiment.Status.LIVE, + False, + NimbusExperiment.Status.LIVE, + NimbusExperiment.Status.LIVE, + NimbusExperiment.PublishStatus.REVIEW, + False, + ), + # Live -> Paused + ( + "nimbus-ui-new-live-to-paused-rollout", + NimbusExperiment.Status.LIVE, + False, + NimbusExperiment.Status.PAUSED, + None, + NimbusExperiment.PublishStatus.APPROVED, + True, + ), + # Paused -> Live + ( + "nimbus-ui-new-paused-to-live-rollout", + NimbusExperiment.Status.PAUSED, + True, + NimbusExperiment.Status.LIVE, + None, + NimbusExperiment.PublishStatus.APPROVED, + False, + ), + ] + ) + def test_valid_submission( + self, + url_name, + initial_status, + initial_is_paused, + expected_status, + expected_status_next, + expected_publish_status, + expected_is_paused, + ): + experiment = NimbusExperimentFactory.create( + status=initial_status, + status_next=None, + publish_status=NimbusExperiment.PublishStatus.IDLE, + is_paused=initial_is_paused, + 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) + self.assertEqual(experiment.is_paused, expected_is_paused) + + 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-paused-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-paused-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 f0018ae230..598b59d0cf 100644 --- a/experimenter/experimenter/nimbus_ui/urls.py +++ b/experimenter/experimenter/nimbus_ui/urls.py @@ -2,6 +2,9 @@ from django.views.generic import RedirectView from experimenter.nimbus_ui.new.views import ( + DraftToLiveRolloutView, + DraftToPreviewRolloutView, + LiveToPausedRolloutView, NewAddSubscriberView, NewAddTagView, NewAudienceUpdateView, @@ -15,6 +18,12 @@ NewSubscriberSearchView, NewTagSearchView, NimbusRolloutDetailView, + PausedToLiveRolloutView, + PreviewToDraftRolloutView, + PreviewToLiveRolloutView, +) +from experimenter.nimbus_ui.new.views import ( + LiveToUpdateRolloutView as NewLiveToUpdateRolloutView, ) from experimenter.nimbus_ui.views import ( ApproveEndEnrollmentView, @@ -300,6 +309,41 @@ 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-live-rollout/$", + DraftToLiveRolloutView.as_view(), + name="nimbus-ui-new-draft-to-live-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/preview-to-live-rollout/$", + PreviewToLiveRolloutView.as_view(), + name="nimbus-ui-new-preview-to-live-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-]+)/live-to-update-rollout/$", + NewLiveToUpdateRolloutView.as_view(), + name="nimbus-ui-new-live-to-update-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/live-to-paused-rollout/$", + LiveToPausedRolloutView.as_view(), + name="nimbus-ui-new-live-to-paused-rollout", + ), + re_path( + r"^new/(?P[\w-]+)/paused-to-live-rollout/$", + PausedToLiveRolloutView.as_view(), + name="nimbus-ui-new-paused-to-live-rollout", + ), re_path( r"^(?P[\w-]+)/results/$", ResultsView.as_view(),