Skip to content
Merged
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,18 @@
# Generated by Django 5.2.7 on 2025-11-03 21:21

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('experiments', '0298_convert_projects_to_tags'),
]

operations = [
migrations.AddField(
model_name='nimbusversionedschema',
name='allow_coenrollment',
field=models.BooleanField(default=False),
),
]
17 changes: 17 additions & 0 deletions experimenter/experimenter/experiments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2197,6 +2197,22 @@ class Meta:
def __str__(self): # pragma: no cover
return f"{self.branch}: {self.feature_config}"

@property
def allow_coenrollment(self):
min_version = NimbusExperiment.Version.parse(
self.branch.experiment.firefox_min_version
)
max_version = None
if self.branch.experiment.firefox_max_version:
max_version = NimbusExperiment.Version.parse(
self.branch.experiment.firefox_max_version
)
schemas = self.feature_config.get_versioned_schema_range(
min_version,
max_version,
).schemas
return all(schema.allow_coenrollment for schema in schemas)


class NimbusBranchScreenshot(models.Model):
branch = models.ForeignKey(
Expand Down Expand Up @@ -2626,6 +2642,7 @@ class NimbusVersionedSchema(models.Model):
null=True,
)
schema = models.TextField(blank=True, null=True)
allow_coenrollment = models.BooleanField(null=False, default=False)

# Desktop-only
set_pref_vars = models.JSONField[dict[str, str]](null=False, default=dict)
Expand Down
45 changes: 45 additions & 0 deletions experimenter/experimenter/experiments/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,51 @@
from experimenter.projects.tests.factories import ProjectFactory


class TestNimbusBranchFeatureValue(TestCase):
@mock_valid_features
def setUp(self):
Features.clear_cache()
call_command("load_feature_configs")

def test_feature_with_coenrollment(self):
feature = NimbusFeatureConfig.objects.get(slug="someFeature")
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.CREATED,
feature_configs=[feature],
)
self.assertTrue(
experiment.reference_branch.feature_values.get(
feature_config=feature
).allow_coenrollment
)

def test_feature_without_coenrollment(self):
feature = NimbusFeatureConfig.objects.get(slug="missingVariables")
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.CREATED,
feature_configs=[feature],
)
self.assertFalse(
experiment.reference_branch.feature_values.get(
feature_config=feature
).allow_coenrollment
)

def test_feature_with_max_version(self):
feature = NimbusFeatureConfig.objects.get(slug="missingVariables")
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.CREATED,
feature_configs=[feature],
firefox_min_version=NimbusExperiment.Version.FIREFOX_139,
firefox_max_version=NimbusExperiment.Version.FIREFOX_140,
)
self.assertFalse(
experiment.reference_branch.feature_values.get(
feature_config=feature
).allow_coenrollment
)


class TestNimbusExperimentManager(TestCase):
def test_sorted_by_latest_change(self):
older_experiment = NimbusExperimentFactory.create()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ def handle(self, *args, **options):
isinstance(feature.model, DesktopFeature)
and feature.model.is_early_startup
)
allow_coenrollment = feature.model.allow_coenrollment

feature_version: Optional[NimbusFeatureVersion] = None
feature_version_id: Optional[int] = None
Expand All @@ -170,6 +171,7 @@ def handle(self, *args, **options):
schema = NimbusVersionedSchema(
feature_config=feature_config,
version=feature_version,
allow_coenrollment=allow_coenrollment,
is_early_startup=is_early_startup,
set_pref_vars={},
)
Expand All @@ -183,6 +185,10 @@ def handle(self, *args, **options):
schema.schema = jsonschema
dirty_fields.append("schema")

if schema.allow_coenrollment != allow_coenrollment:
schema.allow_coenrollment = allow_coenrollment
dirty_fields.append("allow_coenrollment")

if feature_config.application == Application.DESKTOP:
set_pref_vars = {
var_name: _set_pref_name(var.set_pref)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def test_loads_new_feature_configs(self):
)
self.assertFalse(schema.has_remote_schema)
self.assertTrue(schema.is_early_startup)
self.assertTrue(schema.allow_coenrollment)

feature_config = NimbusFeatureConfig.objects.get(slug="oldSetPrefFeature")
schema = feature_config.schemas.get(version=None)
Expand Down Expand Up @@ -111,8 +112,7 @@ def test_updates_existing_feature_configs(self):
application=NimbusExperiment.Application.DESKTOP,
schemas=[
NimbusVersionedSchemaFactory.build(
version=None,
schema="{}",
version=None, schema="{}", allow_coenrollment=False
)
],
)
Expand Down Expand Up @@ -150,6 +150,7 @@ def test_updates_existing_feature_configs(self):
},
)
self.assertTrue(schema.is_early_startup)
self.assertTrue(schema.allow_coenrollment)

feature_config = NimbusFeatureConfig.objects.get(slug="oldSetPrefFeature")
schema = feature_config.schemas.get(version=None)
Expand Down
4 changes: 4 additions & 0 deletions experimenter/experimenter/nimbus_ui/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ class NimbusUIConstants:
"project_impact": """Set an impact rating so others can understand the scale of
this experiment's effect.""",
}
COENROLLMENT_NOTE = (
"Note: This feature supports co-enrollment with other experiments/rollouts "
"for the selected versions."
)

class ReviewRequestMessages(Enum):
END_EXPERIMENT = "end this experiment"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ <h6>
{% if branch.feature_values.all %}
{% for feature_value in branch.feature_values.all %}
<tr>
<th class="text-nowrap">{{ feature_value.feature_config.name|format_not_set }} Value</th>
<th class="text-nowrap">
{{ feature_value.feature_config.name|format_not_set }} Value
{% if feature_value.allow_coenrollment %}<div class="form-text">{{ coenrollment_note }}</div>{% endif %}
</th>
<td colspan="3"
id="preview-recipe-json"
class="collapsed-json"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,12 @@ <h4>Branches</h4>
{{ branch_feature_values_form.id }}
<div class="row mt-3">
<div class="col">
<label class="form-label">{{ branch_feature_values_form.instance.feature_config.name }}</label>
<label class="form-label">
{{ branch_feature_values_form.instance.feature_config.name }}
{% if branch_feature_values_form.instance.allow_coenrollment %}
<div class="form-text">{{ coenrollment_note }}</div>
{% endif %}
</label>
{{ branch_feature_values_form.value|add_error_class:"is-invalid" }}
{% for error in branch_feature_values_form.value.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
Expand Down
7 changes: 7 additions & 0 deletions experimenter/experimenter/nimbus_ui/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ def get_context_data(self, **kwargs):
if "save_failed" in self.request.GET:
context["save_failed"] = True

context["coenrollment_note"] = NimbusUIConstants.COENROLLMENT_NOTE

return context


Expand Down Expand Up @@ -507,6 +509,11 @@ class BranchesBaseView(
def can_edit(self):
return self.object.can_edit_branches()

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["coenrollment_note"] = NimbusUIConstants.COENROLLMENT_NOTE
return context


class BranchesPartialUpdateView(RenderDBResponseMixin, BranchesBaseView):
pass
Expand Down