Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'),
),
]
146 changes: 146 additions & 0 deletions experimenter/experimenter/experiments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -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 [
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -2757,6 +2837,72 @@ def __str__(self):
return f"{self.title} ({self.link})"


class NimbusRolloutPhase(models.Model):
Comment thread
RJAK11 marked this conversation as resolved.
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
Expand Down
13 changes: 13 additions & 0 deletions experimenter/experimenter/experiments/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
NimbusExperimentBranchThroughRequired,
NimbusFeatureConfig,
NimbusIsolationGroup,
NimbusRolloutPhase,
NimbusVersionedSchema,
Tag,
)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading