feat(project): create v8 api for segments support (#11300)

Because

* We need to extend experiments to support segment functionality
* We want to be able to update the API for Jetstream without affecting
the clients that consume v6

This commit

* Creates a new v8 api based on the existing v6 api to support adding
segments

Fixes #11290
This commit is contained in:
Rana Al-Khulaidi 2024-09-10 10:49:16 -04:00 коммит произвёл GitHub
Родитель c8b14364b5
Коммит 98f4409a43
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
14 изменённых файлов: 1638 добавлений и 69 удалений

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

@ -1993,6 +1993,348 @@
]
}
},
"/api/v8/experiments/": {
"get": {
"operationId": "listNimbusExperiments",
"description": "",
"parameters": [
{
"name": "is_localized",
"required": false,
"in": "query",
"description": "is_localized",
"schema": {
"type": "string"
}
},
{
"name": "is_first_run",
"required": false,
"in": "query",
"description": "is_first_run",
"schema": {
"type": "string"
}
},
{
"name": "status",
"required": false,
"in": "query",
"description": "status",
"schema": {
"type": "string",
"enum": [
"Draft",
"Preview",
"Live",
"Complete"
]
}
},
{
"name": "application",
"required": false,
"in": "query",
"description": "application",
"schema": {
"type": "string",
"enum": [
"firefox-desktop",
"fenix",
"ios",
"focus-android",
"klar-android",
"focus-ios",
"klar-ios",
"monitor-web",
"vpn-web",
"fxa-web",
"demo-app"
]
}
},
{
"name": "feature_config",
"required": false,
"in": "query",
"description": "feature_config",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NimbusExperiment"
}
}
}
},
"description": ""
}
},
"tags": [
"Nimbus: Public Analysis"
]
}
},
"/api/v8/experiments/{slug}/": {
"get": {
"operationId": "retrieveNimbusExperiment",
"description": "",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"description": "",
"schema": {
"type": "string"
}
},
{
"name": "is_localized",
"required": false,
"in": "query",
"description": "is_localized",
"schema": {
"type": "string"
}
},
{
"name": "is_first_run",
"required": false,
"in": "query",
"description": "is_first_run",
"schema": {
"type": "string"
}
},
{
"name": "status",
"required": false,
"in": "query",
"description": "status",
"schema": {
"type": "string",
"enum": [
"Draft",
"Preview",
"Live",
"Complete"
]
}
},
{
"name": "application",
"required": false,
"in": "query",
"description": "application",
"schema": {
"type": "string",
"enum": [
"firefox-desktop",
"fenix",
"ios",
"focus-android",
"klar-android",
"focus-ios",
"klar-ios",
"monitor-web",
"vpn-web",
"fxa-web",
"demo-app"
]
}
},
{
"name": "feature_config",
"required": false,
"in": "query",
"description": "feature_config",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NimbusExperiment"
}
}
},
"description": ""
}
},
"tags": [
"Nimbus: Public Analysis"
]
}
},
"/api/v8/draft-experiments/": {
"get": {
"operationId": "listNimbusExperiments",
"description": "",
"parameters": [
{
"name": "is_localized",
"required": false,
"in": "query",
"description": "is_localized",
"schema": {
"type": "string"
}
},
{
"name": "is_first_run",
"required": false,
"in": "query",
"description": "is_first_run",
"schema": {
"type": "string"
}
},
{
"name": "application",
"required": false,
"in": "query",
"description": "application",
"schema": {
"type": "string",
"enum": [
"firefox-desktop",
"fenix",
"ios",
"focus-android",
"klar-android",
"focus-ios",
"klar-ios",
"monitor-web",
"vpn-web",
"fxa-web",
"demo-app"
]
}
},
{
"name": "feature_config",
"required": false,
"in": "query",
"description": "feature_config",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NimbusExperiment"
}
}
}
},
"description": ""
}
},
"tags": [
"Nimbus: Public Analysis"
]
}
},
"/api/v8/draft-experiments/{slug}/": {
"get": {
"operationId": "retrieveNimbusExperiment",
"description": "",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"description": "",
"schema": {
"type": "string"
}
},
{
"name": "is_localized",
"required": false,
"in": "query",
"description": "is_localized",
"schema": {
"type": "string"
}
},
{
"name": "is_first_run",
"required": false,
"in": "query",
"description": "is_first_run",
"schema": {
"type": "string"
}
},
{
"name": "application",
"required": false,
"in": "query",
"description": "application",
"schema": {
"type": "string",
"enum": [
"firefox-desktop",
"fenix",
"ios",
"focus-android",
"klar-android",
"focus-ios",
"klar-ios",
"monitor-web",
"vpn-web",
"fxa-web",
"demo-app"
]
}
},
{
"name": "feature_config",
"required": false,
"in": "query",
"description": "feature_config",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NimbusExperiment"
}
}
},
"description": ""
}
},
"tags": [
"Nimbus: Public Analysis"
]
}
},
"/api/v2/experiments/{slug}/intent-to-ship-email": {
"put": {
"operationId": "updateExperiment",
@ -3845,36 +4187,8 @@
"readOnly": true
},
"branches": {
"type": "array",
"items": {
"type": "object",
"properties": {
"slug": {
"type": "string",
"maxLength": 80,
"pattern": "^[-a-zA-Z0-9_]+$"
},
"ratio": {
"type": "integer",
"maximum": 2147483647,
"minimum": 0
},
"features": {
"type": "string",
"readOnly": true
},
"description": {
"type": "string"
},
"screenshots": {
"type": "string",
"readOnly": true
}
},
"required": [
"slug"
]
}
"type": "string",
"readOnly": true
},
"targeting": {
"type": "string",
@ -3908,11 +4222,11 @@
"type": "string",
"readOnly": true
},
"locales": {
"localizations": {
"type": "string",
"readOnly": true
},
"localizations": {
"locales": {
"type": "string",
"readOnly": true
},
@ -3925,7 +4239,6 @@
"slug",
"channel",
"bucketConfig",
"branches",
"startDate",
"enrollmentEndDate",
"endDate",

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

@ -2005,6 +2005,348 @@
]
}
},
"/api/v8/experiments/": {
"get": {
"operationId": "listNimbusExperiments",
"description": "",
"parameters": [
{
"name": "is_localized",
"required": false,
"in": "query",
"description": "is_localized",
"schema": {
"type": "string"
}
},
{
"name": "is_first_run",
"required": false,
"in": "query",
"description": "is_first_run",
"schema": {
"type": "string"
}
},
{
"name": "status",
"required": false,
"in": "query",
"description": "status",
"schema": {
"type": "string",
"enum": [
"Draft",
"Preview",
"Live",
"Complete"
]
}
},
{
"name": "application",
"required": false,
"in": "query",
"description": "application",
"schema": {
"type": "string",
"enum": [
"firefox-desktop",
"fenix",
"ios",
"focus-android",
"klar-android",
"focus-ios",
"klar-ios",
"monitor-web",
"vpn-web",
"fxa-web",
"demo-app"
]
}
},
{
"name": "feature_config",
"required": false,
"in": "query",
"description": "feature_config",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NimbusExperiment"
}
}
}
},
"description": ""
}
},
"tags": [
"Nimbus: Public Analysis"
]
}
},
"/api/v8/experiments/{slug}/": {
"get": {
"operationId": "retrieveNimbusExperiment",
"description": "",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"description": "",
"schema": {
"type": "string"
}
},
{
"name": "is_localized",
"required": false,
"in": "query",
"description": "is_localized",
"schema": {
"type": "string"
}
},
{
"name": "is_first_run",
"required": false,
"in": "query",
"description": "is_first_run",
"schema": {
"type": "string"
}
},
{
"name": "status",
"required": false,
"in": "query",
"description": "status",
"schema": {
"type": "string",
"enum": [
"Draft",
"Preview",
"Live",
"Complete"
]
}
},
{
"name": "application",
"required": false,
"in": "query",
"description": "application",
"schema": {
"type": "string",
"enum": [
"firefox-desktop",
"fenix",
"ios",
"focus-android",
"klar-android",
"focus-ios",
"klar-ios",
"monitor-web",
"vpn-web",
"fxa-web",
"demo-app"
]
}
},
{
"name": "feature_config",
"required": false,
"in": "query",
"description": "feature_config",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NimbusExperiment"
}
}
},
"description": ""
}
},
"tags": [
"Nimbus: Public Analysis"
]
}
},
"/api/v8/draft-experiments/": {
"get": {
"operationId": "listNimbusExperiments",
"description": "",
"parameters": [
{
"name": "is_localized",
"required": false,
"in": "query",
"description": "is_localized",
"schema": {
"type": "string"
}
},
{
"name": "is_first_run",
"required": false,
"in": "query",
"description": "is_first_run",
"schema": {
"type": "string"
}
},
{
"name": "application",
"required": false,
"in": "query",
"description": "application",
"schema": {
"type": "string",
"enum": [
"firefox-desktop",
"fenix",
"ios",
"focus-android",
"klar-android",
"focus-ios",
"klar-ios",
"monitor-web",
"vpn-web",
"fxa-web",
"demo-app"
]
}
},
{
"name": "feature_config",
"required": false,
"in": "query",
"description": "feature_config",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NimbusExperiment"
}
}
}
},
"description": ""
}
},
"tags": [
"Nimbus: Public Analysis"
]
}
},
"/api/v8/draft-experiments/{slug}/": {
"get": {
"operationId": "retrieveNimbusExperiment",
"description": "",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"description": "",
"schema": {
"type": "string"
}
},
{
"name": "is_localized",
"required": false,
"in": "query",
"description": "is_localized",
"schema": {
"type": "string"
}
},
{
"name": "is_first_run",
"required": false,
"in": "query",
"description": "is_first_run",
"schema": {
"type": "string"
}
},
{
"name": "application",
"required": false,
"in": "query",
"description": "application",
"schema": {
"type": "string",
"enum": [
"firefox-desktop",
"fenix",
"ios",
"focus-android",
"klar-android",
"focus-ios",
"klar-ios",
"monitor-web",
"vpn-web",
"fxa-web",
"demo-app"
]
}
},
{
"name": "feature_config",
"required": false,
"in": "query",
"description": "feature_config",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NimbusExperiment"
}
}
},
"description": ""
}
},
"tags": [
"Nimbus: Public Analysis"
]
}
},
"/api/v2/experiments/{slug}/intent-to-ship-email": {
"put": {
"operationId": "updateExperiment",
@ -3857,36 +4199,8 @@
"readOnly": true
},
"branches": {
"type": "array",
"items": {
"type": "object",
"properties": {
"slug": {
"type": "string",
"maxLength": 80,
"pattern": "^[-a-zA-Z0-9_]+$"
},
"ratio": {
"type": "integer",
"maximum": 2147483647,
"minimum": 0
},
"features": {
"type": "string",
"readOnly": true
},
"description": {
"type": "string"
},
"screenshots": {
"type": "string",
"readOnly": true
}
},
"required": [
"slug"
]
}
"type": "string",
"readOnly": true
},
"targeting": {
"type": "string",
@ -3920,11 +4234,11 @@
"type": "string",
"readOnly": true
},
"locales": {
"localizations": {
"type": "string",
"readOnly": true
},
"localizations": {
"locales": {
"type": "string",
"readOnly": true
},
@ -3937,7 +4251,6 @@
"slug",
"channel",
"bucketConfig",
"branches",
"startDate",
"enrollmentEndDate",
"endDate",

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

@ -33,6 +33,9 @@ class Command(BaseCommand):
elif "/api/v6/" in path:
for method in paths[path]:
paths[path][method]["tags"] = ["Nimbus: Public"]
elif "/api/v8/" in path:
for method in paths[path]:
paths[path][method]["tags"] = ["Nimbus: Public Analysis"]
return json.dumps(schema, indent=2)

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

@ -52,7 +52,7 @@ class NimbusExperimentViewSet(
filter_backends = [DjangoFilterBackend]
filterset_class = NimbusExperimentFilterSet
@method_decorator(cache_page(settings.V6_API_CACHE_DURATION))
@method_decorator(cache_page(settings.API_CACHE_DURATION))
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)

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

@ -46,6 +46,6 @@ class NimbusExperimentViewSet(
filter_backends = [DjangoFilterBackend]
filterset_class = NimbusExperimentFilterSet
@method_decorator(cache_page(settings.V6_API_CACHE_DURATION))
@method_decorator(cache_page(settings.API_CACHE_DURATION))
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)

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

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

@ -0,0 +1,194 @@
import contextlib
import json
from django.conf import settings
from rest_framework import serializers
from experimenter.experiments.models import (
NimbusBranch,
NimbusBucketRange,
NimbusExperiment,
)
class NimbusBucketRangeSerializer(serializers.ModelSerializer):
randomizationUnit = serializers.ReadOnlyField(
source="isolation_group.randomization_unit"
)
namespace = serializers.ReadOnlyField(source="isolation_group.namespace")
total = serializers.ReadOnlyField(source="isolation_group.total")
class Meta:
model = NimbusBucketRange
fields = (
"randomizationUnit",
"namespace",
"start",
"count",
"total",
)
class NimbusBranchSerializer(serializers.ModelSerializer):
features = serializers.SerializerMethodField()
class Meta:
model = NimbusBranch
fields = ("slug", "ratio", "features")
def get_features(self, obj):
features = []
for fv in obj.feature_values.all():
feature_value = {
"featureId": fv.feature_config and fv.feature_config.slug or "",
"enabled": True, # TODO: Remove after Desktop 104 is no longer supported
"value": {},
}
with contextlib.suppress(Exception):
# The value may still be invalid at this time
feature_value["value"] = json.loads(fv.value)
features.append(feature_value)
return features
class NimbusBranchSerializerDesktop(NimbusBranchSerializer):
feature = serializers.SerializerMethodField()
class Meta:
model = NimbusBranch
fields = ("slug", "ratio", "feature", "features")
def get_feature(self, obj):
return {
"featureId": "this-is-included-for-desktop-pre-95-support",
"enabled": False, # TODO: Remove after Desktop 104 is no longer supported
"value": {},
}
class NimbusBranchSerializerMobile(NimbusBranchSerializer):
feature = serializers.SerializerMethodField()
class Meta:
model = NimbusBranch
fields = ("slug", "ratio", "feature", "features")
def get_feature(self, obj):
return {
"featureId": "this-is-included-for-mobile-pre-96-support",
"enabled": False, # TODO: Remove after Desktop 104 is no longer supported
"value": {},
}
class NimbusExperimentSerializer(serializers.ModelSerializer):
schemaVersion = serializers.ReadOnlyField(default=settings.NIMBUS_SCHEMA_VERSION)
id = serializers.ReadOnlyField(source="slug")
arguments = serializers.ReadOnlyField(default={})
application = serializers.SerializerMethodField()
appName = serializers.SerializerMethodField()
appId = serializers.SerializerMethodField()
branches = serializers.SerializerMethodField()
userFacingName = serializers.ReadOnlyField(source="name")
userFacingDescription = serializers.ReadOnlyField(source="public_description")
isEnrollmentPaused = serializers.ReadOnlyField(source="is_paused")
isRollout = serializers.ReadOnlyField(source="is_rollout")
bucketConfig = NimbusBucketRangeSerializer(source="bucket_range")
featureIds = serializers.SerializerMethodField()
probeSets = serializers.ReadOnlyField(default=[])
outcomes = serializers.SerializerMethodField()
startDate = serializers.DateField(source="start_date")
enrollmentEndDate = serializers.DateField(source="actual_enrollment_end_date")
endDate = serializers.DateField(source="end_date")
proposedDuration = serializers.ReadOnlyField(source="proposed_duration")
proposedEnrollment = serializers.ReadOnlyField(source="proposed_enrollment")
referenceBranch = serializers.SerializerMethodField()
featureValidationOptOut = serializers.ReadOnlyField(
source="is_client_schema_disabled"
)
localizations = serializers.SerializerMethodField()
locales = serializers.SerializerMethodField()
publishedDate = serializers.DateTimeField(source="published_date")
class Meta:
model = NimbusExperiment
fields = (
"schemaVersion",
"slug",
"id",
"arguments",
"application",
"appName",
"appId",
"channel",
"userFacingName",
"userFacingDescription",
"isEnrollmentPaused",
"isRollout",
"bucketConfig",
"featureIds",
"probeSets",
"outcomes",
"branches",
"targeting",
"startDate",
"enrollmentEndDate",
"endDate",
"proposedDuration",
"proposedEnrollment",
"referenceBranch",
"featureValidationOptOut",
"localizations",
"locales",
"publishedDate",
)
def get_application(self, obj):
return self.get_appId(obj)
def get_appName(self, obj):
return obj.application_config.app_name
def get_appId(self, obj):
return obj.application_config.channel_app_id.get(obj.channel, "")
def get_branches(self, obj):
serializer_cls = NimbusBranchSerializer
if obj.application == NimbusExperiment.Application.DESKTOP:
serializer_cls = NimbusBranchSerializerDesktop
elif NimbusExperiment.Application.is_mobile(obj.application):
serializer_cls = NimbusBranchSerializerMobile
return serializer_cls(obj.branches.all(), many=True).data
def get_featureIds(self, obj):
return sorted(
[feature_config.slug for feature_config in obj.feature_configs.all()]
)
def get_outcomes(self, obj):
prioritized_outcomes = (
("primary", obj.primary_outcomes),
("secondary", obj.secondary_outcomes),
)
return [
{"slug": slug, "priority": priority}
for (priority, outcomes) in prioritized_outcomes
for slug in outcomes
]
def get_referenceBranch(self, obj):
if obj.reference_branch:
return obj.reference_branch.slug
def get_localizations(self, obj):
if obj.is_localized:
with contextlib.suppress(json.JSONDecodeError):
return json.loads(obj.localizations)
def get_locales(self, obj):
locale_codes = [locale.code for locale in obj.locales.all()]
if len(locale_codes):
return locale_codes

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

@ -0,0 +1,15 @@
from rest_framework.routers import SimpleRouter
from experimenter.experiments.api.v8.views import (
NimbusExperimentDraftViewSet,
NimbusExperimentViewSet,
)
router = SimpleRouter()
router.register(r"experiments", NimbusExperimentViewSet, "nimbus-experiment-rest-v8")
router.register(
r"draft-experiments",
NimbusExperimentDraftViewSet,
"nimbus-experiment-rest-v8-draft",
)
urlpatterns = router.urls

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

@ -0,0 +1,78 @@
from django.conf import settings
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from django_filters import FilterSet, filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import mixins, viewsets
from experimenter.experiments.api.v8.serializers import NimbusExperimentSerializer
from experimenter.experiments.models import NimbusExperiment, NimbusFeatureConfig
class BaseExperimentFilterSet(FilterSet):
application = filters.MultipleChoiceFilter(
choices=NimbusExperiment.Application.choices,
)
feature_config = filters.ModelMultipleChoiceFilter(
queryset=NimbusFeatureConfig.objects.all(),
field_name="feature_configs__slug",
to_field_name="slug",
)
class Meta:
model = NimbusExperiment
fields = ("is_localized",)
class NimbusExperimentFilterSet(BaseExperimentFilterSet):
class Meta:
model = NimbusExperiment
fields = (*BaseExperimentFilterSet.Meta.fields, "is_first_run", "status")
class NimbusDraftExperimentFilterSet(BaseExperimentFilterSet):
class Meta:
model = NimbusExperiment
fields = (*BaseExperimentFilterSet.Meta.fields, "is_first_run")
class NimbusExperimentViewSet(
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "slug"
queryset = (
NimbusExperiment.objects.with_related()
.exclude(status__in=[NimbusExperiment.Status.DRAFT])
.order_by("slug")
)
serializer_class = NimbusExperimentSerializer
filter_backends = [DjangoFilterBackend]
filterset_class = NimbusExperimentFilterSet
@method_decorator(cache_page(settings.API_CACHE_DURATION))
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)
class NimbusExperimentDraftViewSet(NimbusExperimentViewSet):
filterset_class = NimbusDraftExperimentFilterSet
queryset = (
NimbusExperiment.objects.with_related()
.filter(status=NimbusExperiment.Status.DRAFT)
.order_by("slug")
)
class NimbusExperimentFirstRunViewSet(NimbusExperimentViewSet):
filterset_class = BaseExperimentFilterSet
queryset = (
NimbusExperiment.objects.with_related()
.filter(status=NimbusExperiment.Status.LIVE)
.filter(is_first_run=True)
.order_by("slug")
)

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

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

@ -0,0 +1,372 @@
import datetime
import json
from django.conf import settings
from django.test import TestCase
from mozilla_nimbus_shared import check_schema
from parameterized import parameterized
from experimenter.base.tests.factories import LocaleFactory
from experimenter.experiments.api.v8.serializers import NimbusExperimentSerializer
from experimenter.experiments.models import NimbusBranchFeatureValue, NimbusExperiment
from experimenter.experiments.tests.factories import (
TEST_LOCALIZATIONS,
NimbusBranchFactory,
NimbusExperimentFactory,
NimbusFeatureConfigFactory,
)
class TestNimbusExperimentSerializer(TestCase):
maxDiff = None
def test_expected_schema_with_desktop(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"],
locales=[locale_en_us],
_enrollment_end_date=datetime.date(2022, 1, 5),
)
serializer = NimbusExperimentSerializer(experiment)
experiment_data = serializer.data.copy()
bucket_data = dict(experiment_data.pop("bucketConfig"))
branches_data = [dict(b) for b in experiment_data.pop("branches")]
feature_ids_data = experiment_data.pop("featureIds")
assert experiment.start_date
assert experiment.actual_enrollment_end_date
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"},
],
"featureValidationOptOut": experiment.is_client_schema_disabled,
"localizations": None,
"locales": ["en-US"],
"publishedDate": experiment.published_date,
},
)
self.assertEqual(set(feature_ids_data), {feature1.slug, feature2.slug})
self.assertEqual(
bucket_data,
{
"randomizationUnit": (
experiment.bucket_range.isolation_group.randomization_unit
),
"namespace": experiment.bucket_range.isolation_group.namespace,
"start": experiment.bucket_range.start,
"count": experiment.bucket_range.count,
"total": experiment.bucket_range.isolation_group.total,
},
)
self.assertEqual(len(branches_data), 2)
for branch in experiment.branches.all():
self.assertIn(
{
"slug": branch.slug,
"ratio": branch.ratio,
"feature": {
"featureId": "this-is-included-for-desktop-pre-95-support",
"enabled": False,
"value": {},
},
"features": [
{
"featureId": fv.feature_config.slug,
"enabled": True,
"value": json.loads(fv.value),
}
for fv in branch.feature_values.all()
],
},
branches_data,
)
check_schema("experiments/NimbusExperiment", serializer.data)
def test_enrollment_end_date_none_while_live_enrolling(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.LIVE_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"],
locales=[locale_en_us],
)
serializer = NimbusExperimentSerializer(experiment)
experiment_data = serializer.data.copy()
assert experiment.start_date
self.assertIsNone(experiment.actual_enrollment_end_date)
self.assertIsNone(experiment.end_date)
self.assertEqual(
experiment_data.get("enrollmentEndDate"),
experiment.actual_enrollment_end_date,
)
def test_list_includes_single_and_multi_feature_schemas(self):
feature1 = NimbusFeatureConfigFactory.create()
feature2 = NimbusFeatureConfigFactory.create()
single_feature_experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.ENDING_APPROVE_APPROVE,
application=NimbusExperiment.Application.DESKTOP,
feature_configs=[feature1],
)
multi_feature_experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.ENDING_APPROVE_APPROVE,
application=NimbusExperiment.Application.DESKTOP,
feature_configs=[feature1, feature2],
)
serializer = NimbusExperimentSerializer(NimbusExperiment.objects.all(), many=True)
experiments_data = {e["slug"]: e for e in serializer.data.copy()}
self.assertIn(
"feature", experiments_data[single_feature_experiment.slug]["branches"][0]
)
self.assertIn(
"features", experiments_data[single_feature_experiment.slug]["branches"][0]
)
self.assertIn(
"feature", experiments_data[multi_feature_experiment.slug]["branches"][0]
)
self.assertIn(
"features", experiments_data[multi_feature_experiment.slug]["branches"][0]
)
@parameterized.expand(list(NimbusExperiment.Application))
def test_serializers_with_missing_feature_value(self, application):
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.LAUNCH_APPROVE,
application=application,
)
experiment.delete_branches()
experiment.reference_branch = NimbusBranchFactory(
experiment=experiment, feature_values=[]
)
experiment.save()
serializer = NimbusExperimentSerializer(experiment)
self.assertEqual(serializer.data["branches"][0]["features"], [])
check_schema("experiments/NimbusExperiment", serializer.data)
def test_serializers_with_empty_feature_value(self):
application = NimbusExperiment.Application.DESKTOP
feature_config = NimbusFeatureConfigFactory.create(application=application)
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.LAUNCH_APPROVE,
application=application,
feature_configs=[feature_config],
)
experiment.delete_branches()
experiment.reference_branch = NimbusBranchFactory(
experiment=experiment, feature_values=[]
)
experiment.save()
NimbusBranchFeatureValue.objects.create(
branch=experiment.reference_branch, feature_config=feature_config, value=""
)
serializer = NimbusExperimentSerializer(experiment)
self.assertEqual(serializer.data["branches"][0]["features"][0]["value"], {})
check_schema("experiments/NimbusExperiment", serializer.data)
def test_serializer_with_branch_invalid_feature_value(self):
application = NimbusExperiment.Application.DESKTOP
feature_config = NimbusFeatureConfigFactory.create(application=application)
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.CREATED,
application=application,
feature_configs=[feature_config],
)
feature_value = experiment.reference_branch.feature_values.get()
feature_value.value = "this is not json"
feature_value.save()
serializer = NimbusExperimentSerializer(experiment)
branch_slug = serializer.data["referenceBranch"]
branch = next(x for x in serializer.data["branches"] if x["slug"] == branch_slug)
self.assertEqual(branch["features"][0]["value"], {})
@parameterized.expand(
[
(application, channel, channel_app_id)
for application in NimbusExperiment.Application
for (channel, channel_app_id) in NimbusExperiment.APPLICATION_CONFIGS[
application
].channel_app_id.items()
]
)
def test_sets_app_id_name_channel_for_application(
self,
application,
channel,
channel_app_id,
):
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.LAUNCH_APPROVE,
application=application,
channel=channel,
)
serializer = NimbusExperimentSerializer(experiment)
self.assertEqual(serializer.data["application"], channel_app_id)
self.assertEqual(serializer.data["channel"], channel)
self.assertEqual(
serializer.data["appName"],
NimbusExperiment.APPLICATION_CONFIGS[application].app_name,
)
self.assertEqual(serializer.data["appId"], channel_app_id)
check_schema("experiments/NimbusExperiment", serializer.data)
def test_serializer_outputs_targeting(self):
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.LAUNCH_APPROVE,
application=NimbusExperiment.Application.DESKTOP,
targeting_config_slug=NimbusExperiment.TargetingConfig.FIRST_RUN,
channel=NimbusExperiment.Channel.NO_CHANNEL,
)
serializer = NimbusExperimentSerializer(experiment)
self.assertEqual(serializer.data["targeting"], experiment.targeting)
check_schema("experiments/NimbusExperiment", serializer.data)
def test_serializer_outputs_empty_targeting(self):
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.LAUNCH_APPROVE,
publish_status=NimbusExperiment.PublishStatus.APPROVED,
targeting_config_slug=NimbusExperiment.TargetingConfig.NO_TARGETING,
application=NimbusExperiment.Application.FENIX,
firefox_min_version=NimbusExperiment.Version.FIREFOX_94,
)
serializer = NimbusExperimentSerializer(experiment)
self.assertEqual(serializer.data["targeting"], "true")
check_schema("experiments/NimbusExperiment", serializer.data)
def test_localized_desktop(self):
locale_en_us = LocaleFactory.create(code="en-US")
locale_en_ca = LocaleFactory.create(code="en-CA")
locale_fr = LocaleFactory.create(code="fr")
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.ENDING_APPROVE_APPROVE,
application=NimbusExperiment.Application.DESKTOP,
targeting_config_slug=NimbusExperiment.TargetingConfig.NO_TARGETING,
channel=NimbusExperiment.Channel.NIGHTLY,
primary_outcomes=["foo", "bar", "baz"],
secondary_outcomes=["qux", "quux"],
is_localized=True,
localizations=TEST_LOCALIZATIONS,
locales=[locale_en_us, locale_en_ca, locale_fr],
)
serializer = NimbusExperimentSerializer(experiment)
self.assertIn("localizations", serializer.data)
self.assertEqual(serializer.data["localizations"], json.loads(TEST_LOCALIZATIONS))
check_schema("experiments/NimbusExperiment", serializer.data)
def test_multiple_locales(self):
locale_en_us = LocaleFactory.create(code="en-US")
locale_en_ca = LocaleFactory.create(code="en-CA")
locale_fr = LocaleFactory.create(code="fr")
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.ENDING_APPROVE_APPROVE,
application=NimbusExperiment.Application.DESKTOP,
targeting_config_slug=NimbusExperiment.TargetingConfig.NO_TARGETING,
locales=[locale_en_us, locale_en_ca, locale_fr],
)
serializer = NimbusExperimentSerializer(experiment)
self.assertIn("locales", serializer.data)
self.assertEqual(set(serializer.data["locales"]), {"en-US", "en-CA", "fr"})
def test_all_locales(self):
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.ENDING_APPROVE_APPROVE,
application=NimbusExperiment.Application.DESKTOP,
targeting_config_slug=NimbusExperiment.TargetingConfig.NO_TARGETING,
)
serializer = NimbusExperimentSerializer(experiment)
self.assertIn("locales", serializer.data)
self.assertIsNone(serializer.data["locales"])
@parameterized.expand(
[
("invalid json", None),
(json.dumps({}), {}),
]
)
def test_localized_localizations_json(self, l10n_json, expected):
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.ENDING_APPROVE_APPROVE,
application=NimbusExperiment.Application.DESKTOP,
is_localized=True,
localizations=l10n_json,
)
serializer = NimbusExperimentSerializer(experiment)
self.assertIn("localizations", serializer.data)
if expected is None:
self.assertIsNone(serializer.data["localizations"])
else:
self.assertEqual(serializer.data["localizations"], expected)

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

