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
90 changes: 90 additions & 0 deletions experimenter/experimenter/experiments/api/v5/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, NotRequired, Optional, Self, TypedDict
from urllib.parse import urlparse

import jsonschema
import packaging
Expand Down Expand Up @@ -123,6 +124,18 @@ class TransitionConstants:
}


def is_valid_http_url(s: Any):
if not isinstance(s, str):
return False

try:
url = urlparse(s)
except Exception: # pragma: no cover
return False

return url.scheme in ("http", "https")


class NestedRefResolver(jsonschema.RefResolver):
"""A custom ref resolver that handles bundled schema."""

Expand Down Expand Up @@ -2322,6 +2335,82 @@ def _validate_primary_secondary_outcomes(self, data):

return data

def _validate_firefox_labs(self, data):
if not data.get("is_firefox_labs_opt_in"):
return data

min_version = NimbusExperiment.Version.parse(data.get("firefox_min_version"))
required_min_version = NimbusExperiment.FIREFOX_LABS_MIN_VERSION.get(
self.instance.application
)

if required_min_version is None:
raise serializers.ValidationError(
{
"is_firefox_labs_opt_in": (
NimbusExperiment.ERROR_FIREFOX_LABS_UNSUPPORTED_APPLICATION
),
}
)
required_min_version = NimbusExperiment.Version.parse(required_min_version)
if min_version < required_min_version:
raise serializers.ValidationError(
{
"firefox_min_version": (
NimbusExperiment.ERROR_FIREFOX_LABS_MIN_VERSION.format(
version=required_min_version
)
),
}
)

if not data.get("is_rollout"):
raise serializers.ValidationError(
{"is_rollout": NimbusExperiment.ERROR_FIREFOX_LABS_ROLLOUT_REQUIRED}
)

errors = {
field: [NimbusExperiment.ERROR_FIREFOX_LABS_REQUIRED_FIELD]
for field in (
"firefox_labs_title",
"firefox_labs_description",
"firefox_labs_group",
)
if not len((data.get(field) or "").strip())
}

if description_links := (
data.get("firefox_labs_description_links") or ""
).strip():
try:
description_links_obj = json.loads(description_links)
except Exception:
errors["firefox_labs_description_links"] = [
NimbusExperiment.ERROR_FIREFOX_LABS_DESCRIPTION_LINKS_JSON,
]
else:
if isinstance(description_links_obj, dict):
if not all(
is_valid_http_url(value)
for value in description_links_obj.values()
):
errors["firefox_labs_description_links"] = [
NimbusExperiment.ERROR_FIREFOX_LABS_DESCRIPTION_LINKS_HTTP_URLS,
]

elif (
not isinstance(description_links_obj, dict)
and description_links_obj is not None
):
errors["firefox_labs_description_links"] = [
NimbusExperiment.ERROR_FIREFOX_LABS_DESCRIPTION_LINKS_JSON,
]

if errors:
raise serializers.ValidationError(errors)

return data

def validate(self, data):
application = data.get("application")
channel = data.get("channel")
Expand All @@ -2340,6 +2429,7 @@ def validate(self, data):
data = self._validate_proposed_release_date(data)
data = self._validate_feature_value_variables(data)
data = self._validate_primary_secondary_outcomes(data)
data = self._validate_firefox_labs(data)
if application == NimbusExperiment.Application.DESKTOP:
data = self._validate_desktop_pref_rollouts(data)
data = self._validate_desktop_pref_flips(data)
Expand Down
9 changes: 6 additions & 3 deletions experimenter/experimenter/experiments/api/v6/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,7 @@ class NimbusExperimentSerializer(serializers.ModelSerializer):
isFirefoxLabsOptIn = serializers.ReadOnlyField(source="is_firefox_labs_opt_in")
firefoxLabsTitle = serializers.ReadOnlyField(source="firefox_labs_title")
firefoxLabsDescription = serializers.ReadOnlyField(source="firefox_labs_description")
firefoxLabsDescriptionLinks = serializers.ReadOnlyField(
source="firefox_labs_description_links"
)
firefoxLabsDescriptionLinks = serializers.SerializerMethodField()
firefoxLabsGroup = serializers.ReadOnlyField(source="firefox_labs_group")
requiresRestart = serializers.ReadOnlyField(source="requires_restart")

Expand Down Expand Up @@ -207,3 +205,8 @@ def get_locales(self, obj):
locale_codes = [locale.code for locale in obj.locales.all()]
if len(locale_codes):
return locale_codes

