feat(nimbus): Firefox Labs Model and API changes (#11697)

Because

- We want to add the fields from the updated `NimbusExperiment` schema
to the `NimbusExperiment` model to be available internally.

This commit

- Updates `NimbusExperiment` model.
- Updates `NimbusBranchSerializerDesktop` model.
- Updates `v6` and `v8` serializer classes with the above fields.
- Generates new db migration for the above fields.

Fixes #11556
This commit is contained in:
Herraj Luhano 2024-11-07 16:31:25 -05:00 коммит произвёл GitHub
Родитель 4b23a0ad7d
Коммит 5488b455af
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
10 изменённых файлов: 323 добавлений и 48 удалений

Просмотреть файл

@ -4237,6 +4237,18 @@
"publishedDate": {
"type": "string",
"format": "date-time"
},
"isFirefoxLabsOptIn": {
"type": "string",
"readOnly": true
},
"firefoxLabsTitle": {
"type": "string",
"readOnly": true
},
"firefoxLabsDescription": {
"type": "string",
"readOnly": true
}
},
"required": [

Просмотреть файл

@ -4249,6 +4249,18 @@
"publishedDate": {
"type": "string",
"format": "date-time"
},
"isFirefoxLabsOptIn": {
"type": "string",
"readOnly": true
},
"firefoxLabsTitle": {
"type": "string",
"readOnly": true
},
"firefoxLabsDescription": {
"type": "string",
"readOnly": true
}
},
"required": [

Просмотреть файл

@ -55,10 +55,11 @@ class NimbusBranchSerializer(serializers.ModelSerializer):
class NimbusBranchSerializerDesktop(NimbusBranchSerializer):
feature = serializers.SerializerMethodField()
firefoxLabsTitle = serializers.ReadOnlyField(source="firefox_labs_title")
class Meta:
model = NimbusBranch
fields = ("slug", "ratio", "feature", "features")
fields = ("slug", "ratio", "feature", "features", "firefoxLabsTitle")
def get_feature(self, obj):
return {
@ -111,6 +112,9 @@ class NimbusExperimentSerializer(serializers.ModelSerializer):
localizations = serializers.SerializerMethodField()
locales = serializers.SerializerMethodField()
publishedDate = serializers.DateTimeField(source="published_date")
isFirefoxLabsOptIn = serializers.ReadOnlyField(source="is_firefox_labs_opt_in")
firefoxLabsTitle = serializers.ReadOnlyField(source="firefox_labs_title")
firefoxLabsDescription = serializers.ReadOnlyField(source="firefox_labs_description")
class Meta:
model = NimbusExperiment
@ -143,6 +147,9 @@ class NimbusExperimentSerializer(serializers.ModelSerializer):
"localizations",
"locales",
"publishedDate",
"isFirefoxLabsOptIn",
"firefoxLabsTitle",
"firefoxLabsDescription",
)
def get_application(self, obj):

Просмотреть файл

@ -55,10 +55,11 @@ class NimbusBranchSerializer(serializers.ModelSerializer):
class NimbusBranchSerializerDesktop(NimbusBranchSerializer):
feature = serializers.SerializerMethodField()
firefoxLabsTitle = serializers.ReadOnlyField(source="firefox_labs_title")
class Meta:
model = NimbusBranch
fields = ("slug", "ratio", "feature", "features")
fields = ("slug", "ratio", "feature", "features", "firefoxLabsTitle")
def get_feature(self, obj):
return {
@ -112,6 +113,9 @@ class NimbusExperimentSerializer(serializers.ModelSerializer):
localizations = serializers.SerializerMethodField()
locales = serializers.SerializerMethodField()
publishedDate = serializers.DateTimeField(source="published_date")
isFirefoxLabsOptIn = serializers.ReadOnlyField(source="is_firefox_labs_opt_in")
firefoxLabsTitle = serializers.ReadOnlyField(source="firefox_labs_title")
firefoxLabsDescription = serializers.ReadOnlyField(source="firefox_labs_description")
class Meta:
model = NimbusExperiment
@ -145,6 +149,9 @@ class NimbusExperimentSerializer(serializers.ModelSerializer):
"localizations",
"locales",
"publishedDate",
"isFirefoxLabsOptIn",
"firefoxLabsTitle",
"firefoxLabsDescription",
)
def get_application(self, obj):

Просмотреть файл

@ -0,0 +1,47 @@
# Generated by Django 5.1.1 on 2024-11-05 21:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("experiments", "0273_nimbusexperiment_segments"),
]
operations = [
migrations.AddField(
model_name="nimbusbranch",
name="firefox_labs_title",
field=models.TextField(
blank=True,
null=True,
verbose_name="An optional string containing the Fluent ID for the title of the opt-in",
),
),
migrations.AddField(
model_name="nimbusexperiment",
name="firefox_labs_description",
field=models.TextField(
blank=True,
null=True,
verbose_name="An optional string containing the Fluent ID for the description of the opt-in",
),
),
migrations.AddField(
model_name="nimbusexperiment",
name="firefox_labs_title",
field=models.TextField(
blank=True,
null=True,
verbose_name="An optional string containing the Fluent ID for the title of the opt-in",
),
),
migrations.AddField(
model_name="nimbusexperiment",
name="is_firefox_labs_opt_in",
field=models.BooleanField(
default=False, verbose_name="Is Experiment a Firefox Labs opt-n"
),
),
]

