diff --git a/experimenter/experimenter/experiments/migrations/0309_nimbusexperiment_enable_review_slack_notifications.py b/experimenter/experimenter/experiments/migrations/0309_nimbusexperiment_enable_review_slack_notifications.py new file mode 100644 index 0000000000..03a7f5bb9a --- /dev/null +++ b/experimenter/experimenter/experiments/migrations/0309_nimbusexperiment_enable_review_slack_notifications.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.9 on 2026-01-06 17:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiments', '0308_delete_duplicate_results_changes'), + ] + + operations = [ + migrations.AddField( + model_name='nimbusexperiment', + name='enable_review_slack_notifications', + field=models.BooleanField(default=True, verbose_name='Enable Review Slack Notifications'), + ), + ] diff --git a/experimenter/experimenter/experiments/models.py b/experimenter/experimenter/experiments/models.py index 5ed3241d11..bd75acfa9c 100644 --- a/experimenter/experimenter/experiments/models.py +++ b/experimenter/experimenter/experiments/models.py @@ -427,6 +427,10 @@ class NimbusExperiment(NimbusConstants, TargetingConstants, FilterMixin, models. blank=True, verbose_name="Subscribers", ) + enable_review_slack_notifications = models.BooleanField( + "Enable Review Slack Notifications", + default=True, + ) use_group_id = models.BooleanField(default=True) objects = NimbusExperimentManager() is_firefox_labs_opt_in = models.BooleanField( diff --git a/experimenter/experimenter/experiments/tests/test_changelog_utils.py b/experimenter/experimenter/experiments/tests/test_changelog_utils.py index d3d9280152..92bb683cfe 100644 --- a/experimenter/experimenter/experiments/tests/test_changelog_utils.py +++ b/experimenter/experimenter/experiments/tests/test_changelog_utils.py @@ -57,6 +57,7 @@ def test_outputs_expected_schema_for_empty_experiment(self): "channels": [], "conclusion_recommendations": [], "countries": [], + "enable_review_slack_notifications": True, "equal_branch_ratio": experiment.equal_branch_ratio, "excluded_experiments": [], "exclude_countries": experiment.exclude_countries, @@ -191,6 +192,7 @@ def test_outputs_expected_schema_for_complete_experiment(self): "channel": experiment.channel, "channels": experiment.channels, "conclusion_recommendations": [], + "enable_review_slack_notifications": True, "equal_branch_ratio": experiment.equal_branch_ratio, "excluded_experiments": [], "exclude_countries": experiment.exclude_countries, diff --git a/experimenter/experimenter/nimbus_ui/forms.py b/experimenter/experimenter/nimbus_ui/forms.py index a3109c8ba3..5c7fd0b632 100644 --- a/experimenter/experimenter/nimbus_ui/forms.py +++ b/experimenter/experimenter/nimbus_ui/forms.py @@ -1329,6 +1329,20 @@ def save(self, commit=True): return self.instance +class ToggleReviewSlackNotificationsForm(NimbusChangeLogFormMixin, forms.ModelForm): + class Meta: + model = NimbusExperiment + fields = ["enable_review_slack_notifications"] + + def get_changelog_message(self): + status = ( + "enabled" + if self.cleaned_data.get("enable_review_slack_notifications") + else "disabled" + ) + return f"{self.request.user} {status} review Slack notifications" + + class SlackNotificationMixin: slack_action = None @@ -1336,12 +1350,13 @@ class SlackNotificationMixin: def save(self, commit=True): experiment = super().save(commit=commit) if self.slack_action: - nimbus_send_slack_notification.delay( - experiment_id=experiment.id, - email_addresses=experiment.notification_emails, - action_text=NimbusConstants.SLACK_FORM_ACTIONS[self.slack_action], - requesting_user_email=self.request.user.email, - ) + if experiment.enable_review_slack_notifications: + nimbus_send_slack_notification.delay( + experiment_id=experiment.id, + email_addresses=experiment.notification_emails, + action_text=NimbusConstants.SLACK_FORM_ACTIONS[self.slack_action], + requesting_user_email=self.request.user.email, + ) return experiment diff --git a/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/experiment_base.html b/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/experiment_base.html index 6a74f8a848..9362cc2c8e 100644 --- a/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/experiment_base.html +++ b/experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/experiment_base.html @@ -36,6 +36,31 @@

