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 @@
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