Просмотреть файл

@ -370,6 +370,20 @@ class NimbusExperiment(NimbusConstants, TargetingConstants, FilterMixin, models.
)
use_group_id = models.BooleanField(default=False)
objects = NimbusExperimentManager()
is_firefox_labs_opt_in = models.BooleanField(
"Is Experiment a Firefox Labs opt-n", default=False
)
firefox_labs_title = models.TextField(
"An optional string containing the Fluent ID for the title of the opt-in",
blank=True,
null=True,
)
firefox_labs_description = models.TextField(
"An optional string containing the Fluent ID "
"for the description of the opt-in",
blank=True,
null=True,
)
class Meta:
verbose_name = "Nimbus Experiment"
@ -1356,6 +1370,11 @@ class NimbusBranch(models.Model):
slug = models.SlugField(max_length=NimbusConstants.MAX_SLUG_LEN, null=False)
description = models.TextField(blank=True, default="")
ratio = models.PositiveIntegerField(default=1)
firefox_labs_title = models.TextField(
"An optional string containing the Fluent ID for the title of the opt-in",
blank=True,
null=True,
)
class Meta:
verbose_name = "Nimbus Branch"

Просмотреть файл

@ -1,5 +1,6 @@
import datetime
import json
from typing import Any
from django.conf import settings
from django.test import TestCase
@ -36,6 +37,9 @@ class TestNimbusExperimentSerializer(TestCase):
secondary_outcomes=["quux", "xyzzy"],
locales=[locale_en_us],
_enrollment_end_date=datetime.date(2022, 1, 5),
is_firefox_labs_opt_in=False,
firefox_labs_title=None,
firefox_labs_description=None,
)
serializer = NimbusExperimentSerializer(experiment)
experiment_data = serializer.data.copy()
@ -48,6 +52,10 @@ class TestNimbusExperimentSerializer(TestCase):
assert experiment.end_date
min_required_version = NimbusExperiment.MIN_REQUIRED_VERSION
expected_experiment_data = self._experiment_data_without_branches_and_featureIds(
experiment, min_required_version
)
self.assertDictEqual(experiment_data, expected_experiment_data)
self.assertDictEqual(
experiment_data,
@ -92,6 +100,9 @@ class TestNimbusExperimentSerializer(TestCase):
"localizations": None,
"locales": ["en-US"],
"publishedDate": experiment.published_date,
"isFirefoxLabsOptIn": False,
"firefoxLabsTitle": None,
"firefoxLabsDescription": None,
},
)
@ -129,12 +140,56 @@ class TestNimbusExperimentSerializer(TestCase):
}
for fv in branch.feature_values.all()
],
"firefoxLabsTitle": branch.firefox_labs_title,
},
branches_data,
)
NimbusExperimentSchema.model_validate(serializer.data)
def test_expected_schema_with_desktop_with_non_default_fxlabs_fields(self):
locale_en_us = LocaleFactory.create(code="en-US")
application = NimbusExperiment.Application.DESKTOP
feature1 = NimbusFeatureConfigFactory.create(application=application)
feature2 = NimbusFeatureConfigFactory.create(application=application)
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.ENDING_APPROVE_APPROVE,
application=application,
firefox_min_version=NimbusExperiment.MIN_REQUIRED_VERSION,
feature_configs=[feature1, feature2],
targeting_config_slug=NimbusExperiment.TargetingConfig.NO_TARGETING,
channel=NimbusExperiment.Channel.NIGHTLY,
primary_outcomes=["foo", "bar", "baz"],
secondary_outcomes=["quux", "xyzzy"],
segments=["segment1", "segment2"],
locales=[locale_en_us],
_enrollment_end_date=datetime.date(2022, 1, 5),
is_firefox_labs_opt_in=True,
firefox_labs_title="test-fx-labs-title",
firefox_labs_description="test-fx-labs-description",
)
serializer = NimbusExperimentSerializer(experiment)
experiment_data = serializer.data.copy()
min_required_version = NimbusExperiment.MIN_REQUIRED_VERSION
expected_experiment_data = self._experiment_data_without_branches_and_featureIds(
experiment, min_required_version
)
expected_experiment_data.update(
{
"isFirefoxLabsOptIn": True,
"firefoxLabsTitle": "test-fx-labs-title",
"firefoxLabsDescription": "test-fx-labs-description",
}
)
# popping these since this test is not asserting on these
experiment_data.pop("bucketConfig")
experiment_data.pop("branches")
experiment_data.pop("featureIds")
self.assertDictEqual(experiment_data, expected_experiment_data)
def test_enrollment_end_date_none_while_live_enrolling(self):
locale_en_us = LocaleFactory.create(code="en-US")
application = NimbusExperiment.Application.DESKTOP
@ -370,3 +425,52 @@ class TestNimbusExperimentSerializer(TestCase):
self.assertIsNone(serializer.data["localizations"])
else:
self.assertEqual(serializer.data["localizations"], expected)
def _experiment_data_without_branches_and_featureIds(
self, experiment_data, min_required_version
) -> dict[str, Any]:
return {
"arguments": {},
"application": "firefox-desktop",
"appName": "firefox_desktop",
"appId": "firefox-desktop",
"channel": "nightly",
# DRF manually replaces the isoformat suffix so we have to do the same
"startDate": experiment_data.start_date.isoformat().replace("+00:00", "Z"),
"enrollmentEndDate": (
experiment_data.actual_enrollment_end_date.isoformat().replace(
"+00:00", "Z"
)
),
"endDate": experiment_data.end_date.isoformat().replace("+00:00", "Z"),
"id": experiment_data.slug,
"isEnrollmentPaused": True,
"isRollout": False,
"proposedDuration": experiment_data.proposed_duration,
"proposedEnrollment": experiment_data.proposed_enrollment,
"referenceBranch": experiment_data.reference_branch.slug,
"schemaVersion": settings.NIMBUS_SCHEMA_VERSION,
"slug": experiment_data.slug,
"targeting": (
f'(browserSettings.update.channel == "nightly") '
f"&& (version|versionCompare('{min_required_version}') >= 0) "
f"&& (locale in ['en-US'])"
),
"userFacingDescription": experiment_data.public_description,
"userFacingName": experiment_data.name,
"probeSets": [],
"outcomes": [
{"priority": "primary", "slug": "foo"},
{"priority": "primary", "slug": "bar"},
{"priority": "primary", "slug": "baz"},
{"priority": "secondary", "slug": "quux"},
{"priority": "secondary", "slug": "xyzzy"},
],
"featureValidationOptOut": experiment_data.is_client_schema_disabled,
"localizations": None,
"locales": ["en-US"],
"publishedDate": experiment_data.published_date,
"isFirefoxLabsOptIn": False,
"firefoxLabsTitle": None,
"firefoxLabsDescription": None,
}