{{ experiment.name }}

+
+
+ {% csrf_token %} + +
+ + +
+
+
{% if experiment.parent %}

Cloned from diff --git a/experimenter/experimenter/nimbus_ui/tests/test_forms.py b/experimenter/experimenter/nimbus_ui/tests/test_forms.py index 82bb3d6ce6..d4bfd91e4d 100644 --- a/experimenter/experimenter/nimbus_ui/tests/test_forms.py +++ b/experimenter/experimenter/nimbus_ui/tests/test_forms.py @@ -80,6 +80,7 @@ TagFormSet, TakeawaysForm, ToggleArchiveForm, + ToggleReviewSlackNotificationsForm, UnsubscribeForm, ) from experimenter.openidc.tests.factories import UserFactory @@ -404,6 +405,54 @@ def test_toggle_unarchive(self): ) +class TestToggleReviewSlackNotificationsForm(RequestFormTestCase): + @parameterized.expand( + [ + ( + "enable", + False, + True, + "enabled", + ), + ( + "disable", + True, + False, + "disabled", + ), + ] + ) + def test_toggle_slack_notifications( + self, _name, initial_value, new_value, expected_status + ): + experiment = NimbusExperiment.objects.create( + owner=self.user, + name="Test Experiment", + slug="test-experiment", + enable_review_slack_notifications=initial_value, + ) + + data = { + "enable_review_slack_notifications": new_value, + } + + form = ToggleReviewSlackNotificationsForm( + data, instance=experiment, request=self.request + ) + self.assertTrue(form.is_valid()) + + updated_experiment = form.save() + + self.assertEqual(updated_experiment.enable_review_slack_notifications, new_value) + + changelog_message = form.get_changelog_message() + + self.assertEqual( + changelog_message, + f"{self.user} {expected_status} review Slack notifications", + ) + + class TestQAStatusForm(RequestFormTestCase): def test_form_updates_qa_fields_and_creates_changelog(self): experiment = NimbusExperimentFactory.create_with_lifecycle( @@ -1382,6 +1431,100 @@ def test_approve_update_rollout_form(self): self.mock_preview_task.assert_called_once_with(countdown=5) self.mock_allocate_bucket_range.assert_called_once() + @parameterized.expand( + [ + ( + "draft_to_review_skips_slack_when_disabled", + DraftToReviewForm, + NimbusExperiment.Status.DRAFT, + {}, + False, + False, + ), + ( + "draft_to_review_sends_slack_when_enabled", + DraftToReviewForm, + NimbusExperiment.Status.DRAFT, + {}, + True, + True, + ), + ( + "live_to_update_rollout_skips_slack_when_disabled", + LiveToUpdateRolloutForm, + NimbusExperiment.Status.LIVE, + {"is_rollout": True}, + False, + False, + ), + ( + "live_to_update_rollout_sends_slack_when_enabled", + LiveToUpdateRolloutForm, + NimbusExperiment.Status.LIVE, + {"is_rollout": True}, + True, + True, + ), + ( + "live_to_end_enrollment_skips_slack_when_disabled", + LiveToEndEnrollmentForm, + NimbusExperiment.Status.LIVE, + {}, + False, + False, + ), + ( + "live_to_end_enrollment_sends_slack_when_enabled", + LiveToEndEnrollmentForm, + NimbusExperiment.Status.LIVE, + {}, + True, + True, + ), + ( + "live_to_complete_skips_slack_when_disabled", + LiveToCompleteForm, + NimbusExperiment.Status.LIVE, + {}, + False, + False, + ), + ( + "live_to_complete_sends_slack_when_enabled", + LiveToCompleteForm, + NimbusExperiment.Status.LIVE, + {}, + True, + True, + ), + ] + ) + def test_slack_notification_behavior( + self, + _name, + form_class, + status, + extra_kwargs, + enable_slack, + should_call_slack, + ): + experiment = NimbusExperimentFactory.create( + status=status, + status_next=None, + publish_status=NimbusExperiment.PublishStatus.IDLE, + enable_review_slack_notifications=enable_slack, + **extra_kwargs, + ) + form = form_class(data={}, instance=experiment, request=self.request) + self.assertTrue(form.is_valid(), form.errors) + + form.save() + + if should_call_slack: + self.mock_slack_task.assert_called_once() + else: + self.mock_slack_task.assert_not_called() + class TestOverviewForm(RequestFormTestCase): def test_valid_form_saves(self): diff --git a/experimenter/experimenter/nimbus_ui/tests/test_views.py b/experimenter/experimenter/nimbus_ui/tests/test_views.py index 1fcce7be41..78165c28ec 100644 --- a/experimenter/experimenter/nimbus_ui/tests/test_views.py +++ b/experimenter/experimenter/nimbus_ui/tests/test_views.py @@ -1626,6 +1626,43 @@ def test_toggle_archive_status_to_unarchive(self): self.assertFalse(updated_experiment.is_archived) +class TestToggleReviewSlackNotificationsView(AuthTestCase): + def setUp(self): + super().setUp() + self.experiment = NimbusExperiment.objects.create( + slug="test-experiment", + name="Test Experiment", + owner=self.user, + enable_review_slack_notifications=True, + ) + + @parameterized.expand( + [ + ("disable", True, False), + ("enable", False, True), + ] + ) + def test_toggle_slack_notifications(self, _name, initial_value, new_value): + self.experiment.enable_review_slack_notifications = initial_value + self.experiment.save() + + response = self.client.post( + reverse( + "nimbus-ui-toggle-review-slack-notifications", + kwargs={"slug": self.experiment.slug}, + ), + {"enable_review_slack_notifications": new_value}, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual( + response.url, + reverse("nimbus-ui-detail", kwargs={"slug": self.experiment.slug}), + ) + + updated_experiment = NimbusExperiment.objects.get(slug=self.experiment.slug) + self.assertEqual(updated_experiment.enable_review_slack_notifications, new_value) + + class TestOverviewUpdateView(AuthTestCase): def test_get_renders_for_draft_experiment(self): experiment = NimbusExperimentFactory.create_with_lifecycle( diff --git a/experimenter/experimenter/nimbus_ui/urls.py b/experimenter/experimenter/nimbus_ui/urls.py index d3d6c233f0..8a3129ff5e 100644 --- a/experimenter/experimenter/nimbus_ui/urls.py +++ b/experimenter/experimenter/nimbus_ui/urls.py @@ -51,6 +51,7 @@ TagsManageView, TakeawaysUpdateView, ToggleArchiveView, + ToggleReviewSlackNotificationsView, UnsubscribeView, ) @@ -200,6 +201,11 @@ FeatureUnsubscribeView.as_view(), name="nimbus-ui-feature-unsubscribe", ), + re_path( + r"^(?P[\w-]+)/toggle_review_slack_notifications/", + ToggleReviewSlackNotificationsView.as_view(), + name="nimbus-ui-toggle-review-slack-notifications", + ), re_path( r"^(?P[\w-]+)/update_collaborators/", CollaboratorsUpdateView.as_view(), diff --git a/experimenter/experimenter/nimbus_ui/views.py b/experimenter/experimenter/nimbus_ui/views.py index 97a7fb2d0e..51d806e827 100644 --- a/experimenter/experimenter/nimbus_ui/views.py +++ b/experimenter/experimenter/nimbus_ui/views.py @@ -73,6 +73,7 @@ TagFormSet, TakeawaysForm, ToggleArchiveForm, + ToggleReviewSlackNotificationsForm, UnsubscribeForm, ) @@ -591,6 +592,17 @@ class UnsubscribeView( form_class = UnsubscribeForm +class ToggleReviewSlackNotificationsView( + NimbusExperimentViewMixin, + RequestFormMixin, + UpdateView, +): + form_class = ToggleReviewSlackNotificationsForm + + def get_success_url(self): + return reverse("nimbus-ui-detail", kwargs={"slug": self.object.slug}) + + class FeatureSubscribeView(FeatureSubscriberViewMixin): form_class = FeatureSubscribeForm url_name = "nimbus-ui-feature-subscribe"