From 9d2de72ae9edcefc991e984ef0c955d54136c32d Mon Sep 17 00:00:00 2001 From: Rana Al-Khulaidi Date: Thu, 2 Jul 2026 14:17:07 -0400 Subject: [PATCH] feat(nimbus): Add Rollout Schedule Card --- ...0333_nimbusrolloutplantemplate_and_more.py | 63 +++ .../experimenter/experiments/models.py | 146 +++++++ .../experiments/tests/factories.py | 13 + .../experiments/tests/test_changelog_utils.py | 8 + .../experiments/tests/test_models.py | 233 +++++++++++ .../experimenter/nimbus_ui/constants.py | 18 + .../experimenter/nimbus_ui/new/forms.py | 243 +++++++++++ .../experimenter/nimbus_ui/new/views.py | 43 ++ .../new/rollouts/rollout_detail.html | 4 +- .../templates/new/rollouts/schedule/card.html | 109 +++++ .../new/rollouts/schedule/edit_form.html | 138 ++++++ .../nimbus_ui/tests/test_new_forms.py | 57 +++ .../nimbus_ui/tests/test_new_views.py | 395 +++++++++++++++++- experimenter/experimenter/nimbus_ui/urls.py | 30 ++ 14 files changed, 1496 insertions(+), 4 deletions(-) create mode 100644 experimenter/experimenter/experiments/migrations/0333_nimbusrolloutplantemplate_and_more.py create mode 100644 experimenter/experimenter/nimbus_ui/templates/new/rollouts/schedule/card.html create mode 100644 experimenter/experimenter/nimbus_ui/templates/new/rollouts/schedule/edit_form.html diff --git a/experimenter/experimenter/experiments/migrations/0333_nimbusrolloutplantemplate_and_more.py b/experimenter/experimenter/experiments/migrations/0333_nimbusrolloutplantemplate_and_more.py new file mode 100644 index 000000000..2cfdc2e17 --- /dev/null +++ b/experimenter/experimenter/experiments/migrations/0333_nimbusrolloutplantemplate_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.14 on 2026-07-02 17:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiments', '0332_nimbusexperiment_holdback_fields'), + ] + + operations = [ + migrations.CreateModel( + name='NimbusRolloutPlanTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True, verbose_name='Rollout Plan Name')), + ('phases', models.JSONField(default=list, verbose_name='Rollout Plan Phases')), + ], + options={ + 'verbose_name': 'Nimbus Rollout Plan Template', + 'verbose_name_plural': 'Nimbus Rollout Plan Templates', + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='nimbusexperiment', + name='rollout_advance_observations', + field=models.TextField(blank=True, default='', verbose_name='Advance Rollout Phase Observations'), + ), + migrations.AddField( + model_name='nimbusexperiment', + name='rollout_pause_observations', + field=models.TextField(blank=True, default='', verbose_name='Pause Rollout Observations'), + ), + migrations.CreateModel( + name='NimbusRolloutPhase', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_date', models.DateField(blank=True, null=True, verbose_name='Phase Start Date')), + ('end_date', models.DateField(blank=True, null=True, verbose_name='Phase End Date')), + ('actual_start_date', models.DateField(blank=True, null=True, verbose_name='Phase Actual Start Date')), + ('population_percent', models.DecimalField(decimal_places=4, default=0.0, max_digits=7, verbose_name='Phase Population Percent')), + ('experiment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rollout_phases', to='experiments.nimbusexperiment')), + ], + options={ + 'verbose_name': 'Nimbus Rollout Phase', + 'verbose_name_plural': 'Nimbus Rollout Phases', + 'ordering': ('id',), + }, + ), + migrations.AddField( + model_name='nimbusexperiment', + name='rollout_phase', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='experiments.nimbusrolloutphase', verbose_name='Current Rollout Phase'), + ), + migrations.AddField( + model_name='nimbusexperiment', + name='rollout_phase_next', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='experiments.nimbusrolloutphase', verbose_name='Next Rollout Phase'), + ), + ] diff --git a/experimenter/experimenter/experiments/models.py b/experimenter/experimenter/experiments/models.py index 75af68d3f..b866f5763 100644 --- a/experimenter/experimenter/experiments/models.py +++ b/experimenter/experimenter/experiments/models.py @@ -231,6 +231,28 @@ class NimbusExperiment(NimbusConstants, TargetingConstants, FilterMixin, models. is_rollout_dirty = models.BooleanField( "Approved Changes Flag", blank=False, null=False, default=False ) + rollout_advance_observations = models.TextField( + "Advance Rollout Phase Observations", blank=True, default="" + ) + rollout_pause_observations = models.TextField( + "Pause Rollout Observations", blank=True, default="" + ) + rollout_phase = models.ForeignKey( + "NimbusRolloutPhase", + verbose_name="Current Rollout Phase", + related_name="+", + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + rollout_phase_next = models.ForeignKey( + "NimbusRolloutPhase", + verbose_name="Next Rollout Phase", + related_name="+", + blank=True, + null=True, + on_delete=models.SET_NULL, + ) proposed_duration = models.PositiveIntegerField( "Proposed Duration", default=NimbusConstants.DEFAULT_PROPOSED_DURATION, @@ -1238,6 +1260,54 @@ def computed_observations_days(self): def is_live_rollout(self): return self.is_rollout and (self.is_enrolling or self.is_observation) + def annotated_rollout_phases(self): + phases = list(self.rollout_phases.all()) + current_index = None + if self.rollout_phase_id is not None: + for i, phase in enumerate(phases): + if phase.id == self.rollout_phase_id: + current_index = i + break + for i, phase in enumerate(phases): + if current_index is None or i > current_index: + phase.card_status = NimbusUIConstants.RolloutPhaseStatus.NOT_STARTED + elif i == current_index: + phase.card_status = NimbusUIConstants.RolloutPhaseStatus.IN_PROGRESS + else: + phase.card_status = NimbusUIConstants.RolloutPhaseStatus.COMPLETE + return phases + + def advance_rollout_phase(self): + phases = list(self.rollout_phases.all()) + if not phases: + return + + today = datetime.date.today() + phase_ids = [phase.id for phase in phases] + + if self.rollout_phase_id is None: + next_phase = phases[0] + else: + current_index = phase_ids.index(self.rollout_phase_id) + current_phase = phases[current_index] + current_phase.end_date = today + if current_phase.actual_start_date: + current_phase.start_date = current_phase.actual_start_date + current_phase.save() + next_index = current_index + 1 + next_phase = phases[next_index] if next_index < len(phases) else None + + if next_phase is None: + self.rollout_phase_next = None + self.save() + return + + next_phase.actual_start_date = today + next_phase.save() + self.rollout_phase = next_phase + self.rollout_phase_next = None + self.save() + @property def is_missing_takeaway_info(self): return ( @@ -1258,6 +1328,9 @@ def can_edit_metrics(self): def can_edit_audience(self): return self.is_draft or (self.is_live_rollout and self.is_enrolling) + def can_edit_schedule(self): + return self.is_draft or self.is_live_rollout + def sidebar_links(self, current_path): return [ { @@ -2361,6 +2434,8 @@ def clone(self, name, user, rollout_branch_slug=None, changed_on=None): cloned.is_archived = False cloned.is_paused = False cloned.is_rollout_dirty = False + cloned.rollout_phase = None + cloned.rollout_phase_next = None cloned.reference_branch = None cloned.proposed_release_date = None cloned.published_dto = None @@ -2413,6 +2488,11 @@ def clone(self, name, user, rollout_branch_slug=None, changed_on=None): link.experiment = cloned link.save() + for phase in self.rollout_phases.all(): + phase.id = None + phase.experiment = cloned + phase.save() + for ( required_experiment_branch ) in NimbusExperimentBranchThroughRequired.objects.filter(parent_experiment=self): @@ -2757,6 +2837,72 @@ def __str__(self): return f"{self.title} ({self.link})" +class NimbusRolloutPhase(models.Model): + experiment = models.ForeignKey( + NimbusExperiment, + related_name="rollout_phases", + on_delete=models.CASCADE, + ) + start_date = models.DateField("Phase Start Date", null=True, blank=True) + end_date = models.DateField("Phase End Date", null=True, blank=True) + actual_start_date = models.DateField("Phase Actual Start Date", null=True, blank=True) + population_percent = models.DecimalField[Decimal]( + "Phase Population Percent", max_digits=7, decimal_places=4, default=0.0 + ) + + class Meta: + verbose_name = "Nimbus Rollout Phase" + verbose_name_plural = "Nimbus Rollout Phases" + ordering = ("id",) + + def __str__(self): + return f"Rollout phase ({self.population_percent}%)" + + @property + def duration_days(self): + if self.start_date and self.end_date: + return max(0, (self.end_date - self.start_date).days) + return None + + @property + def duration_display(self): + days = self.duration_days + if days is None: + return None + return f"{days} day{'' if days == 1 else 's'}" + + @property + def days_elapsed(self): + if not self.start_date: + return 0 + return max(0, (datetime.date.today() - self.start_date).days) + + +class NimbusRolloutPlanTemplate(models.Model): + name = models.CharField("Rollout Plan Name", max_length=255, unique=True) + phases = models.JSONField("Rollout Plan Phases", default=list) + + class Meta: + verbose_name = "Nimbus Rollout Plan Template" + verbose_name_plural = "Nimbus Rollout Plan Templates" + ordering = ("name",) + + def __str__(self): + return self.name + + @staticmethod + def summary(phases): + formatted_phases = [] + + for phase in phases: + if phase == int(phase): + formatted_phases.append(f"{int(phase)}%") + else: + formatted_phases.append(f"{phase}%") + + return " → ".join(formatted_phases) + + class NimbusIsolationGroup(models.Model): application = models.CharField( max_length=255, choices=NimbusExperiment.Application.choices diff --git a/experimenter/experimenter/experiments/tests/factories.py b/experimenter/experimenter/experiments/tests/factories.py index 7d987dc4a..019a62ab3 100644 --- a/experimenter/experimenter/experiments/tests/factories.py +++ b/experimenter/experimenter/experiments/tests/factories.py @@ -37,6 +37,7 @@ NimbusExperimentBranchThroughRequired, NimbusFeatureConfig, NimbusIsolationGroup, + NimbusRolloutPhase, NimbusVersionedSchema, Tag, ) @@ -1272,6 +1273,18 @@ def create_with_title(cls, title, experiment): ) +class NimbusRolloutPhaseFactory(factory.django.DjangoModelFactory): + experiment = factory.SubFactory(NimbusExperimentFactory) + start_date = factory.LazyAttribute(lambda o: datetime.date.today()) + end_date = factory.LazyAttribute( + lambda o: datetime.date.today() + datetime.timedelta(days=random.randint(1, 30)) + ) + population_percent = factory.LazyAttribute(lambda o: random.randint(1, 100)) + + class Meta: + model = NimbusRolloutPhase + + class NimbusIsolationGroupFactory(factory.django.DjangoModelFactory): name = factory.LazyAttribute(lambda o: slugify(faker.unique.catch_phrase())) instance = factory.Sequence(lambda n: n) diff --git a/experimenter/experimenter/experiments/tests/test_changelog_utils.py b/experimenter/experimenter/experiments/tests/test_changelog_utils.py index a3c7c4ebb..996ea4959 100644 --- a/experimenter/experimenter/experiments/tests/test_changelog_utils.py +++ b/experimenter/experimenter/experiments/tests/test_changelog_utils.py @@ -123,6 +123,10 @@ def test_outputs_expected_schema_for_empty_experiment(self): "risk_mitigation_link": "", "risk_partner_related": None, "risk_revenue": None, + "rollout_advance_observations": "", + "rollout_pause_observations": "", + "rollout_phase": None, + "rollout_phase_next": None, "secondary_outcomes": [], "segments": [], "slug": "", @@ -257,6 +261,10 @@ def test_outputs_expected_schema_for_complete_experiment(self): "risk_mitigation_link": experiment.risk_mitigation_link, "risk_partner_related": experiment.risk_partner_related, "risk_revenue": experiment.risk_revenue, + "rollout_advance_observations": experiment.rollout_advance_observations, + "rollout_pause_observations": experiment.rollout_pause_observations, + "rollout_phase": experiment.rollout_phase, + "rollout_phase_next": experiment.rollout_phase_next, "secondary_outcomes": [secondary_outcome], "segments": [segment], "slug": experiment.slug, diff --git a/experimenter/experimenter/experiments/tests/test_models.py b/experimenter/experimenter/experiments/tests/test_models.py index 777405996..3669a3c1b 100644 --- a/experimenter/experimenter/experiments/tests/test_models.py +++ b/experimenter/experimenter/experiments/tests/test_models.py @@ -44,6 +44,7 @@ NimbusFeatureConfig, NimbusFeatureVersion, NimbusIsolationGroup, + NimbusRolloutPlanTemplate, NimbusVersionedSchema, Tag, ) @@ -55,6 +56,7 @@ NimbusExperimentFactory, NimbusFeatureConfigFactory, NimbusIsolationGroupFactory, + NimbusRolloutPhaseFactory, NimbusVersionedSchemaFactory, ) from experimenter.experiments.tests.jexl_utils import validate_jexl_expr @@ -6772,3 +6774,234 @@ def test_was_sent_recently_returns_false_when_no_alert(self): experiment, NimbusConstants.AlertType.ANALYSIS_ERROR ) ) + + +class TestNimbusRolloutPhase(TestCase): + def test_str(self): + phase = NimbusRolloutPhaseFactory.create(population_percent=25) + phase.refresh_from_db() + self.assertEqual(str(phase), "Rollout phase (25.0000%)") + + def test_can_edit_schedule_draft(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + self.assertTrue(experiment.can_edit_schedule()) + + def test_can_edit_schedule_live_rollout(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_rollout=True, + ) + self.assertTrue(experiment.can_edit_schedule()) + + def test_can_edit_schedule_completed(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.ENDING_APPROVE_APPROVE, + is_rollout=True, + ) + self.assertFalse(experiment.can_edit_schedule()) + + def test_duration_days_and_display(self): + phase = NimbusRolloutPhaseFactory.build( + start_date=datetime.date(2026, 1, 1), + end_date=datetime.date(2026, 1, 15), + ) + self.assertEqual(phase.duration_days, 14) + self.assertEqual(phase.duration_display, "14 days") + + phase.end_date = datetime.date(2026, 1, 6) + self.assertEqual(phase.duration_days, 5) + self.assertEqual(phase.duration_display, "5 days") + + def test_duration_none_without_dates(self): + phase = NimbusRolloutPhaseFactory.build(start_date=None, end_date=None) + self.assertIsNone(phase.duration_days) + self.assertIsNone(phase.duration_display) + + def test_days_elapsed(self): + today = datetime.date.today() + day = datetime.timedelta(days=1) + + phase = NimbusRolloutPhaseFactory.build( + start_date=today - 3 * day, end_date=today + 3 * day + ) + self.assertEqual(phase.days_elapsed, 3) + + phase = NimbusRolloutPhaseFactory.build( + start_date=today - 10 * day, end_date=today - day + ) + self.assertEqual(phase.days_elapsed, 10) + + phase = NimbusRolloutPhaseFactory.build(start_date=None, end_date=None) + self.assertEqual(phase.days_elapsed, 0) + + def test_clone_copies_rollout_phases(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + NimbusRolloutPhaseFactory.create(experiment=experiment, population_percent=10) + NimbusRolloutPhaseFactory.create(experiment=experiment, population_percent=100) + + cloned = experiment.clone("Cloned Schedule", experiment.owner) + + self.assertEqual(cloned.rollout_phases.count(), 2) + self.assertEqual( + list(cloned.rollout_phases.values_list("population_percent", flat=True)), + [Decimal("10.0000"), Decimal("100.0000")], + ) + + +class TestNimbusRolloutPlanTemplate(TestCase): + def test_str(self): + template = NimbusRolloutPlanTemplate.objects.create(name="High risk", phases=[]) + self.assertEqual(str(template), "High risk") + + def test_summary(self): + self.assertEqual( + NimbusRolloutPlanTemplate.summary([1, 10, 50, 100]), + "1% → 10% → 50% → 100%", + ) + + def test_summary_trims_trailing_zeros(self): + self.assertEqual(NimbusRolloutPlanTemplate.summary([2.5, 100.0]), "2.5% → 100%") + + +class TestRolloutPhaseProgress(TestCase): + def test_annotated_rollout_phases_marks_progress(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_rollout=True, + ) + phases = [ + NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=percent + ) + for percent in (1, 10, 50, 100) + ] + experiment.rollout_phase = phases[2] + experiment.save() + statuses = [p.card_status for p in experiment.annotated_rollout_phases()] + self.assertEqual(statuses, ["complete", "complete", "in_progress", "not_started"]) + + def test_annotated_rollout_phases_marks_progress_after_step_down(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_rollout=True, + ) + phases = [ + NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=percent + ) + for percent in (50, 100, 25) + ] + experiment.rollout_phase = phases[1] + experiment.save() + statuses = [p.card_status for p in experiment.annotated_rollout_phases()] + self.assertEqual(statuses, ["complete", "in_progress", "not_started"]) + + def test_annotated_rollout_phases_all_not_started_when_no_current_phase(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + for percent in (1, 10): + NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=percent + ) + statuses = [p.card_status for p in experiment.annotated_rollout_phases()] + self.assertEqual(statuses, ["not_started", "not_started"]) + + +class TestAdvanceRolloutPhase(TestCase): + def setUp(self): + self.experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_rollout=True, + ) + self.phases = [ + NimbusRolloutPhaseFactory.create( + experiment=self.experiment, + population_percent=percent, + start_date=None, + end_date=None, + ) + for percent in (1, 10, 100) + ] + + def test_advance_from_no_phase_starts_first_phase(self): + today = datetime.date.today() + self.experiment.advance_rollout_phase() + self.experiment.refresh_from_db() + self.phases[0].refresh_from_db() + + self.assertEqual(self.experiment.rollout_phase, self.phases[0]) + self.assertIsNone(self.experiment.rollout_phase_next) + self.assertEqual(self.phases[0].actual_start_date, today) + self.assertIsNone(self.phases[0].start_date) + + def test_advance_stamps_dates_and_moves_pointer(self): + today = datetime.date.today() + planned_start = datetime.date(2026, 1, 1) + self.phases[1].start_date = planned_start + self.phases[1].save() + self.experiment.rollout_phase = self.phases[0] + self.experiment.save() + + 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.phases[0].end_date, today) + self.assertEqual(self.phases[1].actual_start_date, today) + 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) + + def test_completing_phase_finalizes_start_to_actual(self): + today = datetime.date.today() + self.phases[0].start_date = datetime.date(2099, 1, 1) + self.phases[0].save() + + self.experiment.advance_rollout_phase() + self.experiment.advance_rollout_phase() + self.phases[0].refresh_from_db() + + self.assertEqual(self.phases[0].actual_start_date, today) + self.assertEqual(self.phases[0].start_date, today) + self.assertEqual(self.phases[0].end_date, today) + + def test_advance_past_last_phase_clears_next(self): + self.experiment.rollout_phase = self.phases[2] + self.experiment.save() + + self.experiment.advance_rollout_phase() + self.experiment.refresh_from_db() + self.phases[2].refresh_from_db() + + self.assertEqual(self.phases[2].end_date, datetime.date.today()) + self.assertEqual(self.experiment.rollout_phase, self.phases[2]) + self.assertIsNone(self.experiment.rollout_phase_next) + + def test_advance_with_no_phases_is_noop(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + experiment.advance_rollout_phase() + experiment.refresh_from_db() + self.assertIsNone(experiment.rollout_phase) + self.assertIsNone(experiment.rollout_phase_next) + + def test_clone_resets_phase_pointers(self): + self.experiment.rollout_phase = self.phases[1] + self.experiment.rollout_phase_next = self.phases[2] + self.experiment.save() + + cloned = self.experiment.clone("Cloned Phase Pointers", self.experiment.owner) + + self.assertIsNone(cloned.rollout_phase) + self.assertIsNone(cloned.rollout_phase_next) diff --git a/experimenter/experimenter/nimbus_ui/constants.py b/experimenter/experimenter/nimbus_ui/constants.py index 3221efc12..1b4a005f4 100644 --- a/experimenter/experimenter/nimbus_ui/constants.py +++ b/experimenter/experimenter/nimbus_ui/constants.py @@ -332,6 +332,24 @@ class NimbusUIConstants: checklist.""" QA_TICKET_URL = "https://mozilla-hub.atlassian.net/secure/CreateIssueDetails!init.jspa?pid=10212&issuetype=11290" + ERROR_ROLLOUT_PLAN_NAME_DUPLICATE = "A rollout plan with this name already exists." + ERROR_ROLLOUT_PHASE_DATE_ORDER = "The end date must be on or after the start date." + ERROR_ROLLOUT_PHASE_DATE_INCOMPLETE = ( + "Set both a start and an end date or leave both blank." + ) + ERROR_ROLLOUT_PHASE_LOCKED = "This rollout phase is locked and cannot be changed." + ROLLOUT_TEMPLATE_PLANS = {"Medium risk": [1, 10, 50, 100]} + ROLLOUT_ADVANCE_OBSERVATIONS_LABEL = ( + "Move to next phase of rollout if these observations occur" + ) + ROLLOUT_PAUSE_OBSERVATIONS_LABEL = "Pause rollout if these observations occur" + ROLLOUT_PHASE_FIELDS = ("start_date", "end_date", "population_percent") + + class RolloutPhaseStatus: + NOT_STARTED = "not_started" + IN_PROGRESS = "in_progress" + COMPLETE = "complete" + class MetricAreaType: PRIMARY = { "label": "Primary Metric", diff --git a/experimenter/experimenter/nimbus_ui/new/forms.py b/experimenter/experimenter/nimbus_ui/new/forms.py index b0b7a10fc..c4bcf9e51 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 decimal import Decimal import markus from django import forms @@ -20,6 +21,8 @@ NimbusExperimentBranchThroughExcluded, NimbusExperimentBranchThroughRequired, NimbusFeatureConfig, + NimbusRolloutPhase, + NimbusRolloutPlanTemplate, Tag, ) from experimenter.nimbus_ui.constants import NimbusUIConstants @@ -848,6 +851,246 @@ def get_changelog_message(self): return f"{self.request.user} updated collaborators" +class RolloutPhaseForm(forms.ModelForm): + start_date = forms.DateField( + required=False, + widget=forms.DateInput( + attrs={ + "type": "text", + "class": "form-control", + "placeholder": "From", + "onfocus": "this.type='date'", + } + ), + ) + end_date = forms.DateField( + required=False, + widget=forms.DateInput( + attrs={ + "type": "text", + "class": "form-control", + "placeholder": "To", + "onfocus": "this.type='date'", + } + ), + ) + population_percent = forms.DecimalField( + required=False, + min_value=0, + max_value=100, + widget=forms.NumberInput(attrs={"class": "form-control", "min": 0, "max": 100}), + ) + + class Meta: + model = NimbusRolloutPhase + fields = ("start_date", "end_date", "population_percent") + + def clean(self): + cleaned_data = super().clean() + if self.fields["start_date"].disabled: + return cleaned_data + start_date = cleaned_data.get("start_date") + end_date = cleaned_data.get("end_date") + if bool(start_date) != bool(end_date): + self.add_error( + "end_date" if start_date else "start_date", + NimbusUIConstants.ERROR_ROLLOUT_PHASE_DATE_INCOMPLETE, + ) + elif start_date and end_date and end_date < start_date: + self.add_error("end_date", NimbusUIConstants.ERROR_ROLLOUT_PHASE_DATE_ORDER) + return cleaned_data + + +class RolloutScheduleForm(NimbusChangeLogFormMixin, forms.ModelForm): + rollout_plan = forms.ChoiceField( + required=False, + widget=forms.Select(attrs={"class": "form-select"}), + ) + template_name = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "Name this rollout plan", + } + ), + ) + rollout_advance_observations = forms.CharField( + required=False, + widget=forms.Textarea( + attrs={ + "class": "form-control", + "rows": 4, + "placeholder": "Describe observations here", + } + ), + ) + rollout_pause_observations = forms.CharField( + required=False, + widget=forms.Textarea( + attrs={ + "class": "form-control", + "rows": 4, + "placeholder": "Describe observations here", + } + ), + ) + + class Meta: + model = NimbusExperiment + fields = ("rollout_advance_observations", "rollout_pause_observations") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.NimbusRolloutPhaseFormSet = inlineformset_factory( + NimbusExperiment, + NimbusRolloutPhase, + form=RolloutPhaseForm, + extra=0, + ) + self.rollout_phases = self.NimbusRolloutPhaseFormSet( + data=self.data or None, + instance=self.instance, + ) + self.plans = self.available_plans() + self.fields["rollout_plan"].choices = [("", "None")] + [ + (name, f"{name} ({NimbusRolloutPlanTemplate.summary(phases)})") + for name, phases in self.plans.items() + ] + self.fields["rollout_plan"].widget.attrs.update( + { + "hx-post": reverse( + "nimbus-ui-new-apply-rollout-plan", + kwargs={"slug": self.instance.slug}, + ), + "hx-trigger": "change", + "hx-target": "#rollout-schedule-body", + "hx-swap": "outerHTML", + "hx-include": "closest form", + } + ) + + phase_statuses = { + phase.id: phase.card_status + for phase in self.instance.annotated_rollout_phases() + } + self.locked_phase_ids = { + phase_id + for phase_id, status in phase_statuses.items() + if status + in ( + NimbusUIConstants.RolloutPhaseStatus.COMPLETE, + NimbusUIConstants.RolloutPhaseStatus.IN_PROGRESS, + ) + } + for phase_form in self.rollout_phases.forms: + status = phase_statuses.get(phase_form.instance.pk) + phase_form.is_locked = phase_form.instance.pk in self.locked_phase_ids + if status == NimbusUIConstants.RolloutPhaseStatus.COMPLETE: + disabled_fields = NimbusUIConstants.ROLLOUT_PHASE_FIELDS + elif status == NimbusUIConstants.RolloutPhaseStatus.IN_PROGRESS: + disabled_fields = ("population_percent",) + else: + disabled_fields = () + for field_name in disabled_fields: + phase_form.fields[field_name].disabled = True + + @staticmethod + def available_plans(): + plans = dict(NimbusUIConstants.ROLLOUT_TEMPLATE_PLANS) + for template in NimbusRolloutPlanTemplate.objects.all(): + plans[template.name] = template.phases + return plans + + def is_valid(self): + return super().is_valid() and self.rollout_phases.is_valid() + + @transaction.atomic + def save(self): + experiment = super().save() + self.rollout_phases.save() + return experiment + + def get_changelog_message(self): + return f"{self.request.user} updated rollout schedule" + + +class RolloutPhaseCreateForm(RolloutScheduleForm): + @transaction.atomic + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.instance.rollout_phases.create() + return self.instance + + def get_changelog_message(self): + return f"{self.request.user} added a rollout phase" + + +class RolloutPhaseDeleteForm(RolloutScheduleForm): + phase_id = forms.ModelChoiceField(queryset=NimbusRolloutPhase.objects.all()) + + class Meta: + model = NimbusExperiment + fields = ["phase_id"] + + def clean_phase_id(self): + phase = self.cleaned_data["phase_id"] + if phase.pk in self.locked_phase_ids: + raise forms.ValidationError(NimbusUIConstants.ERROR_ROLLOUT_PHASE_LOCKED) + return phase + + @transaction.atomic + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.cleaned_data["phase_id"].delete() + return self.instance + + def get_changelog_message(self): + return f"{self.request.user} removed a rollout phase" + + +class RolloutPlanApplyForm(RolloutScheduleForm): + @transaction.atomic + def save(self, *args, **kwargs): + experiment = super().save(*args, **kwargs) + plan_name = self.cleaned_data.get("rollout_plan") + if plan_name and plan_name in self.plans: + experiment.rollout_phases.exclude(id__in=self.locked_phase_ids).delete() + for population_percent in self.plans[plan_name]: + experiment.rollout_phases.create( + population_percent=Decimal(str(population_percent)) + ) + return experiment + + def get_changelog_message(self): + return f"{self.request.user} applied a rollout plan" + + +class RolloutPlanCreateForm(RolloutScheduleForm): + def clean_template_name(self): + name = (self.cleaned_data.get("template_name") or "").strip() + if name and name in self.plans: + raise forms.ValidationError( + NimbusUIConstants.ERROR_ROLLOUT_PLAN_NAME_DUPLICATE + ) + return name + + @transaction.atomic + def save(self): + experiment = super().save() + name = self.cleaned_data.get("template_name") + if name: + phases = [ + float(phase.population_percent) + for phase in experiment.rollout_phases.all() + ] + NimbusRolloutPlanTemplate.objects.create(name=name, phases=phases) + return experiment + + def get_changelog_message(self): + return f"{self.request.user} created a rollout plan template" + + class SubscribeForm(NimbusChangeLogFormMixin, forms.ModelForm): class Meta: model = NimbusExperiment diff --git a/experimenter/experimenter/nimbus_ui/new/views.py b/experimenter/experimenter/nimbus_ui/new/views.py index 3957f86c7..67b3a519d 100644 --- a/experimenter/experimenter/nimbus_ui/new/views.py +++ b/experimenter/experimenter/nimbus_ui/new/views.py @@ -18,8 +18,13 @@ RolloutAudienceForm, RolloutFeaturesForm, RolloutOverviewForm, + RolloutPhaseCreateForm, + RolloutPhaseDeleteForm, + RolloutPlanApplyForm, + RolloutPlanCreateForm, RolloutQAStatusForm, RolloutRisksForm, + RolloutScheduleForm, SubscribeForm, TagAssignForm, UnsubscribeForm, @@ -293,6 +298,44 @@ class NewRemoveSubscriberView(NewSubscriberView): add = False +class NewRolloutScheduleUpdateView(NewCardUpdateView): + form_class = RolloutScheduleForm + display_template = "new/rollouts/schedule/card.html" + template_name = "new/rollouts/schedule/edit_form.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + selected_plan = self.request.POST.get("template_name") or self.request.POST.get( + "rollout_plan" + ) + if selected_plan: + context["form"].initial["rollout_plan"] = selected_plan + return context + + def can_edit(self): + return self.object.can_edit_schedule() + + +class NewRolloutPhaseCreateView( + RenderParentDBResponseMixin, NewRolloutScheduleUpdateView +): + form_class = RolloutPhaseCreateForm + + +class NewRolloutPhaseDeleteView( + RenderParentDBResponseMixin, NewRolloutScheduleUpdateView +): + form_class = RolloutPhaseDeleteForm + + +class NewRolloutPlanCreateView(RenderParentDBResponseMixin, NewRolloutScheduleUpdateView): + form_class = RolloutPlanCreateForm + + +class NewRolloutPlanApplyView(RenderParentDBResponseMixin, NewRolloutScheduleUpdateView): + form_class = RolloutPlanApplyForm + + class NewSubscribeView(NimbusExperimentViewMixin, RequestFormMixin, UpdateView): model = NimbusExperiment form_class = SubscribeForm diff --git a/experimenter/experimenter/nimbus_ui/templates/new/rollouts/rollout_detail.html b/experimenter/experimenter/nimbus_ui/templates/new/rollouts/rollout_detail.html index e5f7caa2a..9fc58effe 100644 --- a/experimenter/experimenter/nimbus_ui/templates/new/rollouts/rollout_detail.html +++ b/experimenter/experimenter/nimbus_ui/templates/new/rollouts/rollout_detail.html @@ -28,9 +28,7 @@
Build
{# TODO EXP-6685: Rollout Features Form/Card #} {% include "new/rollouts/detail_card.html" with card_id="rollout-features" title="Rollout Features" subtitle="Describe the rollout experience" show_edit_btn=True body_template="new/rollouts/rollout_features/card.html" %} - - {# TODO EXP-6685: Rollout Schedule Form/Card #} - {% include "new/rollouts/detail_card.html" with card_id="schedule" title="Rollout schedule" subtitle="1% → 10% → 50% → 100%" %} + {% include "new/rollouts/detail_card.html" with card_id="schedule" title="Rollout schedule" subtitle="Gradually increase the rollout population in phases" show_edit_btn=True body_template="new/rollouts/schedule/card.html" %} {# ── Check ── #} diff --git a/experimenter/experimenter/nimbus_ui/templates/new/rollouts/schedule/card.html b/experimenter/experimenter/nimbus_ui/templates/new/rollouts/schedule/card.html new file mode 100644 index 000000000..111f0e454 --- /dev/null +++ b/experimenter/experimenter/nimbus_ui/templates/new/rollouts/schedule/card.html @@ -0,0 +1,109 @@ +
+ {% with phases=experiment.annotated_rollout_phases %} + {% if phases %} + + + + + + + + + + {% for phase in phases %} + + {# Rollout Phase #} + + {# Population Ratio #} + + {# Estimated Duration #} + + + {% endfor %} + +
Rollout PhasePopulation RatioEstimated Duration
+ + Phase {{ forloop.counter }} + {% if phase.card_status == "in_progress" and experiment.is_paused %} + Paused + {% endif %} + + +
{{ phase.population_percent|floatformat:"-2" }}%
+
+
+
+
+ {% if phase.card_status == "in_progress" %} + {% if phase.end_date %} +
+ {{ phase.days_elapsed }}/{{ phase.duration_days }} days complete + + Ending on {{ phase.end_date|date:"M j, Y" }} +
+
+
+
+ {% else %} +
+ In progress + + {{ phase.days_elapsed }} day{{ phase.days_elapsed|pluralize }} running +
+
+
+
+ {% endif %} + {% elif phase.card_status == "complete" %} +
+ Completed + {% if phase.actual_start_date and phase.end_date %} + + {{ phase.actual_start_date|date:"M j" }} - {{ phase.end_date|date:"M j, Y" }} + {% endif %} +
+
+
+
+ {% else %} +
+ Not started + {% if phase.duration_display %} + + {{ phase.duration_display }} + {% endif %} + {% if phase.start_date and phase.end_date %} + + {{ phase.start_date|date:"M j" }} - {{ phase.end_date|date:"M j, Y" }} + {% endif %} +
+
+
+
+ {% endif %} +
+ {% endif %} + {% endwith %} +
+ {# Move to next phase observations #} +
+
+ {{ NimbusUIConstants.ROLLOUT_ADVANCE_OBSERVATIONS_LABEL }} +
+
{{ experiment.rollout_advance_observations|default:"—" }}
+
+ {# Pause rollout observations #} +
+
+ {{ NimbusUIConstants.ROLLOUT_PAUSE_OBSERVATIONS_LABEL }} +
+
{{ experiment.rollout_pause_observations|default:"—" }}
+
+
+
diff --git a/experimenter/experimenter/nimbus_ui/templates/new/rollouts/schedule/edit_form.html b/experimenter/experimenter/nimbus_ui/templates/new/rollouts/schedule/edit_form.html new file mode 100644 index 000000000..3f9ebae70 --- /dev/null +++ b/experimenter/experimenter/nimbus_ui/templates/new/rollouts/schedule/edit_form.html @@ -0,0 +1,138 @@ +{% load widget_tweaks %} + +{# ── Edit form ── #} +
+
+ {% csrf_token %} + {# Choose rollout plan #} +
+ +
+
{{ form.rollout_plan|add_class:"form-select"|add_error_class:"is-invalid" }}
+ +
+
+
+ {{ form.template_name|add_class:"form-control"|add_error_class:"is-invalid" }} + + {% if form.template_name.errors %} +
{{ form.template_name.errors|join:", " }}
+ {% endif %} +
+
+
+ {{ form.rollout_phases.management_form }} +
+ {% for phase_form in form.rollout_phases %} +
+ {{ phase_form.id }} +
+ {# Phase Number #} + Phase {{ forloop.counter }} + {# Population in % #} +
+ +
+ {{ phase_form.population_percent|add_class:"form-control"|add_error_class:"is-invalid" }} + % +
+ {% if phase_form.population_percent.errors %} +
+ {{ phase_form.population_percent.errors|join:", " }} +
+ {% endif %} +
+ {# Duration #} +
+ +
+
+ {{ phase_form.start_date|add_class:"form-control"|add_error_class:"is-invalid" }} +
+
+ {{ phase_form.end_date|add_class:"form-control"|add_error_class:"is-invalid" }} +
+
+ {% if phase_form.start_date.errors %} +
+ {{ phase_form.start_date.errors|join:", " }} +
+ {% elif phase_form.end_date.errors %} +
+ {{ phase_form.end_date.errors|join:", " }} +
+ {% endif %} +
+ {% if not phase_form.is_locked %} + + {% endif %} +
+
+ {% endfor %} +
+ +
+
+ {# Move to next phase observations #} +
+ + {{ form.rollout_advance_observations|add_class:"form-control"|add_error_class:"is-invalid" }} + {% if form.rollout_advance_observations.errors %} +
{{ form.rollout_advance_observations.errors|join:", " }}
+ {% endif %} +
+ {# Pause rollout observations #} +
+ + {{ form.rollout_pause_observations|add_class:"form-control"|add_error_class:"is-invalid" }} + {% if form.rollout_pause_observations.errors %} +
{{ form.rollout_pause_observations.errors|join:", " }}
+ {% endif %} +
+ {# Action buttons #} +
+ + +
+
+
diff --git a/experimenter/experimenter/nimbus_ui/tests/test_new_forms.py b/experimenter/experimenter/nimbus_ui/tests/test_new_forms.py index 052a87755..fe8bd8b78 100644 --- a/experimenter/experimenter/nimbus_ui/tests/test_new_forms.py +++ b/experimenter/experimenter/nimbus_ui/tests/test_new_forms.py @@ -34,6 +34,7 @@ RolloutAudienceForm, RolloutFeaturesForm, RolloutOverviewForm, + RolloutPhaseForm, RolloutQAStatusForm, RolloutRisksForm, TagAssignForm, @@ -987,3 +988,59 @@ 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 TestRolloutPhaseForm(TestCase): + def test_end_date_before_start_date_is_invalid(self): + form = RolloutPhaseForm( + data={ + "start_date": "2026-02-01", + "end_date": "2026-01-01", + "population_percent": "10", + } + ) + self.assertFalse(form.is_valid()) + self.assertIn( + NimbusUIConstants.ERROR_ROLLOUT_PHASE_DATE_ORDER, + form.errors["end_date"], + ) + + def test_end_date_equal_to_start_date_is_valid(self): + form = RolloutPhaseForm( + data={ + "start_date": "2026-01-01", + "end_date": "2026-01-01", + "population_percent": "10", + } + ) + self.assertTrue(form.is_valid(), form.errors) + + def test_dates_optional_so_blank_is_valid(self): + form = RolloutPhaseForm(data={"population_percent": "10"}) + self.assertTrue(form.is_valid(), form.errors) + + def test_start_date_without_end_date_is_invalid(self): + form = RolloutPhaseForm(data={"start_date": "2026-01-15"}) + self.assertFalse(form.is_valid()) + self.assertIn( + NimbusUIConstants.ERROR_ROLLOUT_PHASE_DATE_INCOMPLETE, + form.errors["end_date"], + ) + + def test_end_date_without_start_date_is_invalid(self): + form = RolloutPhaseForm(data={"end_date": "2026-01-15"}) + self.assertFalse(form.is_valid()) + self.assertIn( + NimbusUIConstants.ERROR_ROLLOUT_PHASE_DATE_INCOMPLETE, + form.errors["start_date"], + ) + + def test_negative_population_percent_is_invalid(self): + form = RolloutPhaseForm(data={"population_percent": "-1"}) + self.assertFalse(form.is_valid()) + self.assertIn("population_percent", form.errors) + + def test_population_percent_over_100_is_invalid(self): + form = RolloutPhaseForm(data={"population_percent": "101"}) + self.assertFalse(form.is_valid()) + self.assertIn("population_percent", form.errors) diff --git a/experimenter/experimenter/nimbus_ui/tests/test_new_views.py b/experimenter/experimenter/nimbus_ui/tests/test_new_views.py index c4d3fe0b6..228397464 100644 --- a/experimenter/experimenter/nimbus_ui/tests/test_new_views.py +++ b/experimenter/experimenter/nimbus_ui/tests/test_new_views.py @@ -1,3 +1,6 @@ +import datetime +from decimal import Decimal + from django.conf import settings from django.test import TestCase from django.urls import reverse @@ -8,12 +11,17 @@ LanguageFactory, LocaleFactory, ) -from experimenter.experiments.models import NimbusExperiment +from experimenter.experiments.models import ( + NimbusExperiment, + NimbusRolloutPlanTemplate, +) from experimenter.experiments.tests.factories import ( NimbusDocumentationLinkFactory, NimbusExperimentFactory, + NimbusRolloutPhaseFactory, TagFactory, ) +from experimenter.nimbus_ui.constants import NimbusUIConstants from experimenter.nimbus_ui.new.forms import ( NimbusExperimentCreateForm, NimbusExperimentSidebarCloneForm, @@ -621,6 +629,391 @@ def test_post_removes_subscriber(self): self.assertNotIn(user, experiment.subscribers.all()) +class TestNewRolloutScheduleUpdateView(AuthTestCase): + url_name = "nimbus-ui-new-update-schedule" + + def test_get_returns_edit_form_for_draft(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + response = self.client.get( + reverse(self.url_name, kwargs={"slug": experiment.slug}) + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "new/rollouts/schedule/edit_form.html") + + def test_get_editable_when_live_rollout(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_rollout=True, + ) + response = self.client.get( + reverse(self.url_name, kwargs={"slug": experiment.slug}) + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "new/rollouts/schedule/edit_form.html") + + def test_get_shows_builtin_plan(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + response = self.client.get( + reverse(self.url_name, kwargs={"slug": experiment.slug}) + ) + for name, phases in NimbusUIConstants.ROLLOUT_TEMPLATE_PLANS.items(): + self.assertContains(response, name) + self.assertContains(response, NimbusRolloutPlanTemplate.summary(phases)) + + def test_post_valid_saves_and_returns_display_card(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + phase = NimbusRolloutPhaseFactory.create(experiment=experiment) + response = self.client.post( + reverse(self.url_name, kwargs={"slug": experiment.slug}), + { + "rollout_phases-TOTAL_FORMS": "2", + "rollout_phases-INITIAL_FORMS": "1", + "rollout_phases-0-id": phase.id, + "rollout_phases-0-start_date": "2026-01-15", + "rollout_phases-0-end_date": "2026-01-29", + "rollout_phases-0-population_percent": "25", + "rollout_advance_observations": "Test rollout advance observations", + "rollout_pause_observations": "Test rollout pause observations", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "new/rollouts/schedule/card.html") + experiment.refresh_from_db() + phase = experiment.rollout_phases.get() + self.assertEqual(phase.start_date, datetime.date(2026, 1, 15)) + self.assertEqual(phase.end_date, datetime.date(2026, 1, 29)) + self.assertEqual(phase.population_percent, 25) + self.assertEqual( + experiment.rollout_advance_observations, "Test rollout advance observations" + ) + self.assertEqual( + experiment.rollout_pause_observations, "Test rollout pause observations" + ) + self.assertTrue(response.context["hx_swap_oob"]) + + def test_post_in_progress_phase_shows_progress(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_rollout=True, + ) + + today = datetime.date.today() + start = today - datetime.timedelta(days=3) + end = today + datetime.timedelta(days=4) + phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, start_date=start, end_date=end + ) + experiment.rollout_phase = phase + experiment.save() + response = self.client.post( + reverse(self.url_name, kwargs={"slug": experiment.slug}), + { + "rollout_phases-TOTAL_FORMS": "1", + "rollout_phases-INITIAL_FORMS": "1", + "rollout_phases-0-id": phase.id, + "rollout_phases-0-start_date": start.isoformat(), + "rollout_phases-0-end_date": end.isoformat(), + "rollout_phases-0-population_percent": "50", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "new/rollouts/schedule/card.html") + self.assertContains(response, "3/7 days complete") + + def test_post_can_change_in_progress_phase_dates(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_rollout=True, + ) + phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, + population_percent=50, + start_date=datetime.date(2026, 1, 1), + end_date=datetime.date(2026, 1, 15), + ) + experiment.rollout_phase = phase + experiment.save() + response = self.client.post( + reverse(self.url_name, kwargs={"slug": experiment.slug}), + { + "rollout_phases-TOTAL_FORMS": "1", + "rollout_phases-INITIAL_FORMS": "1", + "rollout_phases-0-id": phase.id, + "rollout_phases-0-start_date": "2026-02-01", + "rollout_phases-0-end_date": "2026-02-20", + "rollout_phases-0-population_percent": "90", + }, + ) + self.assertEqual(response.status_code, 200) + phase.refresh_from_db() + self.assertEqual(phase.start_date, datetime.date(2026, 2, 1)) + self.assertEqual(phase.end_date, datetime.date(2026, 2, 20)) + self.assertEqual(phase.population_percent, 50) + + +class TestNewRolloutPhaseCreateView(AuthTestCase): + url_name = "nimbus-ui-new-create-rollout-phase" + + def test_post_adds_phase_row_and_persists(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + response = self.client.post( + reverse(self.url_name, kwargs={"slug": experiment.slug}), + { + "rollout_phases-TOTAL_FORMS": "0", + "rollout_phases-INITIAL_FORMS": "0", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "new/rollouts/schedule/edit_form.html") + self.assertEqual(response.context["form"].rollout_phases.total_form_count(), 1) + self.assertEqual(experiment.rollout_phases.count(), 1) + + def test_post_on_non_editable_experiment_redirects(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + ) + response = self.client.post( + reverse(self.url_name, kwargs={"slug": experiment.slug}), + {"rollout_phases-TOTAL_FORMS": "0", "rollout_phases-INITIAL_FORMS": "0"}, + ) + self.assertIn("HX-Redirect", response.headers) + + +class TestNewRolloutPhaseDeleteView(AuthTestCase): + url_name = "nimbus-ui-new-delete-rollout-phase" + + def test_post_removes_phase_row_and_persists(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + phase = NimbusRolloutPhaseFactory.create(experiment=experiment) + response = self.client.post( + reverse(self.url_name, kwargs={"slug": experiment.slug}), + { + "rollout_phases-TOTAL_FORMS": "1", + "rollout_phases-INITIAL_FORMS": "1", + "rollout_phases-0-id": phase.id, + "rollout_phases-0-start_date": "2026-01-15", + "rollout_phases-0-end_date": "2026-01-29", + "rollout_phases-0-population_percent": "25", + "phase_id": str(phase.id), + }, + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "new/rollouts/schedule/edit_form.html") + self.assertEqual(response.context["form"].rollout_phases.total_form_count(), 0) + self.assertEqual(experiment.rollout_phases.count(), 0) + + def test_post_does_not_delete_locked_phase(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_rollout=True, + ) + done = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=10 + ) + current = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=50 + ) + experiment.rollout_phase = current + experiment.save() + response = self.client.post( + reverse(self.url_name, kwargs={"slug": experiment.slug}), + { + "rollout_phases-TOTAL_FORMS": "2", + "rollout_phases-INITIAL_FORMS": "2", + "rollout_phases-0-id": done.id, + "rollout_phases-0-population_percent": "10", + "rollout_phases-1-id": current.id, + "rollout_phases-1-population_percent": "50", + "phase_id": str(done.id), + }, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(experiment.rollout_phases.filter(id=done.id).exists()) + + +class TestNewRolloutPlanApplyView(AuthTestCase): + url_name = "nimbus-ui-new-apply-rollout-plan" + + def test_post_applies_plan_phases_and_persists(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + + plan_name, plan_percentages = next( + iter(NimbusUIConstants.ROLLOUT_TEMPLATE_PLANS.items()) + ) + phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=99 + ) + response = self.client.post( + reverse(self.url_name, kwargs={"slug": experiment.slug}), + { + "rollout_phases-TOTAL_FORMS": "1", + "rollout_phases-INITIAL_FORMS": "1", + "rollout_phases-0-id": phase.id, + "rollout_phases-0-population_percent": "99", + "rollout_plan": plan_name, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "new/rollouts/schedule/edit_form.html") + self.assertEqual( + list(experiment.rollout_phases.values_list("population_percent", flat=True)), + [Decimal(pct) for pct in plan_percentages], + ) + + def test_post_no_plan_leaves_phases_unchanged(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + phase = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=99 + ) + response = self.client.post( + reverse(self.url_name, kwargs={"slug": experiment.slug}), + { + "rollout_phases-TOTAL_FORMS": "1", + "rollout_phases-INITIAL_FORMS": "1", + "rollout_phases-0-id": phase.id, + "rollout_phases-0-population_percent": "99", + "rollout_plan": "", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(experiment.rollout_phases.count(), 1) + + def test_post_applies_plan_with_completed_phase_with_inconsistent_dates(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_rollout=True, + ) + plan_name, plan_percentages = next( + iter(NimbusUIConstants.ROLLOUT_TEMPLATE_PLANS.items()) + ) + done = NimbusRolloutPhaseFactory.create( + experiment=experiment, + population_percent=1, + start_date=datetime.date(2026, 7, 5), + end_date=datetime.date(2026, 7, 2), + actual_start_date=datetime.date(2026, 7, 2), + ) + current = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=10 + ) + experiment.rollout_phase = current + experiment.save() + + response = self.client.post( + reverse(self.url_name, kwargs={"slug": experiment.slug}), + { + "rollout_phases-TOTAL_FORMS": "2", + "rollout_phases-INITIAL_FORMS": "2", + "rollout_phases-0-id": done.id, + "rollout_phases-1-id": current.id, + "rollout_plan": plan_name, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(experiment.rollout_phases.filter(id=done.id).exists()) + self.assertTrue(experiment.rollout_phases.filter(id=current.id).exists()) + percents = list( + experiment.rollout_phases.values_list("population_percent", flat=True) + ) + for pct in plan_percentages: + self.assertIn(Decimal(pct), percents) + + +class TestNewRolloutPlanCreateView(AuthTestCase): + url_name = "nimbus-ui-new-create-rollout-plan" + + def test_post_saves_submitted_phases_as_template(self): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.CREATED, + is_rollout=True, + ) + phase1 = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=5 + ) + phase2 = NimbusRolloutPhaseFactory.create( + experiment=experiment, population_percent=25 + ) + plan_name = next(iter(NimbusUIConstants.ROLLOUT_TEMPLATE_PLANS)) + + response = self.client.post( + reverse(self.url_name, kwargs={"slug": experiment.slug}), + { + "rollout_phases-TOTAL_FORMS": "2", + "rollout_phases-INITIAL_FORMS": "2", + "rollout_phases-0-id": phase1.id, + "rollout_phases-0-population_percent": "7", + "rollout_phases-1-id": phase2.id, + "rollout_phases-1-population_percent": "30", + "rollout_plan": plan_name, + "template_name": "My custom plan", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "new/rollouts/schedule/edit_form.html") + + template = NimbusRolloutPlanTemplate.objects.get(name="My custom plan") + self.assertEqual(template.phases, [7.0, 30.0]) + self.assertContains(response, '