Skip to content

Commit e4ab104

Browse files
feat(nimbus): Support pausing Firefox Labs deliveries
Because: - we are going to graduate our first Firefox Labs feature into a real feature (auto-pip); - we want to prevent additional enrollment in the feature when we graduate this feature in Firefox 147; and - we can accomplish this via supporting ending enrollment for Firefox Labs delivries this commit: - adds support for ending enrollment to Firefox Labs rollouts; and - adds additional error handling to the LiveToEndEnrollmentForm to prevent updating the status via a stale page. Fixes #14064
1 parent 57599df commit e4ab104

File tree

6 files changed

+145
-6
lines changed

6 files changed

+145
-6
lines changed

experimenter/experimenter/experiments/constants.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,6 +1232,16 @@ class FirefoxLabsGroups(models.TextChoices):
12321232
ERROR_FIREFOX_LABS_REQUIRED_FIELD = "This field is requried for Firefox Labs Opt-Ins."
12331233
ERROR_FIREFOX_LABS_ROLLOUT_REQUIRED = "Firefox Labs opt-ins must be rollouts."
12341234

1235+
ERROR_CANNOT_PAUSE_NOT_LIVE = "Cannot end enrollment: experiment is not live"
1236+
ERROR_CANNOT_PAUSE_UNPUBLISHED = (
1237+
"Cannot end enrollment: there are unpublished changes"
1238+
)
1239+
ERROR_CANNOT_PAUSE_PAUSED = "Cannot end enrollment: enrollment has already ended"
1240+
ERROR_CANNOT_PAUSE_ROLLOUT = (
1241+
"Cannot end enrollment: rollouts do not support this behaviour"
1242+
)
1243+
ERROR_CANNOT_PAUSE_INVALID = "Cannot end enrollment at this time"
1244+
12351245

12361246
EXTERNAL_URLS = {
12371247
"SIGNOFF_QA": "https://experimenter.info/qa-sign-off",

experimenter/experimenter/experiments/models.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,10 @@ def is_review_timeline(self):
819819
self.PublishStatus.WAITING,
820820
}
821821

822+
@property
823+
def is_draft(self):
824+
return self.status == self.Status.DRAFT
825+
822826
@property
823827
def is_preview(self):
824828
return self.status == self.Status.PREVIEW
@@ -911,7 +915,9 @@ def should_show_timeout_message(self):
911915

912916
@property
913917
def should_show_end_enrollment(self):
914-
return self.is_enrolling and not self.is_rollout
918+
# If these conditions change then you must update
919+
# `LiveToEndEnrollmentForm.clean`.
920+
return self.is_enrolling and (not self.is_rollout or self.is_firefox_labs_opt_in)
915921

916922
@property
917923
def should_show_end_experiment(self):
@@ -1148,7 +1154,7 @@ def computed_observations_days(self):
11481154

11491155
@property
11501156
def is_live_rollout(self):
1151-
return self.is_rollout and self.is_enrolling
1157+
return self.is_rollout and (self.is_enrolling or self.is_observation)
11521158

11531159
@property
11541160
def is_missing_takeaway_info(self):
@@ -1168,7 +1174,7 @@ def can_edit_metrics(self):
11681174
return self.is_draft
11691175

11701176
def can_edit_audience(self):
1171-
return self.is_draft or self.is_live_rollout
1177+
return self.is_draft or (self.is_live_rollout and self.is_enrolling)
11721178