Просмотреть файл

@ -1,5 +1,6 @@
import datetime
import json
from typing import Any
from django.conf import settings
from django.test import TestCase
@ -37,6 +38,9 @@ class TestNimbusExperimentSerializer(TestCase):
segments=["segment1", "segment2"],
locales=[locale_en_us],
_enrollment_end_date=datetime.date(2022, 1, 5),
is_firefox_labs_opt_in=False,
firefox_labs_title=None,
firefox_labs_description=None,
)
serializer = NimbusExperimentSerializer(experiment)
experiment_data = serializer.data.copy()
@ -49,53 +53,10 @@ class TestNimbusExperimentSerializer(TestCase):
assert experiment.end_date
min_required_version = NimbusExperiment.MIN_REQUIRED_VERSION
self.assertDictEqual(
experiment_data,
{
"arguments": {},
"application": "firefox-desktop",
"appName": "firefox_desktop",
"appId": "firefox-desktop",
"channel": "nightly",
# DRF manually replaces the isoformat suffix so we have to do the same
"startDate": experiment.start_date.isoformat().replace("+00:00", "Z"),
"enrollmentEndDate": (
experiment.actual_enrollment_end_date.isoformat().replace(
"+00:00", "Z"
)
),
"endDate": experiment.end_date.isoformat().replace("+00:00", "Z"),
"id": experiment.slug,
"isEnrollmentPaused": True,
"isRollout": False,
"proposedDuration": experiment.proposed_duration,
"proposedEnrollment": experiment.proposed_enrollment,
"referenceBranch": experiment.reference_branch.slug,
"schemaVersion": settings.NIMBUS_SCHEMA_VERSION,
"slug": experiment.slug,
"targeting": (
f'(browserSettings.update.channel == "nightly") '
f"&& (version|versionCompare('{min_required_version}') >= 0) "
f"&& (locale in ['en-US'])"
),
"userFacingDescription": experiment.public_description,
"userFacingName": experiment.name,
"probeSets": [],
"outcomes": [
{"priority": "primary", "slug": "foo"},
{"priority": "primary", "slug": "bar"},
{"priority": "primary", "slug": "baz"},
{"priority": "secondary", "slug": "quux"},
{"priority": "secondary", "slug": "xyzzy"},
],
"segments": [{"slug": "segment1"}, {"slug": "segment2"}],
"featureValidationOptOut": experiment.is_client_schema_disabled,
"localizations": None,
"locales": ["en-US"],
"publishedDate": experiment.published_date,
},
expected_experiment_data = self._experiment_data_without_branches_and_featureIds(
experiment, min_required_version
)
self.assertDictEqual(experiment_data, expected_experiment_data)
self.assertEqual(set(feature_ids_data), {feature1.slug, feature2.slug})
@ -131,12 +92,56 @@ class TestNimbusExperimentSerializer(TestCase):
}
for fv in branch.feature_values.all()
],
"firefoxLabsTitle": branch.firefox_labs_title,
},
branches_data,
)
NimbusExperimentSchema.model_validate(serializer.data)
def test_expected_schema_with_desktop_with_non_default_fxlabs_fields(self):
locale_en_us = LocaleFactory.create(code="en-US")
application = NimbusExperiment.Application.DESKTOP
feature1 = NimbusFeatureConfigFactory.create(application=application)
feature2 = NimbusFeatureConfigFactory.create(application=application)
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.ENDING_APPROVE_APPROVE,
application=application,
firefox_min_version=NimbusExperiment.MIN_REQUIRED_VERSION,
feature_configs=[feature1, feature2],
targeting_config_slug=NimbusExperiment.TargetingConfig.NO_TARGETING,
channel=NimbusExperiment.Channel.NIGHTLY,
primary_outcomes=["foo", "bar", "baz"],
secondary_outcomes=["quux", "xyzzy"],
segments=["segment1", "segment2"],
locales=[locale_en_us],
_enrollment_end_date=datetime.date(2022, 1, 5),
is_firefox_labs_opt_in=True,
firefox_labs_title="test-fx-labs-title",
firefox_labs_description="test-fx-labs-description",
)
serializer = NimbusExperimentSerializer(experiment)
experiment_data = serializer.data.copy()
min_required_version = NimbusExperiment.MIN_REQUIRED_VERSION
expected_experiment_data = self._experiment_data_without_branches_and_featureIds(
experiment, min_required_version
)
expected_experiment_data.update(
{
"isFirefoxLabsOptIn": True,
"firefoxLabsTitle": "test-fx-labs-title",
"firefoxLabsDescription": "test-fx-labs-description",
}
)
# popping these since this test is not asserting on these
experiment_data.pop("bucketConfig")
experiment_data.pop("branches")
experiment_data.pop("featureIds")
self.assertDictEqual(experiment_data, expected_experiment_data)
def test_enrollment_end_date_none_while_live_enrolling(self):
locale_en_us = LocaleFactory.create(code="en-US")
application = NimbusExperiment.Application.DESKTOP
@ -374,3 +379,53 @@ class TestNimbusExperimentSerializer(TestCase):
self.assertIsNone(serializer.data["localizations"])
else:
self.assertEqual(serializer.data["localizations"], expected)
def _experiment_data_without_branches_and_featureIds(
self, experiment_data, min_required_version
) -> dict[str, Any]:
return {
"arguments": {},
"application": "firefox-desktop",
"appName": "firefox_desktop",
"appId": "firefox-desktop",
"channel": "nightly",
# DRF manually replaces the isoformat suffix so we have to do the same
"startDate": experiment_data.start_date.isoformat().replace("+00:00", "Z"),
"enrollmentEndDate": (
experiment_data.actual_enrollment_end_date.isoformat().replace(
"+00:00", "Z"
)
),
"endDate": experiment_data.end_date.isoformat().replace("+00:00", "Z"),
"id": experiment_data.slug,
"isEnrollmentPaused": True,
"isRollout": False,
"proposedDuration": experiment_data.proposed_duration,
"proposedEnrollment": experiment_data.proposed_enrollment,
"referenceBranch": experiment_data.reference_branch.slug,
"schemaVersion": settings.NIMBUS_SCHEMA_VERSION,
"slug": experiment_data.slug,
"targeting": (
f'(browserSettings.update.channel == "nightly") '
f"&& (version|versionCompare('{min_required_version}') >= 0) "
f"&& (locale in ['en-US'])"
),
"userFacingDescription": experiment_data.public_description,
"userFacingName": experiment_data.name,
"probeSets": [],
"outcomes": [
{"priority": "primary", "slug": "foo"},
{"priority": "primary", "slug": "bar"},
{"priority": "primary", "slug": "baz"},
{"priority": "secondary", "slug": "quux"},
{"priority": "secondary", "slug": "xyzzy"},
],
"segments": [{"slug": "segment1"}, {"slug": "segment2"}],
"featureValidationOptOut": experiment_data.is_client_schema_disabled,
"localizations": None,
"locales": ["en-US"],
"publishedDate": experiment_data.published_date,
"isFirefoxLabsOptIn": False,
"firefoxLabsTitle": None,
"firefoxLabsDescription": None,
}

