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 @@
| Rollout Phase | +Population Ratio | +Estimated Duration | +
|---|---|---|
| + + Phase {{ forloop.counter }} + {% if phase.card_status == "in_progress" and experiment.is_paused %} + Paused + {% endif %} + + | + {# Population Ratio #} +
+ {{ phase.population_percent|floatformat:"-2" }}%
+
+
+
+ |
+ {# Estimated Duration #}
+
+ {% 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 %}
+ |
+