11731179
def sidebar_links(self, current_path):
11741180
return [

experimenter/experimenter/nimbus_ui/forms.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,6 +1412,27 @@ class LiveToEndEnrollmentForm(UpdateStatusForm):
14121412
publish_status = NimbusExperiment.PublishStatus.REVIEW
14131413
is_paused = True
14141414

1415+
def clean(self):
1416+
if self.instance and self.instance.is_rollout_dirty:
1417+
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_UNPUBLISHED)
1418+
elif not self.instance.should_show_end_enrollment:
1419+
if (
1420+
self.instance.is_draft
1421+
or self.instance.is_preview
1422+
or self.instance.is_complete
1423+
):
1424+
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_NOT_LIVE)
1425+
elif not self.instance.is_enrolling:
1426+
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_PAUSED)
1427+
elif self.instance.is_rollout and not self.instance.is_firefox_labs_opt_in:
1428+
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_ROLLOUT)
1429+
else: # pragma: nocover
1430+
# The conditions for Experiment.should_show_enrollment have
1431+
# changed but this function has become out of sync.
1432+
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_INVALID)
1433+
1434+
return super().clean()
1435+
14151436
def get_changelog_message(self):
14161437
return f"{self.request.user} requested review to end enrollment"
14171438

experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/launch_controls.html

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@
2727
</p>
2828
</div>
2929
{% endif %}
30+
{% if update_status_form_errors %}
31+
<div class="alert alert-danger" role="alert">
32+
<p class="mb-1">
33+
Could not request an update for this experiment:
34+
</p>
35+
{{ update_status_form_errors }}
36+
<p class="mb-0">
37+
Please fix the above issues or ask in <code>#ask-experimenter</code>.
38+
</p>
39+
</div>
40+
{% endif %}
3041
{% with rejection=experiment.rejection_block %}
3142
{% if rejection %}
3243
<div class="alert alert-warning"
@@ -243,7 +254,8 @@ <h5 class="mb-3 ms-2">Actions</h5>
243254
hx-select="#content"
244255
hx-target="#content"
245256
hx-swap="outerHTML"
246-
class="btn btn-primary m-2 end-enrollment_btn">End Enrollment</button>
257+
class="btn btn-primary m-1 end-enrollment_btn"
258+
{% if experiment.is_rollout_dirty %}disabled{% endif %}>End Enrollment</button>
247259
{% endif %}
248260
{% if experiment.should_show_end_experiment %}
249261
<button type="button"
@@ -252,7 +264,7 @@ <h5 class="mb-3 ms-2">Actions</h5>
252264
hx-target="#content"
253265
hx-swap="outerHTML"
254266
id="end-experiment"
255-
class="btn btn-primary end_experiment_btn">
267+
class="btn btn-primary m-1 end_experiment_btn">
256268
End
257269
{% if experiment.is_rollout %}
258270
Rollout
@@ -267,7 +279,7 @@ <h5 class="mb-3 ms-2">Actions</h5>
267279
hx-select="#content"
268280
hx-target="#content"
269281
hx-swap="outerHTML"
270-
class="btn btn-primary"
282+
class="btn btn-primary m-1"
271283
id="request-update-button"
272284
{% if not experiment.is_rollout_dirty %}disabled{% endif %}>Request Update</button>
273285
{% endif %}

experimenter/experimenter/nimbus_ui/tests/test_forms.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,88 @@ def test_live_to_end_enrollment_form(self):
893893
self.assertEqual(changelog.changed_by, self.user)
894894
self.assertIn("requested review to end enrollment", changelog.message)
895895