@ -0,0 +1,276 @@
import json
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.urls import reverse
from experimenter.experiments.api.v8.serializers import NimbusExperimentSerializer
from experimenter.experiments.models import NimbusExperiment
from experimenter.experiments.tests.factories import (
NimbusExperimentFactory,
NimbusFeatureConfigFactory,
)
@override_settings(
CACHES={
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
}
}
)
class CachedViewSetTest(TestCase):
def setUp(self):
super().setUp()
cache.clear()
class NimbusExperimentFilterMixin:
LIFECYCLE = NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING
LIST_VIEW = "nimbus-experiment-rest-v8-list"
DETAIL_VIEW = "nimbus-experiment-rest-v8-detail"
def create_experiment_kwargs(self):
return {}
def assert_returned_slugs(self, response, expected_slugs):
self.assertEqual(response.status_code, 200)
recipes = json.loads(response.content)
self.assertEqual(
sorted(recipe["slug"] for recipe in recipes),
sorted(expected_slugs),
)
def test_filter_by_is_localized(self):
NimbusExperimentFactory.create_with_lifecycle(
self.LIFECYCLE,
slug="experiment",
**self.create_experiment_kwargs(),
)
NimbusExperimentFactory.create_with_lifecycle(
self.LIFECYCLE,
slug="localized_experiment",
is_localized=True,
localizations=json.dumps(
{
"en-US": {},
"en-CA": {},
}
),
**self.create_experiment_kwargs(),
)
response = self.client.get(
reverse(self.LIST_VIEW),
{"is_localized": "True"},
)
self.assertEqual(response.status_code, 200)
recipes = json.loads(response.content)
slugs = [recipe["slug"] for recipe in recipes]
self.assertEqual(slugs, ["localized_experiment"])
response = self.client.get(
reverse(self.LIST_VIEW),
{"is_localized": "False"},
)
self.assertEqual(response.status_code, 200)
recipes = json.loads(response.content)
slugs = [recipe["slug"] for recipe in recipes]
self.assertEqual(slugs, ["experiment"])
def test_filter_by_feature_config(self):
features = {
slug: NimbusFeatureConfigFactory.create(
slug=slug,
application=NimbusExperiment.Application.DESKTOP,
)
for slug in ("testFeature", "nimbus-qa-1", "nimbus-qa-2")
}
for feature in features.values():
NimbusExperimentFactory.create_with_lifecycle(
self.LIFECYCLE,
application=NimbusExperiment.Application.DESKTOP,
slug=f"{feature.slug}-exp",
feature_configs=[feature],
**self.create_experiment_kwargs(),
)
NimbusExperimentFactory.create_with_lifecycle(
self.LIFECYCLE,
application=NimbusExperiment.Application.DESKTOP,
slug="multi-1",
feature_configs=[features["nimbus-qa-1"], features["testFeature"]],
**self.create_experiment_kwargs(),
)
NimbusExperimentFactory.create_with_lifecycle(
self.LIFECYCLE,
application=NimbusExperiment.Application.DESKTOP,
slug="multi-2",
feature_configs=[features["nimbus-qa-2"], features["testFeature"]],
**self.create_experiment_kwargs(),
)
expected_slugs_by_feature_id = {
"nimbus-qa-1": ["nimbus-qa-1-exp", "multi-1"],
"nimbus-qa-2": ["nimbus-qa-2-exp", "multi-2"],
"testFeature": ["testFeature-exp", "multi-1", "multi-2"],
}
# Test querying for an individual feature ID.
for feature_id, expected_slugs in expected_slugs_by_feature_id.items():
response = self.client.get(
reverse(self.LIST_VIEW),
{"feature_config": feature_id},
)
self.assert_returned_slugs(response, expected_slugs)
# Test querying for multiple feature IDs
response = self.client.get(
reverse(self.LIST_VIEW),
{"feature_config": ["nimbus-qa-1", "nimbus-qa-2"]},
)
self.assertEqual(response.status_code, 200)
self.assert_returned_slugs(
response, ["nimbus-qa-1-exp", "nimbus-qa-2-exp", "multi-1", "multi-2"]
)
def test_filter_by_application(self):
for application in NimbusExperiment.Application.values:
NimbusExperimentFactory.create_with_lifecycle(
self.LIFECYCLE,
application=application,
slug=f"{application}-experiment",
**self.create_experiment_kwargs(),
)
response = self.client.get(
reverse(self.LIST_VIEW),
{
"application": [
NimbusExperiment.Application.FOCUS_ANDROID,
NimbusExperiment.Application.KLAR_ANDROID,
]
},
)
self.assert_returned_slugs(
response,
[
f"{application}-experiment"
for application in (
NimbusExperiment.Application.FOCUS_ANDROID,
NimbusExperiment.Application.KLAR_ANDROID,
)
],
)
class NimbusExperimentIsFirstRunFilterMixin:
def test_filter_by_is_first_run(self):
first_run_experiment = NimbusExperimentFactory.create_with_lifecycle(
self.LIFECYCLE,
is_first_run=True,
**self.create_experiment_kwargs(),
)
non_first_run_experiment = NimbusExperimentFactory.create_with_lifecycle(
self.LIFECYCLE,
is_first_run=False,
**self.create_experiment_kwargs(),
)
response = self.client.get(reverse(self.LIST_VIEW), {"is_first_run": "True"})
self.assert_returned_slugs(response, [first_run_experiment.slug])
response = self.client.get(reverse(self.LIST_VIEW), {"is_first_run": "False"})
self.assert_returned_slugs(response, [non_first_run_experiment.slug])
class TestNimbusExperimentViewSet(
NimbusExperimentFilterMixin, NimbusExperimentIsFirstRunFilterMixin, CachedViewSetTest
):
maxDiff = None
def test_list_view_serializes_experiments(self):
expected_slugs = []
for lifecycle in NimbusExperimentFactory.Lifecycles:
experiment = NimbusExperimentFactory.create_with_lifecycle(
lifecycle, slug=lifecycle.name
)
if experiment.status not in [
NimbusExperiment.Status.DRAFT,
]:
expected_slugs.append(experiment.slug)
response = self.client.get(reverse(self.LIST_VIEW))
self.assert_returned_slugs(response, expected_slugs)
def test_get_nimbus_experiment_returns_expected_data(self):
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.LAUNCH_APPROVE_APPROVE,
slug="test-rest-detail",
)
response = self.client.get(
reverse(self.DETAIL_VIEW, kwargs={"slug": experiment.slug}),
)
self.assertEqual(response.status_code, 200)
recipes = json.loads(response.content)
self.assertEqual(NimbusExperimentSerializer(experiment).data, recipes)
class TestNimbusExperimentDraftViewSet(
NimbusExperimentFilterMixin, NimbusExperimentIsFirstRunFilterMixin, CachedViewSetTest
):
maxDiff = None
LIST_VIEW = "nimbus-experiment-rest-v8-draft-list"
DETAIL_VIEW = "nimbus-experiment-rest-v8-draft-detail"
LIFECYCLE = NimbusExperimentFactory.Lifecycles.CREATED
def test_detail_view_serializes_draft_experiments(self):
draft_slugs = []
non_draft_slugs = []
for lifecycle in NimbusExperimentFactory.Lifecycles:
experiment = NimbusExperimentFactory.create_with_lifecycle(
lifecycle,
slug=lifecycle.name,
)
if experiment.status == NimbusExperiment.Status.DRAFT:
draft_slugs.append(experiment.slug)
else:
non_draft_slugs.append(experiment.slug)
for slug in draft_slugs:
response = self.client.get(reverse(self.DETAIL_VIEW, kwargs={"slug": slug}))
self.assertEqual(response.status_code, 200)
for slug in non_draft_slugs:
response = self.client.get(reverse(self.DETAIL_VIEW, kwargs={"slug": slug}))
self.assertEqual(response.status_code, 404)
def test_list_view_serializes_draft_experiments(self):
expected_slugs = []
for lifecycle in NimbusExperimentFactory.Lifecycles:
experiment = NimbusExperimentFactory.create_with_lifecycle(
lifecycle,
slug=lifecycle.name,
)
if experiment.status == NimbusExperiment.Status.DRAFT:
expected_slugs.append(experiment.slug)
response = self.client.get(reverse(self.LIST_VIEW))
self.assert_returned_slugs(response, expected_slugs)

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