Просмотреть файл

@ -471,6 +471,9 @@ class NimbusExperimentFactory(factory.django.DjangoModelFactory):
qa_status = factory.LazyAttribute(
lambda o: random.choice(list(NimbusExperiment.QAStatus)).value
)
is_firefox_labs_opt_in = factory.LazyAttribute(lambda o: False)
firefox_labs_title = factory.LazyAttribute(lambda o: faker.catch_phrase())
firefox_labs_description = factory.LazyAttribute(lambda o: faker.catch_phrase())
class Meta:
model = NimbusExperiment
@ -707,6 +710,7 @@ class NimbusBranchFactory(factory.django.DjangoModelFactory):
lambda o: slugify(o.name)[: NimbusExperiment.MAX_SLUG_LEN]
)
description = factory.LazyAttribute(lambda o: faker.text())
firefox_labs_title = factory.LazyAttribute(lambda o: faker.catch_phrase())
class Meta:
model = NimbusBranch

Просмотреть файл

@ -58,9 +58,12 @@ class TestNimbusExperimentChangeLogSerializer(TestCase):
"feature_configs": [],
"firefox_max_version": NimbusExperiment.Version.NO_VERSION,
"firefox_min_version": NimbusExperiment.Version.NO_VERSION,
"firefox_labs_title": experiment.firefox_labs_title,
"firefox_labs_description": experiment.firefox_labs_description,
"hypothesis": NimbusExperiment.HYPOTHESIS_DEFAULT,
"is_archived": experiment.is_archived,
"is_client_schema_disabled": experiment.is_client_schema_disabled,
"is_firefox_labs_opt_in": experiment.is_firefox_labs_opt_in,
"is_first_run": experiment.is_first_run,
"is_localized": experiment.is_localized,
"is_paused": False,
@ -166,9 +169,12 @@ class TestNimbusExperimentChangeLogSerializer(TestCase):
"excluded_experiments": [],
"firefox_max_version": experiment.firefox_max_version,
"firefox_min_version": experiment.firefox_min_version,
"firefox_labs_title": experiment.firefox_labs_title,
"firefox_labs_description": experiment.firefox_labs_description,
"hypothesis": experiment.hypothesis,
"is_archived": experiment.is_archived,
"is_client_schema_disabled": experiment.is_client_schema_disabled,
"is_firefox_labs_opt_in": experiment.is_firefox_labs_opt_in,
"is_first_run": experiment.is_first_run,
"is_localized": experiment.is_localized,
"is_paused": experiment.is_paused,
@ -268,6 +274,7 @@ class TestNimbusExperimentChangeLogSerializer(TestCase):
"feature_config": experiment.feature_configs.get().id,
}
],
"firefox_labs_title": experiment.reference_branch.firefox_labs_title,
},
)
@ -285,6 +292,7 @@ class TestNimbusExperimentChangeLogSerializer(TestCase):
"feature_config": experiment.feature_configs.get().id,
}
],
"firefox_labs_title": branch.firefox_labs_title,
},
branches_data,
)