896+
def test_live_to_end_enrollment_form_rollout(self):
897+
rollout = NimbusExperimentFactory.create_with_lifecycle(
898+
NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, is_rollout=True
899+
)
900+
901+
form = LiveToEndEnrollmentForm(data={}, instance=rollout, request=self.request)
902+
self.assertFalse(form.is_valid())
903+
self.assertEqual(
904+
form.errors, {"__all__": [NimbusExperiment.ERROR_CANNOT_PAUSE_ROLLOUT]}
905+
)
906+
907+
def test_live_to_end_enrollment_form_firefox_labs(self):
908+
rollout = NimbusExperimentFactory.create_with_lifecycle(
909+
NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING,
910+
is_rollout=True,
911+
is_firefox_labs_opt_in=True,
912+
firefox_labs_title="title",
913+
firefox_labs_description="description",
914+
firefox_labs_group="group",
915+
)
916+
917+
form = LiveToEndEnrollmentForm(data={}, instance=rollout, request=self.request)
918+
self.assertTrue(form.is_valid(), form.errors)
919+
920+
rollout = form.save()
921+
self.assertEqual(rollout.status, NimbusExperiment.Status.LIVE)
922+
self.assertEqual(rollout.status_next, NimbusExperiment.Status.LIVE)
923+
self.assertEqual(rollout.publish_status, NimbusExperiment.PublishStatus.REVIEW)
924+
self.assertTrue(rollout.is_paused)
925+
926+
changelog = rollout.changes.latest("changed_on")
927+
self.assertEqual(changelog.changed_by, self.user)
928+
self.assertIn("requested review to end enrollment", changelog.message)
929+
930+
def test_live_to_end_enrollment_form_firefox_labs_dirty(self):
931+
rollout = NimbusExperimentFactory.create_with_lifecycle(
932+
NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING,
933+
is_rollout=True,
934+
is_firefox_labs_opt_in=True,
935+
firefox_labs_title="title",
936+
firefox_labs_description="description",
937+
firefox_labs_group="group",
938+
is_rollout_dirty=True,
939+
)
940+
941+
form = LiveToEndEnrollmentForm(data={}, instance=rollout, request=self.request)
942+
self.assertFalse(form.is_valid())
943+
self.assertEqual(
944+
form.errors, {"__all__": [NimbusExperiment.ERROR_CANNOT_PAUSE_UNPUBLISHED]}
945+
)
946+
947+
def test_live_to_end_enrollment_form_paused(self):
948+
experiment = NimbusExperimentFactory.create(
949+
status=NimbusExperimentFactory.Lifecycles.LIVE_PAUSED,
950+
status_next=None,
951+
publish_status=NimbusExperiment.PublishStatus.IDLE,
952+
)
953+
954+
form = LiveToEndEnrollmentForm(data={}, instance=experiment, request=self.request)
955+
self.assertFalse(form.is_valid())
956+
957+
self.assertEqual(
958+
form.errors, {"__all__": [NimbusExperiment.ERROR_CANNOT_PAUSE_PAUSED]}
959+
)
960+
961+
@parameterized.expand(
962+
[
963+
NimbusExperimentFactory.Lifecycles.CREATED,
964+
NimbusExperimentFactory.Lifecycles.PREVIEW,
965+
NimbusExperimentFactory.Lifecycles.ENDING_APPROVE_APPROVE,
966+
]
967+
)
968+
def test_live_to_end_enrollment_form_not_live(self, lifecycle):
969+
experiment = NimbusExperimentFactory.create_with_lifecycle(lifecycle)
970+
971+
form = LiveToEndEnrollmentForm(data={}, instance=experiment, request=self.request)
972+
self.assertFalse(form.is_valid())
973+
974+
self.assertEqual(
975+
form.errors, {"__all__": [NimbusExperiment.ERROR_CANNOT_PAUSE_NOT_LIVE]}
976+
)
977+
896978
def test_approve_end_enrollment_form(self):
897979
experiment = NimbusExperimentFactory.create(
898980
status=NimbusExperiment.Status.LIVE,

experimenter/experimenter/nimbus_ui/views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,14 @@ class FeatureUnsubscribeView(FeatureSubscriberViewMixin):
602602
class StatusUpdateView(RequestFormMixin, RenderResponseMixin, NimbusExperimentDetailView):
603603
fields = None
604604

605+
def get_context_data(self, *, form, **kwargs):
606+
context = super().get_context_data(form=form, **kwargs)
607+
608+
if self.request.method in ("POST", "PUT") and not form.is_valid():
609+
context["update_status_form_errors"] = form.errors["__all__"]
610+
611+
return context
612+
605613

606614
class DraftToPreviewView(StatusUpdateView):
607615
form_class = DraftToPreviewForm

0 commit comments

Comments
 (0)