@ -203,6 +203,10 @@ OPENIDC_AUTH_WHITELIST = (
"nimbus-experiment-rest-v6-draft-detail",
"nimbus-experiment-rest-v7-list",
"nimbus-experiment-rest-v7-detail",
"nimbus-experiment-rest-v8-list",
"nimbus-experiment-rest-v8-detail",
"nimbus-experiment-rest-v8-draft-list",
"nimbus-experiment-rest-v8-draft-detail",
)
# Internationalization
@ -367,7 +371,7 @@ CACHES = {
"TIMEOUT": None,
},
}
V6_API_CACHE_DURATION = 60 * 60
API_CACHE_DURATION = 60 * 60
SIZING_DATA_KEY = "population_sizing"
# Celery

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

@ -24,6 +24,7 @@ urlpatterns = [
re_path(r"^api/v5/", include("experimenter.experiments.api.v5.urls")),
re_path(r"^api/v6/", include("experimenter.experiments.api.v6.urls")),
re_path(r"^api/v7/", include("experimenter.experiments.api.v7.urls")),
re_path(r"^api/v8/", include("experimenter.experiments.api.v8.urls")),
re_path(r"^admin/", admin.site.urls),
re_path(r"^experiments/", include("experimenter.legacy.legacy_experiments.urls")),
re_path(r"^nimbus_new/", include("experimenter.nimbus_ui_new.urls")),