def get_firefoxLabsDescriptionLinks(self, obj):
if obj.firefox_labs_description_links:
with contextlib.suppress(json.JSONDecodeError):
return json.loads(obj.firefox_labs_description_links)
9 changes: 6 additions & 3 deletions experimenter/experimenter/experiments/api/v8/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,7 @@ class NimbusExperimentSerializer(serializers.ModelSerializer):
isFirefoxLabsOptIn = serializers.ReadOnlyField(source="is_firefox_labs_opt_in")
firefoxLabsTitle = serializers.ReadOnlyField(source="firefox_labs_title")
firefoxLabsDescription = serializers.ReadOnlyField(source="firefox_labs_description")
firefoxLabsDescriptionLinks = serializers.ReadOnlyField(
source="firefox_labs_description_links"
)
firefoxLabsDescriptionLinks = serializers.SerializerMethodField()
firefoxLabsGroup = serializers.ReadOnlyField(source="firefox_labs_group")
requiresRestart = serializers.ReadOnlyField(source="requires_restart")

Expand Down Expand Up @@ -212,3 +210,8 @@ def get_locales(self, obj):
locale_codes = [locale.code for locale in obj.locales.all()]
if len(locale_codes):
return locale_codes

def get_firefoxLabsDescriptionLinks(self, obj):
if obj.firefox_labs_description_links:
with contextlib.suppress(json.JSONDecodeError):
return json.loads(obj.firefox_labs_description_links)
22 changes: 22 additions & 0 deletions experimenter/experimenter/experiments/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,28 @@ class FirefoxLabsGroups(models.TextChoices):
ENROLLMENT = "Enrollment"
WHAT_TRAIN_IS_IT_NOW_URL = "https://whattrainisitnow.com/api/firefox/releases/"

FIREFOX_LABS_MIN_VERSION = {
Application.DESKTOP: Version.FIREFOX_137,
}

ERROR_FIREFOX_LABS_MIN_VERSION = (
"Firefox Labs requires at least version "
"{version.major}.{version.minor}.{version.micro}."
)

ERROR_FIREFOX_LABS_UNSUPPORTED_APPLICATION = (
"This application does not support Firefox Labs."
)

ERROR_FIREFOX_LABS_DESCRIPTION_LINKS_JSON = (
"Firefox Labs description links must be a JSON object or null."
)
ERROR_FIREFOX_LABS_DESCRIPTION_LINKS_HTTP_URLS = (
"Firefox Labs description links values must be HTTP(S) URLs."
)
ERROR_FIREFOX_LABS_REQUIRED_FIELD = "This field is requried for Firefox Labs Opt-Ins."
ERROR_FIREFOX_LABS_ROLLOUT_REQUIRED = "Firefox Labs opt-ins must be rollouts."


EXTERNAL_URLS = {
"SIGNOFF_QA": "https://experimenter.info/qa-sign-off",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Generated by Django 5.2.2 on 2025-08-19 15:37
import json

from django.db import migrations, models
from django.db.models import Q


def normalize_description_links(apps, schema_editor):
NimbusExperiment = apps.get_model("experiments", "NimbusExperiment")

experiments = list(NimbusExperiment.objects.filter(is_firefox_labs_opt_in=True))
for experiment in experiments:
value = experiment.firefox_labs_description_links

if isinstance(value, str):
if value == '""':
new_value = None
else:
try:
new_value = json.loads(value)
except Exception as e:
# If its not a valid json value, we're gonna leave it as is.
continue

experiment.firefox_labs_description_links = new_value
experiment.save()


class Migration(migrations.Migration):
dependencies = [
("experiments", "0287_nimbusexperiment_channels"),
]

operations = [
migrations.RunPython(normalize_description_links),
migrations.AlterField(
model_name="nimbusexperiment",
name="firefox_labs_description_links",
field=models.TextField(
blank=True,
default=None,
null=True,
verbose_name="Firefox Labs Description Links",
),
),
]
2 changes: 1 addition & 1 deletion experimenter/experimenter/experiments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ class NimbusExperiment(NimbusConstants, TargetingConstants, FilterMixin, models.
blank=True,
null=True,
)
firefox_labs_description_links = models.JSONField[dict[str, str]](
firefox_labs_description_links = models.TextField(
"Firefox Labs Description Links",
blank=True,
null=True,
Expand Down
Loading