diff --git a/docs/experimenter/openapi-schema.json b/docs/experimenter/openapi-schema.json index fd612a14b..00b6a603e 100644 --- a/docs/experimenter/openapi-schema.json +++ b/docs/experimenter/openapi-schema.json @@ -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": [ diff --git a/docs/experimenter/swagger-ui.html b/docs/experimenter/swagger-ui.html index c0a67b2b3..9fe957dce 100644 --- a/docs/experimenter/swagger-ui.html +++ b/docs/experimenter/swagger-ui.html @@ -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": [ diff --git a/experimenter/experimenter/experiments/api/v6/serializers.py b/experimenter/experimenter/experiments/api/v6/serializers.py index f0c2c3087..f8b2b849a 100644 --- a/experimenter/experimenter/experiments/api/v6/serializers.py +++ b/experimenter/experimenter/experiments/api/v6/serializers.py @@ -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): diff --git a/experimenter/experimenter/experiments/api/v8/serializers.py b/experimenter/experimenter/experiments/api/v8/serializers.py index 3013bab1b..00b08fbaa 100644 --- a/experimenter/experimenter/experiments/api/v8/serializers.py +++ b/experimenter/experimenter/experiments/api/v8/serializers.py @@ -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): diff --git a/experimenter/experimenter/experiments/migrations/0274_nimbusbranch_firefox_labs_title_and_more.py b/experimenter/experimenter/experiments/migrations/0274_nimbusbranch_firefox_labs_title_and_more.py new file mode 100644 index 000000000..77eacd0df --- /dev/null +++ b/experimenter/experimenter/experiments/migrations/0274_nimbusbranch_firefox_labs_title_and_more.py @@ -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" + ), + ), + ] diff --git a/experimenter/experimenter/experiments/models.py b/experimenter/experimenter/experiments/models.py index 4d6d073b3..a5f3bb7e3 100644 --- a/experimenter/experimenter/experiments/models.py +++ b/experimenter/experimenter/experiments/models.py @@ -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" diff --git a/experimenter/experimenter/experiments/tests/api/v6/test_serializers.py b/experimenter/experimenter/experiments/tests/api/v6/test_serializers.py index b1a42fa6d..576d55d78 100644 --- a/experimenter/experimenter/experiments/tests/api/v6/test_serializers.py +++ b/experimenter/experimenter/experiments/tests/api/v6/test_serializers.py @@ -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, + } diff --git a/experimenter/experimenter/experiments/tests/api/v8/test_serializers.py b/experimenter/experimenter/experiments/tests/api/v8/test_serializers.py index 3a31f8ca0..7f23820a8 100644 --- a/experimenter/experimenter/experiments/tests/api/v8/test_serializers.py +++ b/experimenter/experimenter/experiments/tests/api/v8/test_serializers.py @@ -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, + } diff --git a/experimenter/experimenter/experiments/tests/factories.py b/experimenter/experimenter/experiments/tests/factories.py index fa97bd067..f8e95c7a4 100644 --- a/experimenter/experimenter/experiments/tests/factories.py +++ b/experimenter/experimenter/experiments/tests/factories.py @@ -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 diff --git a/experimenter/experimenter/experiments/tests/test_changelog_utils.py b/experimenter/experimenter/experiments/tests/test_changelog_utils.py index 696390403..dd71380cb 100644 --- a/experimenter/experimenter/experiments/tests/test_changelog_utils.py +++ b/experimenter/experimenter/experiments/tests/test_changelog_utils.py @@ -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, )