From 51213e247d4de9f3b9d5725eada779e6e526ac71 Mon Sep 17 00:00:00 2001 From: Tif Tran Date: Tue, 17 Dec 2019 14:31:55 -0800 Subject: [PATCH] serializer reorg (#2069) --- app/experimenter/experiments/api_views.py | 8 +- app/experimenter/experiments/forms.py | 2 +- app/experimenter/experiments/models.py | 2 +- app/experimenter/experiments/serializers.py | 942 ------------ .../experiments/serializers/__init__.py | 0 .../experiments/serializers/clone.py | 37 + .../experiments/serializers/design.py | 398 +++++ .../experiments/serializers/entities.py | 185 +++ .../experiments/serializers/geo.py | 16 + .../experiments/serializers/recipe.py | 333 +++++ .../experiments/tests/serializers/__init__.py | 0 .../tests/serializers/test_clone.py | 54 + .../test_design.py} | 1321 +++-------------- .../tests/serializers/test_entities.py | 265 ++++ .../experiments/tests/serializers/test_geo.py | 21 + .../tests/serializers/test_recipe.py | 678 +++++++++ .../experiments/tests/test_api_views.py | 10 +- .../experiments/tests/test_changelog_utils.py | 2 +- .../experiments/tests/test_models.py | 2 +- 19 files changed, 2174 insertions(+), 2102 deletions(-) delete mode 100644 app/experimenter/experiments/serializers.py create mode 100644 app/experimenter/experiments/serializers/__init__.py create mode 100644 app/experimenter/experiments/serializers/clone.py create mode 100644 app/experimenter/experiments/serializers/design.py create mode 100644 app/experimenter/experiments/serializers/entities.py create mode 100644 app/experimenter/experiments/serializers/geo.py create mode 100644 app/experimenter/experiments/serializers/recipe.py create mode 100644 app/experimenter/experiments/tests/serializers/__init__.py create mode 100644 app/experimenter/experiments/tests/serializers/test_clone.py rename app/experimenter/experiments/tests/{test_serializers.py => serializers/test_design.py} (53%) create mode 100644 app/experimenter/experiments/tests/serializers/test_entities.py create mode 100644 app/experimenter/experiments/tests/serializers/test_geo.py create mode 100644 app/experimenter/experiments/tests/serializers/test_recipe.py diff --git a/app/experimenter/experiments/api_views.py b/app/experimenter/experiments/api_views.py index 2a7c35c36e..22d68b9412 100644 --- a/app/experimenter/experiments/api_views.py +++ b/app/experimenter/experiments/api_views.py @@ -10,17 +10,17 @@ from experimenter.experiments.constants import ExperimentConstants from experimenter.experiments.models import Experiment from experimenter.experiments import email -from experimenter.experiments.serializers import ( - ExperimentCloneSerializer, +from experimenter.experiments.serializers.entities import ExperimentSerializer +from experimenter.experiments.serializers.clone import ExperimentCloneSerializer +from experimenter.experiments.serializers.design import ( ExperimentDesignAddonSerializer, ExperimentDesignBranchedAddonSerializer, ExperimentDesignGenericSerializer, ExperimentDesignMultiPrefSerializer, ExperimentDesignPrefSerializer, ExperimentDesignRolloutSerializer, - ExperimentRecipeSerializer, - ExperimentSerializer, ) +from experimenter.experiments.serializers.recipe import ExperimentRecipeSerializer class ExperimentListView(ListAPIView): diff --git a/app/experimenter/experiments/forms.py b/app/experimenter/experiments/forms.py index 82ed69fdf2..863ad3e7ae 100644 --- a/app/experimenter/experiments/forms.py +++ b/app/experimenter/experiments/forms.py @@ -23,7 +23,7 @@ ExperimentComment, ExperimentVariant, ) -from experimenter.experiments.serializers import ChangeLogSerializer +from experimenter.experiments.serializers.entities import ChangeLogSerializer from experimenter.notifications.models import Notification diff --git a/app/experimenter/experiments/models.py b/app/experimenter/experiments/models.py index 01fc40bbac..4527564de0 100644 --- a/app/experimenter/experiments/models.py +++ b/app/experimenter/experiments/models.py @@ -317,7 +317,7 @@ def generate_normandy_slug(self): @property def normandy_recipe_json(self): - from experimenter.experiments.serializers import ExperimentRecipeSerializer + from experimenter.experiments.serializers.recipe import ExperimentRecipeSerializer return json.dumps(ExperimentRecipeSerializer(self).data, indent=2) diff --git a/app/experimenter/experiments/serializers.py b/app/experimenter/experiments/serializers.py deleted file mode 100644 index 544072f709..0000000000 --- a/app/experimenter/experiments/serializers.py +++ /dev/null @@ -1,942 +0,0 @@ -import time -import json -from rest_framework import serializers -from django.utils.text import slugify -from django.urls import reverse -from django.db.models import Q - -from experimenter.base.models import Country, Locale -from experimenter.experiments.models import ( - Experiment, - ExperimentVariant, - VariantPreferences, - ExperimentChangeLog, -) -from experimenter.experiments.changelog_utils import generate_change_log - - -class JSTimestampField(serializers.Field): - """ - Serialize a datetime object into javascript timestamp - ie unix time in ms - """ - - def to_representation(self, obj): - if obj: - return time.mktime(obj.timetuple()) * 1000 - else: - return None - - -class PrefTypeField(serializers.Field): - - def to_representation(self, obj): - if obj == Experiment.PREF_TYPE_JSON_STR: - return Experiment.PREF_TYPE_STR - else: - return obj - - -class ExperimentVariantSerializer(serializers.ModelSerializer): - - class Meta: - model = ExperimentVariant - fields = ( - "description", - "is_control", - "name", - "ratio", - "slug", - "value", - "addon_release_url", - ) - - -class LocaleSerializer(serializers.ModelSerializer): - - class Meta: - model = Locale - fields = ("code", "name") - - -class CountrySerializer(serializers.ModelSerializer): - - class Meta: - model = Country - fields = ("code", "name") - - -class ExperimentChangeLogSerializer(serializers.ModelSerializer): - - class Meta: - model = ExperimentChangeLog - fields = ("changed_on", "pretty_status", "new_status", "old_status") - - -class ChangeLogSerializer(serializers.ModelSerializer): - variants = ExperimentVariantSerializer(many=True, required=False) - locales = LocaleSerializer(many=True, required=False) - countries = CountrySerializer(many=True, required=False) - pref_type = PrefTypeField() - - class Meta: - model = Experiment - fields = ( - "type", - "owner", - "name", - "short_description", - "related_work", - "related_to", - "proposed_start_date", - "proposed_duration", - "proposed_enrollment", - "design", - "addon_experiment_id", - "addon_release_url", - "pref_key", - "pref_type", - "pref_branch", - "public_name", - "public_description", - "population_percent", - "firefox_min_version", - "firefox_max_version", - "firefox_channel", - "client_matching", - "locales", - "countries", - "platform", - "objectives", - "analysis", - "analysis_owner", - "survey_required", - "survey_urls", - "survey_instructions", - "engineering_owner", - "bugzilla_id", - "normandy_slug", - "normandy_id", - "data_science_bugzilla_url", - "feature_bugzilla_url", - "risk_internal_only", - "risk_partner_related", - "risk_brand", - "risk_fast_shipped", - "risk_confidential", - "risk_release_population", - "risk_revenue", - "risk_data_category", - "risk_external_team_impact", - "risk_telemetry_data", - "risk_ux", - "risk_security", - "risk_revision", - "risk_technical", - "risk_technical_description", - "risks", - "testing", - "test_builds", - "qa_status", - "review_science", - "review_engineering", - "review_qa_requested", - "review_intent_to_ship", - "review_bugzilla", - "review_qa", - "review_relman", - "review_advisory", - "review_legal", - "review_ux", - "review_security", - "review_vp", - "review_data_steward", - "review_comms", - "review_impacted_teams", - "variants", - "results_url", - "results_initial", - "results_lessons_learned", - ) - - -class ExperimentSerializer(serializers.ModelSerializer): - start_date = JSTimestampField() - end_date = JSTimestampField() - proposed_start_date = JSTimestampField() - variants = ExperimentVariantSerializer(many=True) - locales = LocaleSerializer(many=True) - countries = CountrySerializer(many=True) - pref_type = PrefTypeField() - changes = ExperimentChangeLogSerializer(many=True) - - class Meta: - model = Experiment - fields = ( - "experiment_url", - "type", - "name", - "slug", - "public_name", - "public_description", - "status", - "client_matching", - "locales", - "countries", - "platform", - "start_date", - "end_date", - "population", - "population_percent", - "firefox_channel", - "firefox_min_version", - "firefox_max_version", - "addon_experiment_id", - "addon_release_url", - "pref_branch", - "pref_key", - "pref_type", - "proposed_start_date", - "proposed_enrollment", - "proposed_duration", - "variants", - "changes", - ) - - -class FilterObjectBucketSampleSerializer(serializers.ModelSerializer): - type = serializers.SerializerMethodField() - input = serializers.ReadOnlyField(default=["normandy.recipe.id", "normandy.userId"]) - start = serializers.ReadOnlyField(default=0) - count = serializers.SerializerMethodField() - total = serializers.ReadOnlyField(default=10000) - - class Meta: - model = Experiment - fields = ("type", "input", "start", "count", "total") - - def get_type(self, obj): - return "bucketSample" - - def get_count(self, obj): - return int(obj.population_percent * 100) - - -class FilterObjectChannelSerializer(serializers.ModelSerializer): - type = serializers.SerializerMethodField() - channels = serializers.SerializerMethodField() - - class Meta: - model = Experiment - fields = ("type", "channels") - - def get_type(self, obj): - return "channel" - - def get_channels(self, obj): - return [obj.firefox_channel.lower()] - - -class FilterObjectVersionsSerializer(serializers.ModelSerializer): - type = serializers.SerializerMethodField() - versions = serializers.SerializerMethodField() - - class Meta: - model = Experiment - fields = ("type", "versions") - - def get_type(self, obj): - return "version" - - def get_versions(self, obj): - return obj.versions_integer_list - - -class FilterObjectLocaleSerializer(serializers.ModelSerializer): - type = serializers.SerializerMethodField() - locales = serializers.SerializerMethodField() - - class Meta: - model = Experiment - fields = ("type", "locales") - - def get_type(self, obj): - return "locale" - - def get_locales(self, obj): - return list(obj.locales.all().values_list("code", flat=True)) - - -class FilterObjectCountrySerializer(serializers.ModelSerializer): - type = serializers.SerializerMethodField() - countries = serializers.SerializerMethodField() - - class Meta: - model = Experiment - fields = ("type", "countries") - - def get_type(self, obj): - return "country" - - def get_countries(self, obj): - return list(obj.countries.all().values_list("code", flat=True)) - - -class ExperimentRecipeVariantSerializer(serializers.ModelSerializer): - value = serializers.SerializerMethodField() - - class Meta: - model = ExperimentVariant - fields = ("ratio", "slug", "value") - - def get_value(self, obj): - pref_type = obj.experiment.pref_type - if pref_type in (Experiment.PREF_TYPE_BOOL, Experiment.PREF_TYPE_INT): - return json.loads(obj.value) - - return obj.value - - -class ExperimentRecipeAddonVariantSerializer(serializers.ModelSerializer): - extensionApiId = serializers.SerializerMethodField() - - class Meta: - model = ExperimentVariant - fields = ("ratio", "slug", "extensionApiId") - - def get_extensionApiId(self, obj): - return None - - -class VariantPreferenceRecipeListSerializer(serializers.ListSerializer): - - def to_representation(self, obj): - experiment = obj.instance.experiment - variant = obj.instance - serialized_data = super().to_representation(obj) - - if experiment.is_multi_pref: - return {entry.pop("pref_name"): entry for entry in serialized_data} - - else: - preference_values = {} - preference_values["preferenceBranchType"] = experiment.pref_branch - preference_values["preferenceType"] = PrefTypeField().to_representation( - experiment.pref_type - ) - preference_values["preferenceValue"] = variant.value - - return {experiment.pref_key: preference_values} - - -class VariantPreferenceRecipeSerializer(serializers.ModelSerializer): - preferenceBranchType = serializers.ReadOnlyField(source="pref_branch") - preferenceType = PrefTypeField(source="pref_type") - preferenceValue = serializers.ReadOnlyField(source="pref_value") - - class Meta: - list_serializer_class = VariantPreferenceRecipeListSerializer - model = VariantPreferences - fields = ( - "preferenceBranchType", - "preferenceType", - "preferenceValue", - "pref_name", - ) - - -class ExperimentRecipeMultiPrefVariantSerializer(serializers.ModelSerializer): - preferences = VariantPreferenceRecipeSerializer(many=True) - - class Meta: - model = ExperimentVariant - fields = ("preferences", "ratio", "slug") - - -class ExperimentRecipePrefArgumentsSerializer(serializers.ModelSerializer): - preferenceBranchType = serializers.ReadOnlyField(source="pref_branch") - slug = serializers.ReadOnlyField(source="normandy_slug") - experimentDocumentUrl = serializers.ReadOnlyField(source="experiment_url") - preferenceName = serializers.ReadOnlyField(source="pref_key") - preferenceType = PrefTypeField(source="pref_type") - branches = ExperimentRecipeVariantSerializer(many=True, source="variants") - - class Meta: - model = Experiment - fields = ( - "preferenceBranchType", - "slug", - "experimentDocumentUrl", - "preferenceName", - "preferenceType", - "branches", - ) - - -class ExperimentRecipeBranchedArgumentsSerializer(serializers.ModelSerializer): - slug = serializers.ReadOnlyField(source="normandy_slug") - userFacingName = userFacingDescription = serializers.ReadOnlyField( - source="public_name" - ) - userFacingDescription = serializers.ReadOnlyField(source="public_description") - branches = serializers.SerializerMethodField() - - class Meta: - model = Experiment - fields = ("slug", "userFacingName", "userFacingDescription") - - -class ExperimentRecipeBranchedAddonArgumentsSerializer( - ExperimentRecipeBranchedArgumentsSerializer -): - slug = serializers.ReadOnlyField(source="normandy_slug") - branches = serializers.SerializerMethodField() - - class Meta: - model = Experiment - fields = ("slug", "userFacingName", "userFacingDescription", "branches") - - def get_branches(self, obj): - return ExperimentRecipeAddonVariantSerializer(obj.variants, many=True).data - - -class ExperimentRecipeMultiPrefArgumentsSerializer( - ExperimentRecipeBranchedArgumentsSerializer -): - slug = serializers.ReadOnlyField(source="normandy_slug") - branches = serializers.SerializerMethodField() - experimentDocumentUrl = serializers.ReadOnlyField(source="experiment_url") - - class Meta: - model = Experiment - fields = ( - "slug", - "userFacingName", - "userFacingDescription", - "branches", - "experimentDocumentUrl", - ) - - def get_branches(self, obj): - return ExperimentRecipeMultiPrefVariantSerializer(obj.variants, many=True).data - - -class ExperimentRecipeAddonArgumentsSerializer(serializers.ModelSerializer): - name = serializers.ReadOnlyField(source="addon_experiment_id") - description = serializers.ReadOnlyField(source="public_description") - - class Meta: - model = Experiment - fields = ("name", "description") - - -class ExperimentRecipeAddonRolloutArgumentsSerializer(serializers.ModelSerializer): - slug = serializers.ReadOnlyField(source="normandy_slug") - extensionApiId = serializers.SerializerMethodField() - - class Meta: - model = Experiment - fields = ("slug", "extensionApiId") - - def get_extensionApiId(self, obj): - return f"TODO: {obj.addon_release_url}" - - -class ExperimentRecipePrefRolloutArgumentsSerializer(serializers.ModelSerializer): - slug = serializers.ReadOnlyField(source="normandy_slug") - preferences = serializers.SerializerMethodField() - - class Meta: - model = Experiment - fields = ("slug", "preferences") - - def get_value(self, obj): - pref_type = obj.pref_type - if pref_type in (Experiment.PREF_TYPE_BOOL, Experiment.PREF_TYPE_INT): - return json.loads(obj.pref_value) - - return obj.pref_value - - def get_preferences(self, obj): - return [{"preferenceName": obj.pref_key, "value": self.get_value(obj)}] - - -class ExperimentRecipeSerializer(serializers.ModelSerializer): - action_name = serializers.SerializerMethodField() - filter_object = serializers.SerializerMethodField() - comment = serializers.SerializerMethodField() - arguments = serializers.SerializerMethodField() - experimenter_slug = serializers.ReadOnlyField(source="slug") - - class Meta: - model = Experiment - fields = ( - "action_name", - "name", - "filter_object", - "comment", - "arguments", - "experimenter_slug", - ) - - def get_action_name(self, obj): - if obj.use_multi_pref_serializer: - return "multi-preference-experiment" - if obj.is_pref_experiment: - return "preference-experiment" - elif obj.use_branched_addon_serializer: - return "branched-addon-study" - elif obj.is_addon_experiment: - return "opt-out-study" - elif obj.is_addon_rollout: - return "addon-rollout" - elif obj.is_pref_rollout: - return "preference-rollout" - - def get_filter_object(self, obj): - filter_objects = [ - FilterObjectBucketSampleSerializer(obj).data, - FilterObjectChannelSerializer(obj).data, - FilterObjectVersionsSerializer(obj).data, - ] - - if obj.locales.count(): - filter_objects.append(FilterObjectLocaleSerializer(obj).data) - - if obj.countries.count(): - filter_objects.append(FilterObjectCountrySerializer(obj).data) - - return filter_objects - - def get_arguments(self, obj): - if obj.use_multi_pref_serializer: - return ExperimentRecipeMultiPrefArgumentsSerializer(obj).data - elif obj.is_pref_experiment: - return ExperimentRecipePrefArgumentsSerializer(obj).data - elif obj.use_branched_addon_serializer: - return ExperimentRecipeBranchedAddonArgumentsSerializer(obj).data - elif obj.is_addon_experiment: - return ExperimentRecipeAddonArgumentsSerializer(obj).data - elif obj.is_addon_rollout: - return ExperimentRecipeAddonRolloutArgumentsSerializer(obj).data - elif obj.is_pref_rollout: - return ExperimentRecipePrefRolloutArgumentsSerializer(obj).data - - def get_comment(self, obj): - return f"Platform: {obj.platform}\n{obj.client_matching}" - - -class ExperimentCloneSerializer(serializers.ModelSerializer): - clone_url = serializers.SerializerMethodField() - - class Meta: - model = Experiment - fields = ("name", "clone_url") - - def validate_name(self, value): - existing_slug_or_name = Experiment.objects.filter( - Q(slug=slugify(value)) | Q(name=value) - ) - - if existing_slug_or_name: - raise serializers.ValidationError("This experiment name already exists.") - - if slugify(value): - return value - else: - raise serializers.ValidationError("That's an invalid name.") - - def get_clone_url(self, obj): - return reverse("experiments-detail", kwargs={"slug": obj.slug}) - - def update(self, instance, validated_data): - user = self.context["request"].user - name = validated_data.get("name") - - return instance.clone(name, user) - - -class PrefValidationMixin(object): - - def validate_pref(self, pref_type, pref_value, field_name): - if pref_type == "integer": - try: - int(pref_value) - except ValueError: - return {field_name: "The pref value must be an integer."} - - if pref_type == "boolean": - if pref_value not in ["true", "false"]: - return {field_name: "The pref value must be a boolean."} - - if pref_type == "json string": - try: - json.loads(pref_value) - except ValueError: - return {field_name: "The pref value must be valid JSON."} - return {} - - -class VariantsListSerializer(serializers.ListSerializer): - - def to_representation(self, data): - data = super().to_representation(data) - - if data == []: - blank_variant = {} - control_blank_variant = {} - initial_fields = set(self.child.fields) - set(["id"]) - for field in initial_fields: - blank_variant[field] = None - control_blank_variant[field] = None - - blank_variant["is_control"] = False - blank_variant["ratio"] = 50 - control_blank_variant["is_control"] = True - control_blank_variant["ratio"] = 50 - - if "preferences" in initial_fields: - blank_variant["preferences"] = [{}] - control_blank_variant["preferences"] = [{}] - - data = [control_blank_variant, blank_variant] - - control_branch = [b for b in data if b["is_control"]][0] - treatment_branches = sorted( - [b for b in data if not b["is_control"]], key=lambda b: b.get("id") - ) - - return [control_branch] + treatment_branches - - -class ExperimentDesignVariantBaseSerializer(serializers.ModelSerializer): - id = serializers.IntegerField(required=False) - description = serializers.CharField() - is_control = serializers.BooleanField() - name = serializers.CharField(max_length=255) - ratio = serializers.IntegerField() - - def validate_ratio(self, value): - if 1 <= value <= 100: - return value - - raise serializers.ValidationError(["Branch sizes must be between 1 and 100."]) - - class Meta: - list_serializer_class = VariantsListSerializer - fields = ["id", "description", "is_control", "name", "ratio"] - model = ExperimentVariant - - -class ExperimentDesignVariantPrefSerializer(ExperimentDesignVariantBaseSerializer): - value = serializers.CharField() - - class Meta(ExperimentDesignVariantBaseSerializer.Meta): - fields = ["id", "description", "is_control", "name", "ratio", "value"] - model = ExperimentVariant - - -class ExperimentDesignBranchVariantPreferencesSerializer(serializers.ModelSerializer): - id = serializers.IntegerField(required=False) - pref_name = serializers.CharField(max_length=255) - pref_type = serializers.CharField(max_length=255) - pref_branch = serializers.CharField(max_length=255) - pref_value = serializers.CharField(max_length=255) - - class Meta: - model = VariantPreferences - fields = ["id", "pref_name", "pref_type", "pref_branch", "pref_value"] - - -class ExperimentDesignBranchMultiPrefSerializer( - PrefValidationMixin, ExperimentDesignVariantBaseSerializer -): - preferences = ExperimentDesignBranchVariantPreferencesSerializer(many=True) - - class Meta(ExperimentDesignVariantBaseSerializer.Meta): - fields = ["id", "description", "is_control", "name", "ratio", "preferences"] - model = ExperimentVariant - - def validate_preferences(self, data): - if not self.is_pref_valid(data): - error_list = [{"pref_name": "Pref name per Branch needs to be unique"}] * len( - data - ) - raise serializers.ValidationError(error_list) - self.validate_value_type_match(data) - return data - - def is_pref_valid(self, preferences): - unique_names = len( - set([slugify(pref["pref_name"]) for pref in preferences]) - ) == len(preferences) - - return unique_names - - def validate_value_type_match(self, preferences): - error_list = [] - for pref in preferences: - pref_type = pref.get("pref_type", "") - pref_value = pref["pref_value"] - field_name = "pref_value" - error_list.append(self.validate_pref(pref_type, pref_value, field_name)) - - if any(error_list): - raise serializers.ValidationError(error_list) - - -class ChangelogSerializerMixin(object): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.instance and self.instance.id: - self.old_serialized_vals = ChangeLogSerializer(self.instance).data - - def update_changelog(self, instance, validated_data): - new_serialized_vals = ChangeLogSerializer(instance).data - user = self.context["request"].user - changed_data = validated_data.copy() - generate_change_log( - self.old_serialized_vals, new_serialized_vals, instance, changed_data, user - ) - - return instance - - -class ExperimentDesignBaseSerializer( - ChangelogSerializerMixin, serializers.ModelSerializer -): - variants = ExperimentDesignVariantBaseSerializer(many=True) - - class Meta: - model = Experiment - fields = ("variants",) - - def validate(self, data): - variants = data.get("variants") - - if variants: - if sum([variant["ratio"] for variant in variants]) != 100: - error_list = [] - for variant in variants: - error_list.append({"ratio": ["All branch sizes must add up to 100."]}) - - raise serializers.ValidationError({"variants": error_list}) - - if not self.is_variant_valid(variants): - error_list = [] - for variant in variants: - error_list.append( - {"name": [("All branches must have a unique name")]} - ) - - raise serializers.ValidationError({"variants": error_list}) - - return data - - def is_variant_valid(self, variants): - - slugified_nanes = [slugify(variant["name"]) for variant in variants] - unique_names = len(set(slugified_nanes)) == len(variants) - non_empty = all(slugified_nanes) - - return unique_names and non_empty - - def update(self, instance, validated_data): - variants_data = validated_data.pop("variants", []) - instance = super().update(instance, validated_data) - - if variants_data: - existing_variant_ids = set( - instance.variants.all().values_list("id", flat=True) - ) - # Create or update variants - for variant_data in variants_data: - variant_data["experiment"] = instance - variant_data["slug"] = slugify(variant_data["name"]) - ExperimentVariant(**variant_data).save() - - # Delete removed variants - submitted_variant_ids = set( - [v.get("id") for v in variants_data if v.get("id")] - ) - removed_ids = existing_variant_ids - submitted_variant_ids - - if removed_ids: - ExperimentVariant.objects.filter(id__in=removed_ids).delete() - - self.update_changelog(instance, validated_data) - - return instance - - -class ExperimentDesignMultiPrefSerializer(ExperimentDesignBaseSerializer): - is_multi_pref = serializers.BooleanField() - variants = ExperimentDesignBranchMultiPrefSerializer(many=True) - - class Meta: - model = Experiment - fields = ("is_multi_pref", "variants") - - def update(self, instance, validated_data): - variant_preferences = [ - (v_d, v_d.pop("preferences")) for v_d in validated_data["variants"] - ] - - instance = super().update(instance, validated_data) - existing_pref_ids = list( - instance.variants.all().values_list("preferences__id", flat=True) - ) - submitted_pref_ids = [] - for variant_data, prefs in variant_preferences: - - variant = instance.variants.get(name=variant_data["name"]) - for pref in prefs: - pref["variant_id"] = variant.id - VariantPreferences(**pref).save() - - if pref.get("id"): - pref_id = pref.get("id") - submitted_pref_ids.append(pref_id) - - removed_ids = set(existing_pref_ids) - set(submitted_pref_ids) - - if removed_ids: - VariantPreferences.objects.filter(id__in=removed_ids).delete() - - return instance - - -class ExperimentChangelogVariantSerializer(serializers.ModelSerializer): - - class Meta: - model = ExperimentVariant - fields = ("id", "description", "is_control", "name", "ratio", "value") - - -class ExperimentDesignPrefSerializer(PrefValidationMixin, ExperimentDesignBaseSerializer): - is_multi_pref = serializers.BooleanField() - pref_key = serializers.CharField(max_length=255) - pref_type = serializers.CharField(max_length=255) - pref_branch = serializers.CharField(max_length=255) - variants = ExperimentDesignVariantPrefSerializer(many=True) - - class Meta: - model = Experiment - fields = ("is_multi_pref", "pref_key", "pref_type", "pref_branch", "variants") - - def validate_pref_type(self, value): - if value == "Firefox Pref Type": - raise serializers.ValidationError(["Please select a type."]) - - return value - - def validate_pref_branch(self, value): - if value == "Firefox Pref Branch": - raise serializers.ValidationError(["Please select a branch."]) - - return value - - def validate(self, data): - super().validate(data) - - variants = data["variants"] - - if not len(set(variant["value"] for variant in variants)) == len(variants): - error_list = [] - for variant in variants: - error_list.append( - {"value": ["All branches must have a unique pref value."]} - ) - - raise serializers.ValidationError({"variants": error_list}) - - error_list = [] - pref_type = data.get("pref_type", "") - for variant in variants: - error_list.append(self.validate_pref(pref_type, variant["value"], "value")) - - if any(error_list): - raise serializers.ValidationError({"variants": error_list}) - return data - - -class ExperimentDesignAddonSerializer(ExperimentDesignBaseSerializer): - addon_release_url = serializers.URLField(max_length=400) - is_branched_addon = serializers.BooleanField() - - class Meta: - model = Experiment - fields = ("addon_release_url", "variants", "is_branched_addon") - - -class ExperimentDesignGenericSerializer(ExperimentDesignBaseSerializer): - design = serializers.CharField(allow_null=True, allow_blank=True, required=False) - - class Meta: - model = Experiment - fields = ("design", "variants") - - -class ExperimentBranchedAddonVariantSerializer(ExperimentDesignVariantBaseSerializer): - addon_release_url = serializers.URLField(max_length=400) - - class Meta(ExperimentDesignVariantBaseSerializer.Meta): - model = ExperimentVariant - fields = ["addon_release_url", "id", "description", "is_control", "name", "ratio"] - - -class ExperimentDesignBranchedAddonSerializer(ExperimentDesignBaseSerializer): - variants = ExperimentBranchedAddonVariantSerializer(many=True) - is_branched_addon = serializers.BooleanField() - - class Meta: - model = Experiment - fields = ("is_branched_addon", "variants") - - -class ExperimentDesignRolloutSerializer( - PrefValidationMixin, ExperimentDesignBaseSerializer -): - rollout_type = serializers.ChoiceField(choices=Experiment.ROLLOUT_TYPE_CHOICES) - addon_release_url = serializers.URLField( - max_length=400, allow_null=True, required=False - ) - - ROLLOUT_TYPE_FIELDS = { - Experiment.TYPE_PREF: ("pref_key", "pref_type", "pref_value"), - Experiment.TYPE_ADDON: ("addon_release_url",), - } - - class Meta: - model = Experiment - fields = ( - "rollout_type", - "design", - "addon_release_url", - "pref_key", - "pref_type", - "pref_value", - ) - - def validate(self, data): - data = super().validate(data) - rollout_type = data["rollout_type"] - - errors = {} - - for type_field in self.ROLLOUT_TYPE_FIELDS[rollout_type]: - if type_field not in data or not data[type_field]: - errors[type_field] = ["This field is required."] - - if errors: - raise serializers.ValidationError(errors) - - if data["rollout_type"] == Experiment.TYPE_PREF: - pref_invalid = self.validate_pref( - data["pref_type"], data["pref_value"], "pref_value" - ) - if pref_invalid: - raise serializers.ValidationError(pref_invalid) - - return data diff --git a/app/experimenter/experiments/serializers/__init__.py b/app/experimenter/experiments/serializers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/experimenter/experiments/serializers/clone.py b/app/experimenter/experiments/serializers/clone.py new file mode 100644 index 0000000000..e5c7565dfc --- /dev/null +++ b/app/experimenter/experiments/serializers/clone.py @@ -0,0 +1,37 @@ + +from rest_framework import serializers +from django.utils.text import slugify +from django.urls import reverse +from django.db.models import Q + +from experimenter.experiments.models import Experiment + + +class ExperimentCloneSerializer(serializers.ModelSerializer): + clone_url = serializers.SerializerMethodField() + + class Meta: + model = Experiment + fields = ("name", "clone_url") + + def validate_name(self, value): + existing_slug_or_name = Experiment.objects.filter( + Q(slug=slugify(value)) | Q(name=value) + ) + + if existing_slug_or_name: + raise serializers.ValidationError("This experiment name already exists.") + + if slugify(value): + return value + else: + raise serializers.ValidationError("That's an invalid name.") + + def get_clone_url(self, obj): + return reverse("experiments-detail", kwargs={"slug": obj.slug}) + + def update(self, instance, validated_data): + user = self.context["request"].user + name = validated_data.get("name") + + return instance.clone(name, user) diff --git a/app/experimenter/experiments/serializers/design.py b/app/experimenter/experiments/serializers/design.py new file mode 100644 index 0000000000..841a959ce5 --- /dev/null +++ b/app/experimenter/experiments/serializers/design.py @@ -0,0 +1,398 @@ +import json + +from rest_framework import serializers +from django.utils.text import slugify + +from experimenter.experiments.models import ( + Experiment, + ExperimentVariant, + VariantPreferences, +) +from experimenter.experiments.serializers.entities import ChangeLogSerializer +from experimenter.experiments.changelog_utils import generate_change_log + + +class ChangelogSerializerMixin(object): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.id: + self.old_serialized_vals = ChangeLogSerializer(self.instance).data + + def update_changelog(self, instance, validated_data): + new_serialized_vals = ChangeLogSerializer(instance).data + user = self.context["request"].user + changed_data = validated_data.copy() + generate_change_log( + self.old_serialized_vals, new_serialized_vals, instance, changed_data, user + ) + + return instance + + +class PrefValidationMixin(object): + + def validate_pref(self, pref_type, pref_value, field_name): + if pref_type == "integer": + try: + int(pref_value) + except ValueError: + return {field_name: "The pref value must be an integer."} + + if pref_type == "boolean": + if pref_value not in ["true", "false"]: + return {field_name: "The pref value must be a boolean."} + + if pref_type == "json string": + try: + json.loads(pref_value) + except ValueError: + return {field_name: "The pref value must be valid JSON."} + return {} + + +class VariantsListSerializer(serializers.ListSerializer): + + def to_representation(self, data): + data = super().to_representation(data) + + if data == []: + blank_variant = {} + control_blank_variant = {} + initial_fields = set(self.child.fields) - set(["id"]) + for field in initial_fields: + blank_variant[field] = None + control_blank_variant[field] = None + + blank_variant["is_control"] = False + blank_variant["ratio"] = 50 + control_blank_variant["is_control"] = True + control_blank_variant["ratio"] = 50 + + if "preferences" in initial_fields: + blank_variant["preferences"] = [{}] + control_blank_variant["preferences"] = [{}] + + data = [control_blank_variant, blank_variant] + + control_branch = [b for b in data if b["is_control"]][0] + treatment_branches = sorted( + [b for b in data if not b["is_control"]], key=lambda b: b.get("id") + ) + + return [control_branch] + treatment_branches + + +class ExperimentDesignVariantBaseSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + description = serializers.CharField() + is_control = serializers.BooleanField() + name = serializers.CharField(max_length=255) + ratio = serializers.IntegerField() + + def validate_ratio(self, value): + if 1 <= value <= 100: + return value + + raise serializers.ValidationError(["Branch sizes must be between 1 and 100."]) + + class Meta: + list_serializer_class = VariantsListSerializer + fields = ["id", "description", "is_control", "name", "ratio"] + model = ExperimentVariant + + +class ExperimentDesignVariantPrefSerializer(ExperimentDesignVariantBaseSerializer): + value = serializers.CharField() + + class Meta(ExperimentDesignVariantBaseSerializer.Meta): + fields = ["id", "description", "is_control", "name", "ratio", "value"] + model = ExperimentVariant + + +class ExperimentDesignBranchVariantPreferencesSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + pref_name = serializers.CharField(max_length=255) + pref_type = serializers.CharField(max_length=255) + pref_branch = serializers.CharField(max_length=255) + pref_value = serializers.CharField(max_length=255) + + class Meta: + model = VariantPreferences + fields = ["id", "pref_name", "pref_type", "pref_branch", "pref_value"] + + +class ExperimentDesignBranchMultiPrefSerializer( + PrefValidationMixin, ExperimentDesignVariantBaseSerializer +): + preferences = ExperimentDesignBranchVariantPreferencesSerializer(many=True) + + class Meta(ExperimentDesignVariantBaseSerializer.Meta): + fields = ["id", "description", "is_control", "name", "ratio", "preferences"] + model = ExperimentVariant + + def validate_preferences(self, data): + if not self.is_pref_valid(data): + error_list = [{"pref_name": "Pref name per Branch needs to be unique"}] * len( + data + ) + raise serializers.ValidationError(error_list) + self.validate_value_type_match(data) + return data + + def is_pref_valid(self, preferences): + unique_names = len( + set([slugify(pref["pref_name"]) for pref in preferences]) + ) == len(preferences) + + return unique_names + + def validate_value_type_match(self, preferences): + error_list = [] + for pref in preferences: + pref_type = pref.get("pref_type", "") + pref_value = pref["pref_value"] + field_name = "pref_value" + error_list.append(self.validate_pref(pref_type, pref_value, field_name)) + + if any(error_list): + raise serializers.ValidationError(error_list) + + +class ExperimentDesignBaseSerializer( + ChangelogSerializerMixin, serializers.ModelSerializer +): + variants = ExperimentDesignVariantBaseSerializer(many=True) + + class Meta: + model = Experiment + fields = ("variants",) + + def validate(self, data): + variants = data.get("variants") + + if variants: + if sum([variant["ratio"] for variant in variants]) != 100: + error_list = [] + for variant in variants: + error_list.append({"ratio": ["All branch sizes must add up to 100."]}) + + raise serializers.ValidationError({"variants": error_list}) + + if not self.is_variant_valid(variants): + error_list = [] + for variant in variants: + error_list.append( + {"name": [("All branches must have a unique name")]} + ) + + raise serializers.ValidationError({"variants": error_list}) + + return data + + def is_variant_valid(self, variants): + + slugified_nanes = [slugify(variant["name"]) for variant in variants] + unique_names = len(set(slugified_nanes)) == len(variants) + non_empty = all(slugified_nanes) + + return unique_names and non_empty + + def update(self, instance, validated_data): + variants_data = validated_data.pop("variants", []) + instance = super().update(instance, validated_data) + + if variants_data: + existing_variant_ids = set( + instance.variants.all().values_list("id", flat=True) + ) + # Create or update variants + for variant_data in variants_data: + variant_data["experiment"] = instance + variant_data["slug"] = slugify(variant_data["name"]) + ExperimentVariant(**variant_data).save() + + # Delete removed variants + submitted_variant_ids = set( + [v.get("id") for v in variants_data if v.get("id")] + ) + removed_ids = existing_variant_ids - submitted_variant_ids + + if removed_ids: + ExperimentVariant.objects.filter(id__in=removed_ids).delete() + + self.update_changelog(instance, validated_data) + + return instance + + +class ExperimentDesignRolloutSerializer( + PrefValidationMixin, ExperimentDesignBaseSerializer +): + rollout_type = serializers.ChoiceField(choices=Experiment.ROLLOUT_TYPE_CHOICES) + addon_release_url = serializers.URLField( + max_length=400, allow_null=True, required=False + ) + + ROLLOUT_TYPE_FIELDS = { + Experiment.TYPE_PREF: ("pref_key", "pref_type", "pref_value"), + Experiment.TYPE_ADDON: ("addon_release_url",), + } + + class Meta: + model = Experiment + fields = ( + "rollout_type", + "design", + "addon_release_url", + "pref_key", + "pref_type", + "pref_value", + ) + + def validate(self, data): + data = super().validate(data) + rollout_type = data["rollout_type"] + + errors = {} + + for type_field in self.ROLLOUT_TYPE_FIELDS[rollout_type]: + if type_field not in data or not data[type_field]: + errors[type_field] = ["This field is required."] + + if errors: + raise serializers.ValidationError(errors) + + if data["rollout_type"] == Experiment.TYPE_PREF: + pref_invalid = self.validate_pref( + data["pref_type"], data["pref_value"], "pref_value" + ) + if pref_invalid: + raise serializers.ValidationError(pref_invalid) + + return data + + +class ExperimentDesignMultiPrefSerializer(ExperimentDesignBaseSerializer): + is_multi_pref = serializers.BooleanField() + variants = ExperimentDesignBranchMultiPrefSerializer(many=True) + + class Meta: + model = Experiment + fields = ("is_multi_pref", "variants") + + def update(self, instance, validated_data): + variant_preferences = [ + (v_d, v_d.pop("preferences")) for v_d in validated_data["variants"] + ] + + instance = super().update(instance, validated_data) + existing_pref_ids = list( + instance.variants.all().values_list("preferences__id", flat=True) + ) + submitted_pref_ids = [] + for variant_data, prefs in variant_preferences: + + variant = instance.variants.get(name=variant_data["name"]) + for pref in prefs: + pref["variant_id"] = variant.id + VariantPreferences(**pref).save() + + if pref.get("id"): + pref_id = pref.get("id") + submitted_pref_ids.append(pref_id) + + removed_ids = set(existing_pref_ids) - set(submitted_pref_ids) + + if removed_ids: + VariantPreferences.objects.filter(id__in=removed_ids).delete() + + return instance + + +class ExperimentChangelogVariantSerializer(serializers.ModelSerializer): + + class Meta: + model = ExperimentVariant + fields = ("id", "description", "is_control", "name", "ratio", "value") + + +class ExperimentDesignPrefSerializer(PrefValidationMixin, ExperimentDesignBaseSerializer): + is_multi_pref = serializers.BooleanField() + pref_key = serializers.CharField(max_length=255) + pref_type = serializers.CharField(max_length=255) + pref_branch = serializers.CharField(max_length=255) + variants = ExperimentDesignVariantPrefSerializer(many=True) + + class Meta: + model = Experiment + fields = ("is_multi_pref", "pref_key", "pref_type", "pref_branch", "variants") + + def validate_pref_type(self, value): + if value == "Firefox Pref Type": + raise serializers.ValidationError(["Please select a type."]) + + return value + + def validate_pref_branch(self, value): + if value == "Firefox Pref Branch": + raise serializers.ValidationError(["Please select a branch."]) + + return value + + def validate(self, data): + super().validate(data) + + variants = data["variants"] + + if not len(set(variant["value"] for variant in variants)) == len(variants): + error_list = [] + for variant in variants: + error_list.append( + {"value": ["All branches must have a unique pref value."]} + ) + + raise serializers.ValidationError({"variants": error_list}) + + error_list = [] + pref_type = data.get("pref_type", "") + for variant in variants: + error_list.append(self.validate_pref(pref_type, variant["value"], "value")) + + if any(error_list): + raise serializers.ValidationError({"variants": error_list}) + return data + + +class ExperimentDesignAddonSerializer(ExperimentDesignBaseSerializer): + addon_release_url = serializers.URLField(max_length=400) + is_branched_addon = serializers.BooleanField() + + class Meta: + model = Experiment + fields = ("addon_release_url", "variants", "is_branched_addon") + + +class ExperimentDesignGenericSerializer(ExperimentDesignBaseSerializer): + design = serializers.CharField(allow_null=True, allow_blank=True, required=False) + + class Meta: + model = Experiment + fields = ("design", "variants") + + +class ExperimentBranchedAddonVariantSerializer(ExperimentDesignVariantBaseSerializer): + addon_release_url = serializers.URLField(max_length=400) + + class Meta(ExperimentDesignVariantBaseSerializer.Meta): + model = ExperimentVariant + fields = ["addon_release_url", "id", "description", "is_control", "name", "ratio"] + + +class ExperimentDesignBranchedAddonSerializer(ExperimentDesignBaseSerializer): + variants = ExperimentBranchedAddonVariantSerializer(many=True) + is_branched_addon = serializers.BooleanField() + + class Meta: + model = Experiment + fields = ("is_branched_addon", "variants") diff --git a/app/experimenter/experiments/serializers/entities.py b/app/experimenter/experiments/serializers/entities.py new file mode 100644 index 0000000000..6fe71276ef --- /dev/null +++ b/app/experimenter/experiments/serializers/entities.py @@ -0,0 +1,185 @@ +import time +from rest_framework import serializers + + +from experimenter.experiments.models import ( + Experiment, + ExperimentVariant, + ExperimentChangeLog, +) +from experimenter.experiments.serializers.geo import CountrySerializer, LocaleSerializer + + +class JSTimestampField(serializers.Field): + """ + Serialize a datetime object into javascript timestamp + ie unix time in ms + """ + + def to_representation(self, obj): + if obj: + return time.mktime(obj.timetuple()) * 1000 + else: + return None + + +class PrefTypeField(serializers.Field): + + def to_representation(self, obj): + if obj == Experiment.PREF_TYPE_JSON_STR: + return Experiment.PREF_TYPE_STR + else: + return obj + + +class ExperimentVariantSerializer(serializers.ModelSerializer): + + class Meta: + model = ExperimentVariant + fields = ( + "description", + "is_control", + "name", + "ratio", + "slug", + "value", + "addon_release_url", + ) + + +class ChangeLogSerializer(serializers.ModelSerializer): + variants = ExperimentVariantSerializer(many=True, required=False) + locales = LocaleSerializer(many=True, required=False) + countries = CountrySerializer(many=True, required=False) + pref_type = PrefTypeField() + + class Meta: + model = Experiment + fields = ( + "type", + "owner", + "name", + "short_description", + "related_work", + "related_to", + "proposed_start_date", + "proposed_duration", + "proposed_enrollment", + "design", + "addon_experiment_id", + "addon_release_url", + "pref_key", + "pref_type", + "pref_branch", + "public_name", + "public_description", + "population_percent", + "firefox_min_version", + "firefox_max_version", + "firefox_channel", + "client_matching", + "locales", + "countries", + "platform", + "objectives", + "analysis", + "analysis_owner", + "survey_required", + "survey_urls", + "survey_instructions", + "engineering_owner", + "bugzilla_id", + "normandy_slug", + "normandy_id", + "data_science_bugzilla_url", + "feature_bugzilla_url", + "risk_internal_only", + "risk_partner_related", + "risk_brand", + "risk_fast_shipped", + "risk_confidential", + "risk_release_population", + "risk_revenue", + "risk_data_category", + "risk_external_team_impact", + "risk_telemetry_data", + "risk_ux", + "risk_security", + "risk_revision", + "risk_technical", + "risk_technical_description", + "risks", + "testing", + "test_builds", + "qa_status", + "review_science", + "review_engineering", + "review_qa_requested", + "review_intent_to_ship", + "review_bugzilla", + "review_qa", + "review_relman", + "review_advisory", + "review_legal", + "review_ux", + "review_security", + "review_vp", + "review_data_steward", + "review_comms", + "review_impacted_teams", + "variants", + "results_url", + "results_initial", + "results_lessons_learned", + ) + + +class ExperimentChangeLogSerializer(serializers.ModelSerializer): + + class Meta: + model = ExperimentChangeLog + fields = ("changed_on", "pretty_status", "new_status", "old_status") + + +class ExperimentSerializer(serializers.ModelSerializer): + start_date = JSTimestampField() + end_date = JSTimestampField() + proposed_start_date = JSTimestampField() + variants = ExperimentVariantSerializer(many=True) + locales = LocaleSerializer(many=True) + countries = CountrySerializer(many=True) + pref_type = PrefTypeField() + changes = ExperimentChangeLogSerializer(many=True) + + class Meta: + model = Experiment + fields = ( + "experiment_url", + "type", + "name", + "slug", + "public_name", + "public_description", + "status", + "client_matching", + "locales", + "countries", + "platform", + "start_date", + "end_date", + "population", + "population_percent", + "firefox_channel", + "firefox_min_version", + "firefox_max_version", + "addon_experiment_id", + "addon_release_url", + "pref_branch", + "pref_key", + "pref_type", + "proposed_start_date", + "proposed_enrollment", + "proposed_duration", + "variants", + "changes", + ) diff --git a/app/experimenter/experiments/serializers/geo.py b/app/experimenter/experiments/serializers/geo.py new file mode 100644 index 0000000000..96062b11ab --- /dev/null +++ b/app/experimenter/experiments/serializers/geo.py @@ -0,0 +1,16 @@ +from rest_framework import serializers +from experimenter.experiments.models import Locale, Country + + +class LocaleSerializer(serializers.ModelSerializer): + + class Meta: + model = Locale + fields = ("code", "name") + + +class CountrySerializer(serializers.ModelSerializer): + + class Meta: + model = Country + fields = ("code", "name") diff --git a/app/experimenter/experiments/serializers/recipe.py b/app/experimenter/experiments/serializers/recipe.py new file mode 100644 index 0000000000..4419fd9b50 --- /dev/null +++ b/app/experimenter/experiments/serializers/recipe.py @@ -0,0 +1,333 @@ + +import json +from rest_framework import serializers + +from experimenter.experiments.models import ( + Experiment, + ExperimentVariant, + VariantPreferences, +) + +from experimenter.experiments.serializers.entities import PrefTypeField + + +class FilterObjectBucketSampleSerializer(serializers.ModelSerializer): + type = serializers.SerializerMethodField() + input = serializers.ReadOnlyField(default=["normandy.recipe.id", "normandy.userId"]) + start = serializers.ReadOnlyField(default=0) + count = serializers.SerializerMethodField() + total = serializers.ReadOnlyField(default=10000) + + class Meta: + model = Experiment + fields = ("type", "input", "start", "count", "total") + + def get_type(self, obj): + return "bucketSample" + + def get_count(self, obj): + return int(obj.population_percent * 100) + + +class FilterObjectChannelSerializer(serializers.ModelSerializer): + type = serializers.SerializerMethodField() + channels = serializers.SerializerMethodField() + + class Meta: + model = Experiment + fields = ("type", "channels") + + def get_type(self, obj): + return "channel" + + def get_channels(self, obj): + return [obj.firefox_channel.lower()] + + +class FilterObjectVersionsSerializer(serializers.ModelSerializer): + type = serializers.SerializerMethodField() + versions = serializers.SerializerMethodField() + + class Meta: + model = Experiment + fields = ("type", "versions") + + def get_type(self, obj): + return "version" + + def get_versions(self, obj): + return obj.versions_integer_list + + +class FilterObjectLocaleSerializer(serializers.ModelSerializer): + type = serializers.SerializerMethodField() + locales = serializers.SerializerMethodField() + + class Meta: + model = Experiment + fields = ("type", "locales") + + def get_type(self, obj): + return "locale" + + def get_locales(self, obj): + return list(obj.locales.all().values_list("code", flat=True)) + + +class FilterObjectCountrySerializer(serializers.ModelSerializer): + type = serializers.SerializerMethodField() + countries = serializers.SerializerMethodField() + + class Meta: + model = Experiment + fields = ("type", "countries") + + def get_type(self, obj): + return "country" + + def get_countries(self, obj): + return list(obj.countries.all().values_list("code", flat=True)) + + +class ExperimentRecipeVariantSerializer(serializers.ModelSerializer): + value = serializers.SerializerMethodField() + + class Meta: + model = ExperimentVariant + fields = ("ratio", "slug", "value") + + def get_value(self, obj): + pref_type = obj.experiment.pref_type + if pref_type in (Experiment.PREF_TYPE_BOOL, Experiment.PREF_TYPE_INT): + return json.loads(obj.value) + + return obj.value + + +class ExperimentRecipeAddonVariantSerializer(serializers.ModelSerializer): + extensionApiId = serializers.SerializerMethodField() + + class Meta: + model = ExperimentVariant + fields = ("ratio", "slug", "extensionApiId") + + def get_extensionApiId(self, obj): + return None + + +class VariantPreferenceRecipeListSerializer(serializers.ListSerializer): + + def to_representation(self, obj): + experiment = obj.instance.experiment + variant = obj.instance + serialized_data = super().to_representation(obj) + + if experiment.is_multi_pref: + return {entry.pop("pref_name"): entry for entry in serialized_data} + + else: + preference_values = {} + preference_values["preferenceBranchType"] = experiment.pref_branch + preference_values["preferenceType"] = PrefTypeField().to_representation( + experiment.pref_type + ) + preference_values["preferenceValue"] = variant.value + + return {experiment.pref_key: preference_values} + + +class VariantPreferenceRecipeSerializer(serializers.ModelSerializer): + preferenceBranchType = serializers.ReadOnlyField(source="pref_branch") + preferenceType = PrefTypeField(source="pref_type") + preferenceValue = serializers.ReadOnlyField(source="pref_value") + + class Meta: + list_serializer_class = VariantPreferenceRecipeListSerializer + model = VariantPreferences + fields = ( + "preferenceBranchType", + "preferenceType", + "preferenceValue", + "pref_name", + ) + + +class ExperimentRecipeMultiPrefVariantSerializer(serializers.ModelSerializer): + preferences = VariantPreferenceRecipeSerializer(many=True) + + class Meta: + model = ExperimentVariant + fields = ("preferences", "ratio", "slug") + + +class ExperimentRecipePrefArgumentsSerializer(serializers.ModelSerializer): + preferenceBranchType = serializers.ReadOnlyField(source="pref_branch") + slug = serializers.ReadOnlyField(source="normandy_slug") + experimentDocumentUrl = serializers.ReadOnlyField(source="experiment_url") + preferenceName = serializers.ReadOnlyField(source="pref_key") + preferenceType = PrefTypeField(source="pref_type") + branches = ExperimentRecipeVariantSerializer(many=True, source="variants") + + class Meta: + model = Experiment + fields = ( + "preferenceBranchType", + "slug", + "experimentDocumentUrl", + "preferenceName", + "preferenceType", + "branches", + ) + + +class ExperimentRecipeBranchedArgumentsSerializer(serializers.ModelSerializer): + slug = serializers.ReadOnlyField(source="normandy_slug") + userFacingName = userFacingDescription = serializers.ReadOnlyField( + source="public_name" + ) + userFacingDescription = serializers.ReadOnlyField(source="public_description") + branches = serializers.SerializerMethodField() + + class Meta: + model = Experiment + fields = ("slug", "userFacingName", "userFacingDescription") + + +class ExperimentRecipeBranchedAddonArgumentsSerializer( + ExperimentRecipeBranchedArgumentsSerializer +): + slug = serializers.ReadOnlyField(source="normandy_slug") + branches = serializers.SerializerMethodField() + + class Meta: + model = Experiment + fields = ("slug", "userFacingName", "userFacingDescription", "branches") + + def get_branches(self, obj): + return ExperimentRecipeAddonVariantSerializer(obj.variants, many=True).data + + +class ExperimentRecipeMultiPrefArgumentsSerializer( + ExperimentRecipeBranchedArgumentsSerializer +): + slug = serializers.ReadOnlyField(source="normandy_slug") + branches = serializers.SerializerMethodField() + experimentDocumentUrl = serializers.ReadOnlyField(source="experiment_url") + + class Meta: + model = Experiment + fields = ( + "slug", + "userFacingName", + "userFacingDescription", + "branches", + "experimentDocumentUrl", + ) + + def get_branches(self, obj): + return ExperimentRecipeMultiPrefVariantSerializer(obj.variants, many=True).data + + +class ExperimentRecipeAddonArgumentsSerializer(serializers.ModelSerializer): + name = serializers.ReadOnlyField(source="addon_experiment_id") + description = serializers.ReadOnlyField(source="public_description") + + class Meta: + model = Experiment + fields = ("name", "description") + + +class ExperimentRecipeAddonRolloutArgumentsSerializer(serializers.ModelSerializer): + slug = serializers.ReadOnlyField(source="normandy_slug") + extensionApiId = serializers.SerializerMethodField() + + class Meta: + model = Experiment + fields = ("slug", "extensionApiId") + + def get_extensionApiId(self, obj): + return f"TODO: {obj.addon_release_url}" + + +class ExperimentRecipePrefRolloutArgumentsSerializer(serializers.ModelSerializer): + slug = serializers.ReadOnlyField(source="normandy_slug") + preferences = serializers.SerializerMethodField() + + class Meta: + model = Experiment + fields = ("slug", "preferences") + + def get_value(self, obj): + pref_type = obj.pref_type + if pref_type in (Experiment.PREF_TYPE_BOOL, Experiment.PREF_TYPE_INT): + return json.loads(obj.pref_value) + + return obj.pref_value + + def get_preferences(self, obj): + return [{"preferenceName": obj.pref_key, "value": self.get_value(obj)}] + + +class ExperimentRecipeSerializer(serializers.ModelSerializer): + action_name = serializers.SerializerMethodField() + filter_object = serializers.SerializerMethodField() + comment = serializers.SerializerMethodField() + arguments = serializers.SerializerMethodField() + experimenter_slug = serializers.ReadOnlyField(source="slug") + + class Meta: + model = Experiment + fields = ( + "action_name", + "name", + "filter_object", + "comment", + "arguments", + "experimenter_slug", + ) + + def get_action_name(self, obj): + if obj.use_multi_pref_serializer: + return "multi-preference-experiment" + if obj.is_pref_experiment: + return "preference-experiment" + elif obj.use_branched_addon_serializer: + return "branched-addon-study" + elif obj.is_addon_experiment: + return "opt-out-study" + elif obj.is_addon_rollout: + return "addon-rollout" + elif obj.is_pref_rollout: + return "preference-rollout" + + def get_filter_object(self, obj): + filter_objects = [ + FilterObjectBucketSampleSerializer(obj).data, + FilterObjectChannelSerializer(obj).data, + FilterObjectVersionsSerializer(obj).data, + ] + + if obj.locales.count(): + filter_objects.append(FilterObjectLocaleSerializer(obj).data) + + if obj.countries.count(): + filter_objects.append(FilterObjectCountrySerializer(obj).data) + + return filter_objects + + def get_arguments(self, obj): + if obj.use_multi_pref_serializer: + return ExperimentRecipeMultiPrefArgumentsSerializer(obj).data + elif obj.is_pref_experiment: + return ExperimentRecipePrefArgumentsSerializer(obj).data + elif obj.use_branched_addon_serializer: + return ExperimentRecipeBranchedAddonArgumentsSerializer(obj).data + elif obj.is_addon_experiment: + return ExperimentRecipeAddonArgumentsSerializer(obj).data + elif obj.is_addon_rollout: + return ExperimentRecipeAddonRolloutArgumentsSerializer(obj).data + elif obj.is_pref_rollout: + return ExperimentRecipePrefRolloutArgumentsSerializer(obj).data + + def get_comment(self, obj): + return f"Platform: {obj.platform}\n{obj.client_matching}" diff --git a/app/experimenter/experiments/tests/serializers/__init__.py b/app/experimenter/experiments/tests/serializers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/experimenter/experiments/tests/serializers/test_clone.py b/app/experimenter/experiments/tests/serializers/test_clone.py new file mode 100644 index 0000000000..ee584e9f7e --- /dev/null +++ b/app/experimenter/experiments/tests/serializers/test_clone.py @@ -0,0 +1,54 @@ + +from django.test import TestCase + +from experimenter.experiments.tests.factories import ExperimentFactory + + +from experimenter.experiments.serializers.clone import ExperimentCloneSerializer +from experimenter.experiments.tests.mixins import MockRequestMixin + + +class TestCloneSerializer(MockRequestMixin, TestCase): + + def test_clone_serializer_rejects_duplicate_slug(self): + experiment_1 = ExperimentFactory.create( + name="good experiment", slug="great-experiment" + ) + clone_data = {"name": "great experiment"} + serializer = ExperimentCloneSerializer(instance=experiment_1, data=clone_data) + + self.assertFalse(serializer.is_valid()) + + def test_clone_serializer_rejects_duplicate_name(self): + experiment = ExperimentFactory.create( + name="wonderful experiment", slug="amazing-experiment" + ) + clone_data = {"name": "wonderful experiment"} + serializer = ExperimentCloneSerializer(instance=experiment, data=clone_data) + + self.assertFalse(serializer.is_valid()) + + def test_clone_serializer_rejects_invalid_name(self): + experiment = ExperimentFactory.create( + name="great experiment", slug="great-experiment" + ) + + clone_data = {"name": "@@@@@@@@"} + serializer = ExperimentCloneSerializer(instance=experiment, data=clone_data) + + self.assertFalse(serializer.is_valid()) + + def test_clone_serializer_accepts_unique_name(self): + experiment = ExperimentFactory.create( + name="great experiment", slug="great-experiment" + ) + clone_data = {"name": "best experiment"} + serializer = ExperimentCloneSerializer( + instance=experiment, data=clone_data, context={"request": self.request} + ) + self.assertTrue(serializer.is_valid()) + + serializer.save() + + self.assertEqual(serializer.data["name"], "best experiment") + self.assertEqual(serializer.data["clone_url"], "/experiments/best-experiment/") diff --git a/app/experimenter/experiments/tests/test_serializers.py b/app/experimenter/experiments/tests/serializers/test_design.py similarity index 53% rename from app/experimenter/experiments/tests/test_serializers.py rename to app/experimenter/experiments/tests/serializers/test_design.py index 6b3cc93122..b679eb49f4 100644 --- a/app/experimenter/experiments/tests/test_serializers.py +++ b/app/experimenter/experiments/tests/serializers/test_design.py @@ -1,1128 +1,36 @@ -import datetime -from decimal import Decimal -from django.test import TestCase - -from experimenter.experiments.models import ( - Experiment, - ExperimentChangeLog, - ExperimentVariant, -) -from experimenter.experiments.tests.factories import ( - CountryFactory, - ExperimentChangeLogFactory, - ExperimentFactory, - ExperimentVariantFactory, - LocaleFactory, - UserFactory, - VariantPreferencesFactory, -) -from experimenter.experiments.serializers import ( - ChangeLogSerializer, - CountrySerializer, - ExperimentChangeLogSerializer, - ExperimentCloneSerializer, - ExperimentDesignAddonSerializer, - ExperimentDesignBaseSerializer, - ExperimentDesignBranchMultiPrefSerializer, - ExperimentDesignBranchVariantPreferencesSerializer, - ExperimentDesignBranchedAddonSerializer, - ExperimentDesignGenericSerializer, - ExperimentDesignMultiPrefSerializer, - ExperimentDesignPrefSerializer, - ExperimentDesignRolloutSerializer, - ExperimentDesignVariantBaseSerializer, - ExperimentRecipeAddonArgumentsSerializer, - ExperimentRecipeAddonRolloutArgumentsSerializer, - ExperimentRecipeAddonVariantSerializer, - ExperimentRecipeMultiPrefVariantSerializer, - ExperimentRecipePrefArgumentsSerializer, - ExperimentRecipePrefRolloutArgumentsSerializer, - ExperimentRecipeSerializer, - ExperimentRecipeVariantSerializer, - ExperimentSerializer, - ExperimentVariantSerializer, - FilterObjectBucketSampleSerializer, - FilterObjectChannelSerializer, - FilterObjectCountrySerializer, - FilterObjectLocaleSerializer, - FilterObjectVersionsSerializer, - JSTimestampField, - LocaleSerializer, - PrefTypeField, - PrefValidationMixin, -) -from experimenter.experiments.constants import ExperimentConstants -from experimenter.experiments.tests.mixins import MockRequestMixin - - -class TestJSTimestampField(TestCase): - - def test_field_serializes_to_js_time_format(self): - field = JSTimestampField() - example_datetime = datetime.datetime(2000, 1, 1, 1, 1, 1, 1) - self.assertEqual(field.to_representation(example_datetime), 946688461000.0) - - def test_field_returns_none_if_no_datetime_passed_in(self): - field = JSTimestampField() - self.assertEqual(field.to_representation(None), None) - - -class TestPrefTypeField(TestCase): - - def test_non_json_field(self): - field = PrefTypeField() - self.assertEqual( - field.to_representation(Experiment.PREF_TYPE_INT), Experiment.PREF_TYPE_INT - ) - - def test_json_field(self): - field = PrefTypeField() - self.assertEqual( - field.to_representation(Experiment.PREF_TYPE_JSON_STR), - Experiment.PREF_TYPE_STR, - ) - - -class TestPrefValidationMixin(TestCase): - - def test_matching_json_string_type_value(self): - pref_type = "json string" - pref_value = "{}" - key_value = "key_value" - validator = PrefValidationMixin() - value = validator.validate_pref(pref_type, pref_value, key_value) - - self.assertEqual(value, {}) - - def test_non_matching_json_string_type_value(self): - pref_type = "json string" - pref_value = "not a json string" - key_value = "key_value" - validator = PrefValidationMixin() - value = validator.validate_pref(pref_type, pref_value, key_value) - - self.assertEqual(value, {key_value: "The pref value must be valid JSON."}) - - def test_matching_integer_type_value(self): - pref_type = "integer" - pref_value = "8" - key_value = "key_value" - validator = PrefValidationMixin() - value = validator.validate_pref(pref_type, pref_value, key_value) - - self.assertEqual(value, {}) - - def test_non_matching_integer_type_value(self): - pref_type = "integer" - pref_value = "not a integer" - key_value = "key_value" - validator = PrefValidationMixin() - value = validator.validate_pref(pref_type, pref_value, key_value) - - self.assertEqual(value, {key_value: "The pref value must be an integer."}) - - def test_matching_boolean_type_value(self): - pref_type = "boolean" - pref_value = "true" - key_value = "key_value" - validator = PrefValidationMixin() - value = validator.validate_pref(pref_type, pref_value, key_value) - - self.assertEqual(value, {}) - - def test_non_matching_boolean_type_value(self): - pref_type = "boolean" - pref_value = "not a boolean" - key_value = "key_value" - validator = PrefValidationMixin() - value = validator.validate_pref(pref_type, pref_value, key_value) - - self.assertEqual(value, {key_value: "The pref value must be a boolean."}) - - -class TestExperimentVariantSerializer(TestCase): - - def test_serializer_outputs_expected_bool(self): - experiment = ExperimentFactory(pref_type=Experiment.PREF_TYPE_BOOL) - variant = ExperimentVariantFactory.create(experiment=experiment, value="true") - serializer = ExperimentRecipeVariantSerializer(variant) - - self.assertEqual(type(serializer.data["value"]), bool) - self.assertEqual( - serializer.data, {"ratio": variant.ratio, "slug": variant.slug, "value": True} - ) - - def test_serializer_outputs_expected_int_val(self): - experiment = ExperimentFactory(pref_type=Experiment.PREF_TYPE_INT) - variant = ExperimentVariantFactory.create(experiment=experiment, value="28") - serializer = ExperimentRecipeVariantSerializer(variant) - - self.assertEqual(type(serializer.data["value"]), int) - self.assertEqual( - serializer.data, {"ratio": variant.ratio, "slug": variant.slug, "value": 28} - ) - - def test_serializer_outputs_expected_str_val(self): - experiment = ExperimentFactory(pref_type=Experiment.PREF_TYPE_STR) - variant = ExperimentVariantFactory.create(experiment=experiment) - serializer = ExperimentRecipeVariantSerializer(variant) - - self.assertEqual(type(serializer.data["value"]), str) - self.assertEqual( - serializer.data, - {"ratio": variant.ratio, "slug": variant.slug, "value": variant.value}, - ) - - -class TestLocaleSerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - locale = LocaleFactory.create() - serializer = LocaleSerializer(locale) - self.assertEqual(serializer.data, {"code": locale.code, "name": locale.name}) - - -class TestExperimentChangeLogSerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - change_log = ExperimentChangeLogFactory.create( - changed_on="2019-08-02T18:19:26.267960Z" - ) - serializer = ExperimentChangeLogSerializer(change_log) - self.assertEqual(serializer.data["changed_on"], change_log.changed_on) - - -class TestChangeLogSerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - country1 = CountryFactory(code="CA", name="Canada") - locale1 = LocaleFactory(code="da", name="Danish") - experiment = ExperimentFactory.create(locales=[locale1], countries=[country1]) - - related_exp = ExperimentFactory.create() - experiment.related_to.add(related_exp) - - serializer = ChangeLogSerializer(experiment) - - risk_tech_description = experiment.risk_technical_description - # ensure expected_data has "string" if pref_type is json string - pref_type = PrefTypeField().to_representation(experiment.pref_type) - expected_data = { - "type": experiment.type, - "owner": experiment.owner.id, - "name": experiment.name, - "short_description": experiment.short_description, - "related_work": experiment.related_work, - "related_to": [related_exp.id], - "proposed_start_date": str(experiment.proposed_start_date), - "proposed_duration": experiment.proposed_duration, - "proposed_enrollment": experiment.proposed_enrollment, - "design": experiment.design, - "addon_experiment_id": experiment.addon_experiment_id, - "addon_release_url": experiment.addon_release_url, - "pref_key": experiment.pref_key, - "pref_type": pref_type, - "pref_branch": experiment.pref_branch, - "public_name": experiment.public_name, - "public_description": experiment.public_description, - "population_percent": "{0:.4f}".format(experiment.population_percent), - "firefox_min_version": experiment.firefox_min_version, - "firefox_max_version": experiment.firefox_max_version, - "firefox_channel": experiment.firefox_channel, - "client_matching": experiment.client_matching, - "locales": [{"code": "da", "name": "Danish"}], - "countries": [{"code": "CA", "name": "Canada"}], - "platform": experiment.platform, - "objectives": experiment.objectives, - "analysis": experiment.analysis, - "analysis_owner": experiment.analysis_owner.id, - "survey_required": experiment.survey_required, - "survey_urls": experiment.survey_urls, - "survey_instructions": experiment.survey_instructions, - "engineering_owner": experiment.engineering_owner, - "bugzilla_id": experiment.bugzilla_id, - "normandy_slug": experiment.normandy_slug, - "normandy_id": experiment.normandy_id, - "data_science_bugzilla_url": experiment.data_science_bugzilla_url, - "feature_bugzilla_url": experiment.feature_bugzilla_url, - "risk_internal_only": experiment.risk_internal_only, - "risk_partner_related": experiment.risk_partner_related, - "risk_brand": experiment.risk_brand, - "risk_fast_shipped": experiment.risk_fast_shipped, - "risk_confidential": experiment.risk_confidential, - "risk_release_population": experiment.risk_release_population, - "risk_revenue": experiment.risk_revenue, - "risk_data_category": experiment.risk_data_category, - "risk_external_team_impact": experiment.risk_external_team_impact, - "risk_telemetry_data": experiment.risk_telemetry_data, - "risk_ux": experiment.risk_ux, - "risk_security": experiment.risk_security, - "risk_revision": experiment.risk_revision, - "risk_technical": experiment.risk_technical, - "risk_technical_description": risk_tech_description, - "risks": experiment.risks, - "testing": experiment.testing, - "test_builds": experiment.test_builds, - "qa_status": experiment.qa_status, - "review_science": experiment.review_science, - "review_engineering": experiment.review_engineering, - "review_qa_requested": experiment.review_qa_requested, - "review_intent_to_ship": experiment.review_intent_to_ship, - "review_bugzilla": experiment.review_bugzilla, - "review_qa": experiment.review_qa, - "review_relman": experiment.review_relman, - "review_advisory": experiment.review_advisory, - "review_legal": experiment.review_legal, - "review_ux": experiment.review_ux, - "review_security": experiment.review_security, - "review_vp": experiment.review_vp, - "review_data_steward": experiment.review_data_steward, - "review_comms": experiment.review_comms, - "review_impacted_teams": experiment.review_impacted_teams, - "variants": [ - ExperimentVariantSerializer(variant).data - for variant in experiment.variants.all() - ], - "results_url": experiment.results_url, - "results_initial": experiment.results_initial, - "results_lessons_learned": experiment.results_lessons_learned, - } - - self.assertEqual(set(serializer.data.keys()), set(expected_data.keys())) - - self.assertEqual(serializer.data, expected_data) - - -class TestCountrySerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - country = CountryFactory.create() - serializer = CountrySerializer(country) - self.assertEqual(serializer.data, {"code": country.code, "name": country.name}) - - -class TestExperimentSerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - experiment = ExperimentFactory.create_with_status( - Experiment.STATUS_COMPLETE, countries=[], locales=[] - ) - - # ensure expected_data has "string" if pref_type is json string - pref_type = PrefTypeField().to_representation(experiment.pref_type) - serializer = ExperimentSerializer(experiment) - expected_data = { - "client_matching": experiment.client_matching, - "platform": experiment.platform, - "end_date": JSTimestampField().to_representation(experiment.end_date), - "experiment_url": experiment.experiment_url, - "firefox_channel": experiment.firefox_channel, - "firefox_min_version": experiment.firefox_min_version, - "firefox_max_version": experiment.firefox_max_version, - "name": experiment.name, - "population": experiment.population, - "population_percent": "{0:.4f}".format(experiment.population_percent), - "pref_branch": experiment.pref_branch, - "pref_key": experiment.pref_key, - "pref_type": pref_type, - "addon_experiment_id": experiment.addon_experiment_id, - "addon_release_url": experiment.addon_release_url, - "proposed_start_date": JSTimestampField().to_representation( - experiment.proposed_start_date - ), - "proposed_enrollment": experiment.proposed_enrollment, - "proposed_duration": experiment.proposed_duration, - "public_name": experiment.public_name, - "public_description": experiment.public_description, - "slug": experiment.slug, - "start_date": JSTimestampField().to_representation(experiment.start_date), - "status": Experiment.STATUS_COMPLETE, - "type": experiment.type, - "variants": [ - ExperimentVariantSerializer(variant).data - for variant in experiment.variants.all() - ], - "locales": [], - "countries": [], - "changes": [ - ExperimentChangeLogSerializer(change).data - for change in experiment.changes.all() - ], - } - - self.assertEqual(set(serializer.data.keys()), set(expected_data.keys())) - self.assertEqual(serializer.data, expected_data) - - def test_serializer_locales(self): - locale = LocaleFactory() - experiment = ExperimentFactory.create(locales=[locale]) - serializer = ExperimentSerializer(experiment) - self.assertEqual( - serializer.data["locales"], [{"code": locale.code, "name": locale.name}] - ) - - def test_serializer_countries(self): - country = CountryFactory() - experiment = ExperimentFactory.create(countries=[country]) - serializer = ExperimentSerializer(experiment) - self.assertEqual( - serializer.data["countries"], [{"code": country.code, "name": country.name}] - ) - - -class TestFilterObjectBucketSampleSerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - experiment = ExperimentFactory.create(population_percent=Decimal("12.34")) - serializer = FilterObjectBucketSampleSerializer(experiment) - self.assertEqual( - serializer.data, - { - "type": "bucketSample", - "input": ["normandy.recipe.id", "normandy.userId"], - "start": 0, - "count": 1234, - "total": 10000, - }, - ) - - -class TestFilterObjectChannelSerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - experiment = ExperimentFactory.create(firefox_channel=Experiment.CHANNEL_NIGHTLY) - serializer = FilterObjectChannelSerializer(experiment) - self.assertEqual(serializer.data, {"type": "channel", "channels": ["nightly"]}) - - -class TestFilterObjectVersionsSerializer(TestCase): - - def test_serializer_outputs_version_string_with_only_min(self): - experiment = ExperimentFactory.create( - firefox_min_version="68.0", firefox_max_version="" - ) - serializer = FilterObjectVersionsSerializer(experiment) - self.assertEqual(serializer.data, {"type": "version", "versions": [68]}) - - def test_serializer_outputs_version_string_with_range(self): - experiment = ExperimentFactory.create( - firefox_min_version="68.0", firefox_max_version="70.0" - ) - serializer = FilterObjectVersionsSerializer(experiment) - self.assertEqual(serializer.data, {"type": "version", "versions": [68, 69, 70]}) - - -class TestFilterObjectLocaleSerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - locale1 = LocaleFactory.create(code="ab") - locale2 = LocaleFactory.create(code="cd") - experiment = ExperimentFactory.create(locales=[locale1, locale2]) - serializer = FilterObjectLocaleSerializer(experiment) - self.assertEqual(serializer.data["type"], "locale") - self.assertEqual(set(serializer.data["locales"]), set(["ab", "cd"])) - - -class TestFilterObjectCountrySerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - country1 = CountryFactory.create(code="ab") - country2 = CountryFactory.create(code="cd") - experiment = ExperimentFactory.create(countries=[country1, country2]) - serializer = FilterObjectCountrySerializer(experiment) - self.assertEqual(serializer.data["type"], "country") - self.assertEqual(set(serializer.data["countries"]), set(["ab", "cd"])) - - -class TestExperimentRecipeAddonVariantSerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - variant = ExperimentVariant(slug="slug-value", ratio=25) - serializer = ExperimentRecipeAddonVariantSerializer(variant) - self.assertEqual( - {"ratio": 25, "slug": "slug-value", "extensionApiId": None}, serializer.data - ) - - -class TestChangeLogSerializerMixin(MockRequestMixin, TestCase): - - def test_update_changelog_creates_no_log_when_no_change(self): - experiment = ExperimentFactory.create_with_status( - target_status=Experiment.STATUS_DRAFT, num_variants=0 - ) - variant = ExperimentVariantFactory.create( - ratio=100, - name="variant name", - experiment=experiment, - value=None, - addon_release_url=None, - ) - - data = { - "variants": [ - { - "id": variant.id, - "ratio": variant.ratio, - "description": variant.description, - "name": variant.name, - "is_control": variant.is_control, - } - ] - } - - self.assertEqual(experiment.changes.count(), 1) - serializer = ExperimentDesignBaseSerializer( - instance=experiment, data=data, context={"request": self.request} - ) - - self.assertTrue(serializer.is_valid()) - experiment = serializer.save() - - self.assertEqual(experiment.changes.count(), 1) - - def test_update_change_log_creates_log_with_correct_change(self): - - experiment = ExperimentFactory.create() - variant = ExperimentVariantFactory.create( - experiment=experiment, - ratio=100, - description="it's a description", - name="variant name", - ) - - variant_data = { - "ratio": 100, - "description": variant.description, - "name": variant.name, - } - changed_values = { - "variants": { - "new_value": {"variants": [variant_data]}, - "old_value": None, - "display_name": "Branches", - } - } - ExperimentChangeLog.objects.create( - experiment=experiment, - changed_by=UserFactory(), - old_status=Experiment.STATUS_DRAFT, - new_status=Experiment.STATUS_DRAFT, - changed_values=changed_values, - message="", - ) - - self.assertEqual(experiment.changes.count(), 1) - - change_data = { - "variants": [ - { - "id": variant.id, - "ratio": 100, - "description": "some other description", - "name": "some other name", - "is_control": False, - } - ] - } - serializer = ExperimentDesignBaseSerializer( - instance=experiment, data=change_data, context={"request": self.request} - ) - - self.assertTrue(serializer.is_valid()) - experiment = serializer.save() - - serializer_variant_data = ExperimentVariantSerializer(variant).data - - self.assertEqual(experiment.changes.count(), 2) - changed_values = experiment.changes.latest().changed_values - - variant = ExperimentVariant.objects.get(id=variant.id) - changed_serializer_variant_data = ExperimentVariantSerializer(variant).data - - self.assertIn("variants", changed_values) - self.assertEqual( - changed_values["variants"]["old_value"], [serializer_variant_data] - ) - self.assertEqual( - changed_values["variants"]["new_value"], [changed_serializer_variant_data] - ) - - -class TestExperimentRecipeMultiPrefVariantSerialzer(TestCase): - - def test_serializer_outputs_expected_schema_non_multi_pref_format(self): - experiment = ExperimentFactory.create( - normandy_slug="normandy-slug", - pref_branch=Experiment.PREF_BRANCH_DEFAULT, - pref_type=Experiment.PREF_TYPE_JSON_STR, - pref_key="browser.pref", - firefox_min_version="55.0", - ) - variant = ExperimentVariant( - slug="control", ratio=25, experiment=experiment, value='{"some": "json"}' - ) - serializer = ExperimentRecipeMultiPrefVariantSerializer(variant) - expected_data = { - "preferences": { - "browser.pref": { - "preferenceBranchType": "default", - "preferenceType": "string", - "preferenceValue": '{"some": "json"}', - } - }, - "ratio": 25, - "slug": "control", - } - - self.assertEqual(expected_data, serializer.data) - - def test_serializer_outputs_expected_schema_for_multi_pref_format(self): - experiment = ExperimentFactory.create( - normandy_slug="normandy-slug", firefox_min_version="55.0", is_multi_pref=True - ) - variant = ExperimentVariantFactory.create( - slug="control", ratio=25, experiment=experiment - ) - - preference = VariantPreferencesFactory.create(variant=variant) - serializer = ExperimentRecipeMultiPrefVariantSerializer(variant) - - self.assertEqual(serializer.data["ratio"], 25) - self.assertEqual(serializer.data["slug"], "control") - - serialized_preferences = serializer.data["preferences"] - self.assertEqual( - serialized_preferences[preference.pref_name], - { - "preferenceBranchType": preference.pref_branch, - "preferenceType": preference.pref_type, - "preferenceValue": preference.pref_value, - }, - ) - - -class TestExperimentRecipeMultiPrefVariantSerializer(TestCase): - - def test_seriailzer_outputs_expected_schema_for_single_pref_experiment(self): - experiment = ExperimentFactory.create( - pref_type=Experiment.PREF_TYPE_JSON_STR, firefox_max_version="70.0" - ) - variant = ExperimentVariantFactory.create(experiment=experiment) - - serializer = ExperimentRecipeMultiPrefVariantSerializer(variant) - - self.assertEqual(serializer.data["ratio"], variant.ratio) - self.assertEqual(serializer.data["slug"], variant.slug) - - serialized_preferences = serializer.data["preferences"] - self.assertEqual( - serialized_preferences[experiment.pref_key], - { - "preferenceBranchType": experiment.pref_branch, - "preferenceType": PrefTypeField().to_representation(experiment.pref_type), - "preferenceValue": variant.value, - }, - ) - - def test_seriailzer_outputs_expected_schema_for_multi_pref_variant(self): - experiment = ExperimentFactory.create( - pref_type=Experiment.PREF_TYPE_JSON_STR, is_multi_pref=True - ) - variant = ExperimentVariantFactory.create(experiment=experiment) - preference = VariantPreferencesFactory.create(variant=variant) - serializer = ExperimentRecipeMultiPrefVariantSerializer(variant) - - self.assertEqual(serializer.data["ratio"], variant.ratio) - self.assertEqual(serializer.data["slug"], variant.slug) - - serialized_preferences = serializer.data["preferences"] - self.assertEqual( - serialized_preferences[preference.pref_name], - { - "preferenceBranchType": preference.pref_branch, - "preferenceType": PrefTypeField().to_representation(preference.pref_type), - "preferenceValue": preference.pref_value, - }, - ) - self.assertEqual(serializer.data["ratio"], variant.ratio) - self.assertEqual(serializer.data["slug"], variant.slug) - - -class TestExperimentRecipeVariantSerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - experiment = ExperimentFactory(pref_type=Experiment.PREF_TYPE_STR) - variant = ExperimentVariantFactory.create(experiment=experiment) - serializer = ExperimentRecipeVariantSerializer(variant) - self.assertEqual( - serializer.data, - {"ratio": variant.ratio, "slug": variant.slug, "value": variant.value}, - ) - - -class TestExperimentRecipePrefArgumentsSerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - experiment = ExperimentFactory(pref_type=Experiment.PREF_TYPE_INT) - serializer = ExperimentRecipePrefArgumentsSerializer(experiment) - self.assertEqual( - serializer.data, - { - "preferenceBranchType": experiment.pref_branch, - "slug": experiment.normandy_slug, - "experimentDocumentUrl": experiment.experiment_url, - "preferenceName": experiment.pref_key, - "preferenceType": experiment.pref_type, - "branches": [ - ExperimentRecipeVariantSerializer(variant).data - for variant in experiment.variants.all() - ], - }, - ) - - def test_serializer_outputs_expected_schema_with_json_str(self): - experiment = ExperimentFactory(pref_type=Experiment.PREF_TYPE_JSON_STR) - serializer = ExperimentRecipePrefArgumentsSerializer(experiment) - self.assertEqual( - serializer.data, - { - "preferenceBranchType": experiment.pref_branch, - "slug": experiment.normandy_slug, - "experimentDocumentUrl": experiment.experiment_url, - "preferenceName": experiment.pref_key, - "preferenceType": "string", - "branches": [ - ExperimentRecipeVariantSerializer(variant).data - for variant in experiment.variants.all() - ], - }, - ) - - -class TestExperimentRecipeAddonArgumentsSerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - experiment = ExperimentFactory.create_with_status( - Experiment.STATUS_SHIP, type=Experiment.TYPE_ADDON - ) - serializer = ExperimentRecipeAddonArgumentsSerializer(experiment) - self.assertEqual( - serializer.data, - { - "name": experiment.addon_experiment_id, - "description": experiment.public_description, - }, - ) - - -class TestExperimentRecipeAddonRolloutArgumentsSerializer(TestCase): - - def test_serializer_outputs_expected_schema(self): - experiment = ExperimentFactory.create_with_status( - Experiment.STATUS_SHIP, - type=Experiment.TYPE_ROLLOUT, - rollout_type=Experiment.TYPE_ADDON, - addon_release_url="https://www.example.com/addon.xpi", - ) - serializer = ExperimentRecipeAddonRolloutArgumentsSerializer(experiment) - self.assertEqual( - serializer.data, - { - "slug": experiment.normandy_slug, - "extensionApiId": "TODO: https://www.example.com/addon.xpi", - }, - ) - - -class TestExperimentRecipePrefRolloutArgumentsSerializer(TestCase): - - def test_serializer_outputs_expected_schema_for_int(self): - experiment = ExperimentFactory.create( - type=Experiment.TYPE_ROLLOUT, - normandy_slug="normandy-slug", - rollout_type=Experiment.TYPE_PREF, - pref_type=Experiment.PREF_TYPE_INT, - pref_key="browser.pref", - pref_value="4", - ) - serializer = ExperimentRecipePrefRolloutArgumentsSerializer(experiment) - self.assertDictEqual( - serializer.data, - { - "slug": "normandy-slug", - "preferences": [{"preferenceName": "browser.pref", "value": 4}], - }, - ) - - def test_serializer_outputs_expected_schema_for_bool(self): - experiment = ExperimentFactory.create( - type=Experiment.TYPE_ROLLOUT, - normandy_slug="normandy-slug", - rollout_type=Experiment.TYPE_PREF, - pref_type=Experiment.PREF_TYPE_BOOL, - pref_key="browser.pref", - pref_value="true", - ) - serializer = ExperimentRecipePrefRolloutArgumentsSerializer(experiment) - self.assertDictEqual( - serializer.data, - { - "slug": "normandy-slug", - "preferences": [{"preferenceName": "browser.pref", "value": True}], - }, - ) - - def test_serializer_outputs_expected_schema_for_str(self): - experiment = ExperimentFactory.create( - type=Experiment.TYPE_ROLLOUT, - normandy_slug="normandy-slug", - rollout_type=Experiment.TYPE_PREF, - pref_type=Experiment.PREF_TYPE_STR, - pref_key="browser.pref", - pref_value="a string", - ) - serializer = ExperimentRecipePrefRolloutArgumentsSerializer(experiment) - self.assertDictEqual( - serializer.data, - { - "slug": "normandy-slug", - "preferences": [{"preferenceName": "browser.pref", "value": "a string"}], - }, - ) +from django.test import TestCase -class TestExperimentRecipeSerializer(TestCase): - - def test_serializer_outputs_expected_schema_for_pref_experiment(self): - experiment = ExperimentFactory.create_with_status( - Experiment.STATUS_SHIP, - firefox_min_version="65.0", - type=Experiment.TYPE_PREF, - locales=[LocaleFactory.create()], - countries=[CountryFactory.create()], - platform=Experiment.PLATFORM_MAC, - ) - serializer = ExperimentRecipeSerializer(experiment) - self.assertEqual(serializer.data["action_name"], "preference-experiment") - self.assertEqual(serializer.data["name"], experiment.name) - expected_comment = "Platform: All Mac\n{}".format(experiment.client_matching) - self.assertEqual(serializer.data["comment"], expected_comment) - self.assertEqual( - serializer.data["filter_object"], - [ - FilterObjectBucketSampleSerializer(experiment).data, - FilterObjectChannelSerializer(experiment).data, - FilterObjectVersionsSerializer(experiment).data, - FilterObjectLocaleSerializer(experiment).data, - FilterObjectCountrySerializer(experiment).data, - ], - ) - self.assertEqual( - serializer.data["arguments"], - ExperimentRecipePrefArgumentsSerializer(experiment).data, - ) - - self.assertEqual(serializer.data["experimenter_slug"], experiment.slug) - - def test_serializer_outputs_expected_schema_for_addon_experiment(self): - experiment = ExperimentFactory.create_with_status( - Experiment.STATUS_SHIP, - firefox_min_version="63.0", - type=Experiment.TYPE_ADDON, - locales=[LocaleFactory.create()], - countries=[CountryFactory.create()], - platform=Experiment.PLATFORM_WINDOWS, - ) - serializer = ExperimentRecipeSerializer(experiment) - self.assertEqual(serializer.data["action_name"], "opt-out-study") - self.assertEqual(serializer.data["name"], experiment.name) - - expected_comment = "Platform: All Windows\n{}".format(experiment.client_matching) - self.assertEqual(serializer.data["comment"], expected_comment) - self.assertEqual( - serializer.data["filter_object"], - [ - FilterObjectBucketSampleSerializer(experiment).data, - FilterObjectChannelSerializer(experiment).data, - FilterObjectVersionsSerializer(experiment).data, - FilterObjectLocaleSerializer(experiment).data, - FilterObjectCountrySerializer(experiment).data, - ], - ) - self.assertEqual( - serializer.data["arguments"], - ExperimentRecipeAddonArgumentsSerializer(experiment).data, - ) - - self.assertEqual(serializer.data["experimenter_slug"], experiment.slug) - - def test_serializer_outputs_expect_schema_for_branched_addon(self): - - experiment = ExperimentFactory.create( - firefox_min_version="70.0", - type=Experiment.TYPE_ADDON, - locales=[LocaleFactory.create()], - countries=[CountryFactory.create()], - public_description="this is my public description!", - public_name="public name", - normandy_slug="some-random-slug", - platform=Experiment.PLATFORM_LINUX, - ) - - variant = ExperimentVariant(slug="slug-value", ratio=25, experiment=experiment) - - variant.save() - - serializer = ExperimentRecipeSerializer(experiment) - self.assertEqual(serializer.data["action_name"], "branched-addon-study") - self.assertEqual(serializer.data["name"], experiment.name) - expected_comment = "Platform: All Linux\n{}".format(experiment.client_matching) - self.assertEqual(serializer.data["comment"], expected_comment) - self.assertEqual( - serializer.data["filter_object"], - [ - FilterObjectBucketSampleSerializer(experiment).data, - FilterObjectChannelSerializer(experiment).data, - FilterObjectVersionsSerializer(experiment).data, - FilterObjectLocaleSerializer(experiment).data, - FilterObjectCountrySerializer(experiment).data, - ], - ) - self.assertEqual( - serializer.data["arguments"], - { - "slug": "some-random-slug", - "userFacingName": "public name", - "userFacingDescription": "this is my public description!", - "branches": [{"ratio": 25, "slug": "slug-value", "extensionApiId": None}], - }, - ) - - def test_serializer_outputs_expected_multipref_schema_for_singularpref(self): - - experiment = ExperimentFactory.create( - pref_type=Experiment.PREF_TYPE_INT, - pref_branch=Experiment.PREF_BRANCH_DEFAULT, - firefox_min_version="70.0", - locales=[LocaleFactory.create()], - countries=[CountryFactory.create()], - public_description="this is my public description!", - public_name="public name", - normandy_slug="some-random-slug", - platform=Experiment.PLATFORM_WINDOWS, - ) - - variant = ExperimentVariant( - slug="slug-value", ratio=25, experiment=experiment, value=5 - ) - - variant.save() - - expected_comment = "Platform: All Windows\n{}".format(experiment.client_matching) - serializer = ExperimentRecipeSerializer(experiment) - self.assertEqual(serializer.data["action_name"], "multi-preference-experiment") - self.assertEqual(serializer.data["name"], experiment.name) - self.assertEqual(serializer.data["comment"], expected_comment) - self.assertEqual( - serializer.data["filter_object"], - [ - FilterObjectBucketSampleSerializer(experiment).data, - FilterObjectChannelSerializer(experiment).data, - FilterObjectVersionsSerializer(experiment).data, - FilterObjectLocaleSerializer(experiment).data, - FilterObjectCountrySerializer(experiment).data, - ], - ) - - expected_data = { - "slug": "some-random-slug", - "experimentDocumentUrl": experiment.experiment_url, - "userFacingName": "public name", - "userFacingDescription": "this is my public description!", - "branches": [ - { - "preferences": { - "some-random-slug": { - "preferenceBranchType": "default", - "preferenceType": Experiment.PREF_TYPE_INT, - "preferenceValue": 5, - } - }, - "ratio": 25, - "slug": "slug-value", - } - ], - } - - self.assertCountEqual(serializer.data["arguments"], expected_data) - - def test_serializer_outputs_expected_schema_for_multipref(self): - - experiment = ExperimentFactory.create( - firefox_min_version="70.0", - locales=[LocaleFactory.create()], - countries=[CountryFactory.create()], - public_description="this is my public description!", - public_name="public name", - normandy_slug="some-random-slug", - platform=Experiment.PLATFORM_WINDOWS, - is_multi_pref=True, - ) - - variant = ExperimentVariant( - slug="slug-value", ratio=25, experiment=experiment, is_control=True - ) - - variant.save() - - pref = VariantPreferencesFactory.create(variant=variant) - - expected_comment = "Platform: All Windows\n{}".format(experiment.client_matching) - serializer = ExperimentRecipeSerializer(experiment) - self.assertEqual(serializer.data["action_name"], "multi-preference-experiment") - self.assertEqual(serializer.data["name"], experiment.name) - self.assertEqual(serializer.data["comment"], expected_comment) - self.assertEqual( - serializer.data["filter_object"], - [ - FilterObjectBucketSampleSerializer(experiment).data, - FilterObjectChannelSerializer(experiment).data, - FilterObjectVersionsSerializer(experiment).data, - FilterObjectLocaleSerializer(experiment).data, - FilterObjectCountrySerializer(experiment).data, - ], - ) - - expected_data = { - "slug": "some-random-slug", - "experimentDocumentUrl": experiment.experiment_url, - "userFacingName": "public name", - "userFacingDescription": "this is my public description!", - "branches": [ - { - "preferences": { - "some-random-slug": { - "preferenceBranchType": pref.pref_branch, - "preferenceType": pref.pref_type, - "preferenceValue": pref.pref_value, - } - }, - "ratio": 25, - "slug": "slug-value", - } - ], - } - - self.assertCountEqual(serializer.data["arguments"], expected_data) - - def test_serializer_outputs_expected_schema_for_addon_rollout(self): - experiment = ExperimentFactory.create( - addon_release_url="https://www.example.com/addon.xpi", - countries=[], - firefox_channel=Experiment.CHANNEL_BETA, - firefox_max_version="71", - firefox_min_version="70", - locales=[], - name="Experimenter Name", - normandy_slug="normandy-slug", - platform=Experiment.PLATFORM_WINDOWS, - population_percent=30.0, - rollout_type=Experiment.TYPE_ADDON, - slug="experimenter-slug", - type=Experiment.TYPE_ROLLOUT, - ) - serializer = ExperimentRecipeSerializer(experiment) - self.assertDictEqual( - serializer.data, - { - "action_name": "addon-rollout", - "arguments": { - "extensionApiId": "TODO: https://www.example.com/addon.xpi", - "slug": "normandy-slug", - }, - "comment": "Platform: All Windows\n" - "Geos: US, CA, GB\n" - 'Some "additional" filtering', - "experimenter_slug": "experimenter-slug", - "filter_object": [ - { - "count": 3000, - "input": ["normandy.recipe.id", "normandy.userId"], - "start": 0, - "total": 10000, - "type": "bucketSample", - }, - {"channels": ["beta"], "type": "channel"}, - {"type": "version", "versions": [70, 71]}, - ], - "name": "Experimenter Name", - }, - ) - - def test_serializer_outputs_expected_schema_for_pref_rollout(self): - experiment = ExperimentFactory.create( - countries=[], - firefox_channel=Experiment.CHANNEL_BETA, - firefox_max_version="71", - firefox_min_version="70", - locales=[], - name="Experimenter Name", - normandy_slug="normandy-slug", - platform=Experiment.PLATFORM_WINDOWS, - population_percent=30.0, - pref_key="browser.pref", - pref_value="true", - rollout_type=Experiment.TYPE_PREF, - pref_type=Experiment.PREF_TYPE_BOOL, - slug="experimenter-slug", - type=Experiment.TYPE_ROLLOUT, - ) - serializer = ExperimentRecipeSerializer(experiment) - self.assertDictEqual( - serializer.data, - { - "action_name": "preference-rollout", - "arguments": { - "preferences": [{"preferenceName": "browser.pref", "value": True}], - "slug": "normandy-slug", - }, - "comment": "Platform: All Windows\n" - "Geos: US, CA, GB\n" - 'Some "additional" filtering', - "experimenter_slug": "experimenter-slug", - "filter_object": [ - { - "count": 3000, - "input": ["normandy.recipe.id", "normandy.userId"], - "start": 0, - "total": 10000, - "type": "bucketSample", - }, - {"type": "channel", "channels": ["beta"]}, - {"type": "version", "versions": [70, 71]}, - ], - "name": "Experimenter Name", - }, - ) +from experimenter.experiments.models import ( + Experiment, + ExperimentVariant, + ExperimentChangeLog, +) +from experimenter.experiments.tests.factories import ( + ExperimentFactory, + ExperimentVariantFactory, + VariantPreferencesFactory, + UserFactory, +) - def test_serializer_excludes_locales_if_none_set(self): - experiment = ExperimentFactory.create_with_status( - Experiment.STATUS_SHIP, type=Experiment.TYPE_ADDON - ) - experiment.locales.all().delete() - serializer = ExperimentRecipeSerializer(experiment) - filter_object_types = [f["type"] for f in serializer.data["filter_object"]] - self.assertNotIn("locale", filter_object_types) +from experimenter.experiments.serializers.design import ( + ExperimentDesignVariantBaseSerializer, + ExperimentDesignAddonSerializer, + ExperimentDesignBaseSerializer, + ExperimentDesignBranchMultiPrefSerializer, + ExperimentDesignBranchVariantPreferencesSerializer, + ExperimentDesignBranchedAddonSerializer, + ExperimentDesignGenericSerializer, + ExperimentDesignMultiPrefSerializer, + ExperimentDesignPrefSerializer, + ExperimentDesignRolloutSerializer, + PrefValidationMixin, +) - def test_serializer_excludes_countries_if_none_set(self): - experiment = ExperimentFactory.create_with_status( - Experiment.STATUS_SHIP, type=Experiment.TYPE_ADDON - ) - experiment.countries.all().delete() - serializer = ExperimentRecipeSerializer(experiment) - filter_object_types = [f["type"] for f in serializer.data["filter_object"]] - self.assertNotIn("country", filter_object_types) +from experimenter.experiments.serializers.entities import ExperimentVariantSerializer +from experimenter.experiments.constants import ExperimentConstants +from experimenter.experiments.tests.mixins import MockRequestMixin class TestExperimentDesignVariantBaseSerializer(TestCase): @@ -2254,47 +1162,162 @@ def test_saves_addon_rollout(self): self.assertEqual(experiment.addon_release_url, data["addon_release_url"]) -class TestCloneSerializer(MockRequestMixin, TestCase): +class TestChangeLogSerializerMixin(MockRequestMixin, TestCase): - def test_clone_serializer_rejects_duplicate_slug(self): - experiment_1 = ExperimentFactory.create( - name="good experiment", slug="great-experiment" + def test_update_changelog_creates_no_log_when_no_change(self): + experiment = ExperimentFactory.create_with_status( + target_status=Experiment.STATUS_DRAFT, num_variants=0 + ) + variant = ExperimentVariantFactory.create( + ratio=100, + name="variant name", + experiment=experiment, + value=None, + addon_release_url=None, ) - clone_data = {"name": "great experiment"} - serializer = ExperimentCloneSerializer(instance=experiment_1, data=clone_data) - self.assertFalse(serializer.is_valid()) + data = { + "variants": [ + { + "id": variant.id, + "ratio": variant.ratio, + "description": variant.description, + "name": variant.name, + "is_control": variant.is_control, + } + ] + } - def test_clone_serializer_rejects_duplicate_name(self): - experiment = ExperimentFactory.create( - name="wonderful experiment", slug="amazing-experiment" + self.assertEqual(experiment.changes.count(), 1) + serializer = ExperimentDesignBaseSerializer( + instance=experiment, data=data, context={"request": self.request} ) - clone_data = {"name": "wonderful experiment"} - serializer = ExperimentCloneSerializer(instance=experiment, data=clone_data) - self.assertFalse(serializer.is_valid()) + self.assertTrue(serializer.is_valid()) + experiment = serializer.save() - def test_clone_serializer_rejects_invalid_name(self): - experiment = ExperimentFactory.create( - name="great experiment", slug="great-experiment" - ) + self.assertEqual(experiment.changes.count(), 1) - clone_data = {"name": "@@@@@@@@"} - serializer = ExperimentCloneSerializer(instance=experiment, data=clone_data) + def test_update_change_log_creates_log_with_correct_change(self): - self.assertFalse(serializer.is_valid()) + experiment = ExperimentFactory.create() + variant = ExperimentVariantFactory.create( + experiment=experiment, + ratio=100, + description="it's a description", + name="variant name", + ) - def test_clone_serializer_accepts_unique_name(self): - experiment = ExperimentFactory.create( - name="great experiment", slug="great-experiment" + variant_data = { + "ratio": 100, + "description": variant.description, + "name": variant.name, + } + changed_values = { + "variants": { + "new_value": {"variants": [variant_data]}, + "old_value": None, + "display_name": "Branches", + } + } + ExperimentChangeLog.objects.create( + experiment=experiment, + changed_by=UserFactory(), + old_status=Experiment.STATUS_DRAFT, + new_status=Experiment.STATUS_DRAFT, + changed_values=changed_values, + message="", ) - clone_data = {"name": "best experiment"} - serializer = ExperimentCloneSerializer( - instance=experiment, data=clone_data, context={"request": self.request} + + self.assertEqual(experiment.changes.count(), 1) + + change_data = { + "variants": [ + { + "id": variant.id, + "ratio": 100, + "description": "some other description", + "name": "some other name", + "is_control": False, + } + ] + } + serializer = ExperimentDesignBaseSerializer( + instance=experiment, data=change_data, context={"request": self.request} ) + self.assertTrue(serializer.is_valid()) + experiment = serializer.save() - serializer.save() + serializer_variant_data = ExperimentVariantSerializer(variant).data + + self.assertEqual(experiment.changes.count(), 2) + changed_values = experiment.changes.latest().changed_values + + variant = ExperimentVariant.objects.get(id=variant.id) + changed_serializer_variant_data = ExperimentVariantSerializer(variant).data - self.assertEqual(serializer.data["name"], "best experiment") - self.assertEqual(serializer.data["clone_url"], "/experiments/best-experiment/") + self.assertIn("variants", changed_values) + self.assertEqual( + changed_values["variants"]["old_value"], [serializer_variant_data] + ) + self.assertEqual( + changed_values["variants"]["new_value"], [changed_serializer_variant_data] + ) + + +class TestPrefValidationMixin(TestCase): + + def test_matching_json_string_type_value(self): + pref_type = "json string" + pref_value = "{}" + key_value = "key_value" + validator = PrefValidationMixin() + value = validator.validate_pref(pref_type, pref_value, key_value) + + self.assertEqual(value, {}) + + def test_non_matching_json_string_type_value(self): + pref_type = "json string" + pref_value = "not a json string" + key_value = "key_value" + validator = PrefValidationMixin() + value = validator.validate_pref(pref_type, pref_value, key_value) + + self.assertEqual(value, {key_value: "The pref value must be valid JSON."}) + + def test_matching_integer_type_value(self): + pref_type = "integer" + pref_value = "8" + key_value = "key_value" + validator = PrefValidationMixin() + value = validator.validate_pref(pref_type, pref_value, key_value) + + self.assertEqual(value, {}) + + def test_non_matching_integer_type_value(self): + pref_type = "integer" + pref_value = "not a integer" + key_value = "key_value" + validator = PrefValidationMixin() + value = validator.validate_pref(pref_type, pref_value, key_value) + + self.assertEqual(value, {key_value: "The pref value must be an integer."}) + + def test_matching_boolean_type_value(self): + pref_type = "boolean" + pref_value = "true" + key_value = "key_value" + validator = PrefValidationMixin() + value = validator.validate_pref(pref_type, pref_value, key_value) + + self.assertEqual(value, {}) + + def test_non_matching_boolean_type_value(self): + pref_type = "boolean" + pref_value = "not a boolean" + key_value = "key_value" + validator = PrefValidationMixin() + value = validator.validate_pref(pref_type, pref_value, key_value) + + self.assertEqual(value, {key_value: "The pref value must be a boolean."}) diff --git a/app/experimenter/experiments/tests/serializers/test_entities.py b/app/experimenter/experiments/tests/serializers/test_entities.py new file mode 100644 index 0000000000..edcc90f8a3 --- /dev/null +++ b/app/experimenter/experiments/tests/serializers/test_entities.py @@ -0,0 +1,265 @@ +import datetime + +from django.test import TestCase + +from experimenter.experiments.models import Experiment +from experimenter.experiments.tests.factories import ( + LocaleFactory, + CountryFactory, + ExperimentFactory, + ExperimentVariantFactory, + ExperimentChangeLogFactory, +) + +from experimenter.experiments.serializers.entities import ( + ChangeLogSerializer, + ExperimentChangeLogSerializer, + JSTimestampField, + PrefTypeField, + ExperimentVariantSerializer, + ExperimentSerializer, +) + +from experimenter.experiments.serializers.recipe import ExperimentRecipeVariantSerializer + + +class TestJSTimestampField(TestCase): + + def test_field_serializes_to_js_time_format(self): + field = JSTimestampField() + example_datetime = datetime.datetime(2000, 1, 1, 1, 1, 1, 1) + self.assertEqual(field.to_representation(example_datetime), 946688461000.0) + + def test_field_returns_none_if_no_datetime_passed_in(self): + field = JSTimestampField() + self.assertEqual(field.to_representation(None), None) + + +class TestPrefTypeField(TestCase): + + def test_non_json_field(self): + field = PrefTypeField() + self.assertEqual( + field.to_representation(Experiment.PREF_TYPE_INT), Experiment.PREF_TYPE_INT + ) + + def test_json_field(self): + field = PrefTypeField() + self.assertEqual( + field.to_representation(Experiment.PREF_TYPE_JSON_STR), + Experiment.PREF_TYPE_STR, + ) + + +class TestExperimentVariantSerializer(TestCase): + + def test_serializer_outputs_expected_bool(self): + experiment = ExperimentFactory(pref_type=Experiment.PREF_TYPE_BOOL) + variant = ExperimentVariantFactory.create(experiment=experiment, value="true") + serializer = ExperimentRecipeVariantSerializer(variant) + + self.assertEqual(type(serializer.data["value"]), bool) + self.assertEqual( + serializer.data, {"ratio": variant.ratio, "slug": variant.slug, "value": True} + ) + + def test_serializer_outputs_expected_int_val(self): + experiment = ExperimentFactory(pref_type=Experiment.PREF_TYPE_INT) + variant = ExperimentVariantFactory.create(experiment=experiment, value="28") + serializer = ExperimentRecipeVariantSerializer(variant) + + self.assertEqual(type(serializer.data["value"]), int) + self.assertEqual( + serializer.data, {"ratio": variant.ratio, "slug": variant.slug, "value": 28} + ) + + def test_serializer_outputs_expected_str_val(self): + experiment = ExperimentFactory(pref_type=Experiment.PREF_TYPE_STR) + variant = ExperimentVariantFactory.create(experiment=experiment) + serializer = ExperimentRecipeVariantSerializer(variant) + + self.assertEqual(type(serializer.data["value"]), str) + self.assertEqual( + serializer.data, + {"ratio": variant.ratio, "slug": variant.slug, "value": variant.value}, + ) + + +class TestExperimentSerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + experiment = ExperimentFactory.create_with_status( + Experiment.STATUS_COMPLETE, countries=[], locales=[] + ) + + # ensure expected_data has "string" if pref_type is json string + pref_type = PrefTypeField().to_representation(experiment.pref_type) + serializer = ExperimentSerializer(experiment) + expected_data = { + "client_matching": experiment.client_matching, + "platform": experiment.platform, + "end_date": JSTimestampField().to_representation(experiment.end_date), + "experiment_url": experiment.experiment_url, + "firefox_channel": experiment.firefox_channel, + "firefox_min_version": experiment.firefox_min_version, + "firefox_max_version": experiment.firefox_max_version, + "name": experiment.name, + "population": experiment.population, + "population_percent": "{0:.4f}".format(experiment.population_percent), + "pref_branch": experiment.pref_branch, + "pref_key": experiment.pref_key, + "pref_type": pref_type, + "addon_experiment_id": experiment.addon_experiment_id, + "addon_release_url": experiment.addon_release_url, + "proposed_start_date": JSTimestampField().to_representation( + experiment.proposed_start_date + ), + "proposed_enrollment": experiment.proposed_enrollment, + "proposed_duration": experiment.proposed_duration, + "public_name": experiment.public_name, + "public_description": experiment.public_description, + "slug": experiment.slug, + "start_date": JSTimestampField().to_representation(experiment.start_date), + "status": Experiment.STATUS_COMPLETE, + "type": experiment.type, + "variants": [ + ExperimentVariantSerializer(variant).data + for variant in experiment.variants.all() + ], + "locales": [], + "countries": [], + "changes": [ + ExperimentChangeLogSerializer(change).data + for change in experiment.changes.all() + ], + } + + self.assertEqual(set(serializer.data.keys()), set(expected_data.keys())) + self.assertEqual(serializer.data, expected_data) + + def test_serializer_locales(self): + locale = LocaleFactory() + experiment = ExperimentFactory.create(locales=[locale]) + serializer = ExperimentSerializer(experiment) + self.assertEqual( + serializer.data["locales"], [{"code": locale.code, "name": locale.name}] + ) + + def test_serializer_countries(self): + country = CountryFactory() + experiment = ExperimentFactory.create(countries=[country]) + serializer = ExperimentSerializer(experiment) + self.assertEqual( + serializer.data["countries"], [{"code": country.code, "name": country.name}] + ) + + +class TestExperimentChangeLogSerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + change_log = ExperimentChangeLogFactory.create( + changed_on="2019-08-02T18:19:26.267960Z" + ) + serializer = ExperimentChangeLogSerializer(change_log) + self.assertEqual(serializer.data["changed_on"], change_log.changed_on) + + +class TestChangeLogSerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + country1 = CountryFactory(code="CA", name="Canada") + locale1 = LocaleFactory(code="da", name="Danish") + experiment = ExperimentFactory.create(locales=[locale1], countries=[country1]) + + related_exp = ExperimentFactory.create() + experiment.related_to.add(related_exp) + + serializer = ChangeLogSerializer(experiment) + + risk_tech_description = experiment.risk_technical_description + # ensure expected_data has "string" if pref_type is json string + pref_type = PrefTypeField().to_representation(experiment.pref_type) + expected_data = { + "type": experiment.type, + "owner": experiment.owner.id, + "name": experiment.name, + "short_description": experiment.short_description, + "related_work": experiment.related_work, + "related_to": [related_exp.id], + "proposed_start_date": str(experiment.proposed_start_date), + "proposed_duration": experiment.proposed_duration, + "proposed_enrollment": experiment.proposed_enrollment, + "design": experiment.design, + "addon_experiment_id": experiment.addon_experiment_id, + "addon_release_url": experiment.addon_release_url, + "pref_key": experiment.pref_key, + "pref_type": pref_type, + "pref_branch": experiment.pref_branch, + "public_name": experiment.public_name, + "public_description": experiment.public_description, + "population_percent": "{0:.4f}".format(experiment.population_percent), + "firefox_min_version": experiment.firefox_min_version, + "firefox_max_version": experiment.firefox_max_version, + "firefox_channel": experiment.firefox_channel, + "client_matching": experiment.client_matching, + "locales": [{"code": "da", "name": "Danish"}], + "countries": [{"code": "CA", "name": "Canada"}], + "platform": experiment.platform, + "objectives": experiment.objectives, + "analysis": experiment.analysis, + "analysis_owner": experiment.analysis_owner.id, + "survey_required": experiment.survey_required, + "survey_urls": experiment.survey_urls, + "survey_instructions": experiment.survey_instructions, + "engineering_owner": experiment.engineering_owner, + "bugzilla_id": experiment.bugzilla_id, + "normandy_slug": experiment.normandy_slug, + "normandy_id": experiment.normandy_id, + "data_science_bugzilla_url": experiment.data_science_bugzilla_url, + "feature_bugzilla_url": experiment.feature_bugzilla_url, + "risk_internal_only": experiment.risk_internal_only, + "risk_partner_related": experiment.risk_partner_related, + "risk_brand": experiment.risk_brand, + "risk_fast_shipped": experiment.risk_fast_shipped, + "risk_confidential": experiment.risk_confidential, + "risk_release_population": experiment.risk_release_population, + "risk_revenue": experiment.risk_revenue, + "risk_data_category": experiment.risk_data_category, + "risk_external_team_impact": experiment.risk_external_team_impact, + "risk_telemetry_data": experiment.risk_telemetry_data, + "risk_ux": experiment.risk_ux, + "risk_security": experiment.risk_security, + "risk_revision": experiment.risk_revision, + "risk_technical": experiment.risk_technical, + "risk_technical_description": risk_tech_description, + "risks": experiment.risks, + "testing": experiment.testing, + "test_builds": experiment.test_builds, + "qa_status": experiment.qa_status, + "review_science": experiment.review_science, + "review_engineering": experiment.review_engineering, + "review_qa_requested": experiment.review_qa_requested, + "review_intent_to_ship": experiment.review_intent_to_ship, + "review_bugzilla": experiment.review_bugzilla, + "review_qa": experiment.review_qa, + "review_relman": experiment.review_relman, + "review_advisory": experiment.review_advisory, + "review_legal": experiment.review_legal, + "review_ux": experiment.review_ux, + "review_security": experiment.review_security, + "review_vp": experiment.review_vp, + "review_data_steward": experiment.review_data_steward, + "review_comms": experiment.review_comms, + "review_impacted_teams": experiment.review_impacted_teams, + "variants": [ + ExperimentVariantSerializer(variant).data + for variant in experiment.variants.all() + ], + "results_url": experiment.results_url, + "results_initial": experiment.results_initial, + "results_lessons_learned": experiment.results_lessons_learned, + } + + self.assertEqual(set(serializer.data.keys()), set(expected_data.keys())) + + self.assertEqual(serializer.data, expected_data) diff --git a/app/experimenter/experiments/tests/serializers/test_geo.py b/app/experimenter/experiments/tests/serializers/test_geo.py new file mode 100644 index 0000000000..207266a5ac --- /dev/null +++ b/app/experimenter/experiments/tests/serializers/test_geo.py @@ -0,0 +1,21 @@ + + +from django.test import TestCase +from experimenter.experiments.tests.factories import LocaleFactory, CountryFactory +from experimenter.experiments.serializers.geo import CountrySerializer, LocaleSerializer + + +class TestLocaleSerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + locale = LocaleFactory.create() + serializer = LocaleSerializer(locale) + self.assertEqual(serializer.data, {"code": locale.code, "name": locale.name}) + + +class TestCountrySerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + country = CountryFactory.create() + serializer = CountrySerializer(country) + self.assertEqual(serializer.data, {"code": country.code, "name": country.name}) diff --git a/app/experimenter/experiments/tests/serializers/test_recipe.py b/app/experimenter/experiments/tests/serializers/test_recipe.py new file mode 100644 index 0000000000..53843d9711 --- /dev/null +++ b/app/experimenter/experiments/tests/serializers/test_recipe.py @@ -0,0 +1,678 @@ + +from decimal import Decimal +from django.test import TestCase + +from experimenter.experiments.models import Experiment, ExperimentVariant +from experimenter.experiments.tests.factories import ( + LocaleFactory, + CountryFactory, + ExperimentFactory, + ExperimentVariantFactory, + VariantPreferencesFactory, +) + + +from experimenter.experiments.serializers.recipe import ( + ExperimentRecipeAddonArgumentsSerializer, + ExperimentRecipeAddonVariantSerializer, + ExperimentRecipeAddonRolloutArgumentsSerializer, + ExperimentRecipeMultiPrefVariantSerializer, + ExperimentRecipePrefArgumentsSerializer, + ExperimentRecipePrefRolloutArgumentsSerializer, + ExperimentRecipeSerializer, + ExperimentRecipeVariantSerializer, + FilterObjectBucketSampleSerializer, + FilterObjectChannelSerializer, + FilterObjectCountrySerializer, + FilterObjectLocaleSerializer, + FilterObjectVersionsSerializer, +) + +from experimenter.experiments.serializers.entities import PrefTypeField + + +class TestFilterObjectBucketSampleSerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + experiment = ExperimentFactory.create(population_percent=Decimal("12.34")) + serializer = FilterObjectBucketSampleSerializer(experiment) + self.assertEqual( + serializer.data, + { + "type": "bucketSample", + "input": ["normandy.recipe.id", "normandy.userId"], + "start": 0, + "count": 1234, + "total": 10000, + }, + ) + + +class TestFilterObjectChannelSerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + experiment = ExperimentFactory.create(firefox_channel=Experiment.CHANNEL_NIGHTLY) + serializer = FilterObjectChannelSerializer(experiment) + self.assertEqual(serializer.data, {"type": "channel", "channels": ["nightly"]}) + + +class TestFilterObjectVersionsSerializer(TestCase): + + def test_serializer_outputs_version_string_with_only_min(self): + experiment = ExperimentFactory.create( + firefox_min_version="68.0", firefox_max_version="" + ) + serializer = FilterObjectVersionsSerializer(experiment) + self.assertEqual(serializer.data, {"type": "version", "versions": [68]}) + + def test_serializer_outputs_version_string_with_range(self): + experiment = ExperimentFactory.create( + firefox_min_version="68.0", firefox_max_version="70.0" + ) + serializer = FilterObjectVersionsSerializer(experiment) + self.assertEqual(serializer.data, {"type": "version", "versions": [68, 69, 70]}) + + +class TestFilterObjectLocaleSerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + locale1 = LocaleFactory.create(code="ab") + locale2 = LocaleFactory.create(code="cd") + experiment = ExperimentFactory.create(locales=[locale1, locale2]) + serializer = FilterObjectLocaleSerializer(experiment) + self.assertEqual(serializer.data["type"], "locale") + self.assertEqual(set(serializer.data["locales"]), set(["ab", "cd"])) + + +class TestFilterObjectCountrySerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + country1 = CountryFactory.create(code="ab") + country2 = CountryFactory.create(code="cd") + experiment = ExperimentFactory.create(countries=[country1, country2]) + serializer = FilterObjectCountrySerializer(experiment) + self.assertEqual(serializer.data["type"], "country") + self.assertEqual(set(serializer.data["countries"]), set(["ab", "cd"])) + + +class TestExperimentRecipeAddonVariantSerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + variant = ExperimentVariant(slug="slug-value", ratio=25) + serializer = ExperimentRecipeAddonVariantSerializer(variant) + self.assertEqual( + {"ratio": 25, "slug": "slug-value", "extensionApiId": None}, serializer.data + ) + + +class TestExperimentRecipeAddonArgumentsSerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + experiment = ExperimentFactory.create_with_status( + Experiment.STATUS_SHIP, type=Experiment.TYPE_ADDON + ) + serializer = ExperimentRecipeAddonArgumentsSerializer(experiment) + self.assertEqual( + serializer.data, + { + "name": experiment.addon_experiment_id, + "description": experiment.public_description, + }, + ) + + +class TestExperimentRecipeAddonRolloutArgumentsSerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + experiment = ExperimentFactory.create_with_status( + Experiment.STATUS_SHIP, + type=Experiment.TYPE_ROLLOUT, + rollout_type=Experiment.TYPE_ADDON, + addon_release_url="https://www.example.com/addon.xpi", + ) + serializer = ExperimentRecipeAddonRolloutArgumentsSerializer(experiment) + self.assertEqual( + serializer.data, + { + "slug": experiment.normandy_slug, + "extensionApiId": "TODO: https://www.example.com/addon.xpi", + }, + ) + + +class TestExperimentRecipePrefRolloutArgumentsSerializer(TestCase): + + def test_serializer_outputs_expected_schema_for_int(self): + experiment = ExperimentFactory.create( + type=Experiment.TYPE_ROLLOUT, + normandy_slug="normandy-slug", + rollout_type=Experiment.TYPE_PREF, + pref_type=Experiment.PREF_TYPE_INT, + pref_key="browser.pref", + pref_value="4", + ) + serializer = ExperimentRecipePrefRolloutArgumentsSerializer(experiment) + self.assertDictEqual( + serializer.data, + { + "slug": "normandy-slug", + "preferences": [{"preferenceName": "browser.pref", "value": 4}], + }, + ) + + def test_serializer_outputs_expected_schema_for_bool(self): + experiment = ExperimentFactory.create( + type=Experiment.TYPE_ROLLOUT, + normandy_slug="normandy-slug", + rollout_type=Experiment.TYPE_PREF, + pref_type=Experiment.PREF_TYPE_BOOL, + pref_key="browser.pref", + pref_value="true", + ) + serializer = ExperimentRecipePrefRolloutArgumentsSerializer(experiment) + self.assertDictEqual( + serializer.data, + { + "slug": "normandy-slug", + "preferences": [{"preferenceName": "browser.pref", "value": True}], + }, + ) + + def test_serializer_outputs_expected_schema_for_str(self): + experiment = ExperimentFactory.create( + type=Experiment.TYPE_ROLLOUT, + normandy_slug="normandy-slug", + rollout_type=Experiment.TYPE_PREF, + pref_type=Experiment.PREF_TYPE_STR, + pref_key="browser.pref", + pref_value="a string", + ) + serializer = ExperimentRecipePrefRolloutArgumentsSerializer(experiment) + self.assertDictEqual( + serializer.data, + { + "slug": "normandy-slug", + "preferences": [{"preferenceName": "browser.pref", "value": "a string"}], + }, + ) + + +class TestExperimentRecipeSerializer(TestCase): + + def test_serializer_outputs_expected_schema_for_pref_experiment(self): + experiment = ExperimentFactory.create_with_status( + Experiment.STATUS_SHIP, + firefox_min_version="65.0", + type=Experiment.TYPE_PREF, + locales=[LocaleFactory.create()], + countries=[CountryFactory.create()], + platform=Experiment.PLATFORM_MAC, + ) + serializer = ExperimentRecipeSerializer(experiment) + self.assertEqual(serializer.data["action_name"], "preference-experiment") + self.assertEqual(serializer.data["name"], experiment.name) + expected_comment = "Platform: All Mac\n{}".format(experiment.client_matching) + self.assertEqual(serializer.data["comment"], expected_comment) + self.assertEqual( + serializer.data["filter_object"], + [ + FilterObjectBucketSampleSerializer(experiment).data, + FilterObjectChannelSerializer(experiment).data, + FilterObjectVersionsSerializer(experiment).data, + FilterObjectLocaleSerializer(experiment).data, + FilterObjectCountrySerializer(experiment).data, + ], + ) + self.assertEqual( + serializer.data["arguments"], + ExperimentRecipePrefArgumentsSerializer(experiment).data, + ) + + self.assertEqual(serializer.data["experimenter_slug"], experiment.slug) + + def test_serializer_outputs_expected_schema_for_addon_experiment(self): + experiment = ExperimentFactory.create_with_status( + Experiment.STATUS_SHIP, + firefox_min_version="63.0", + type=Experiment.TYPE_ADDON, + locales=[LocaleFactory.create()], + countries=[CountryFactory.create()], + platform=Experiment.PLATFORM_WINDOWS, + ) + serializer = ExperimentRecipeSerializer(experiment) + self.assertEqual(serializer.data["action_name"], "opt-out-study") + self.assertEqual(serializer.data["name"], experiment.name) + + expected_comment = "Platform: All Windows\n{}".format(experiment.client_matching) + self.assertEqual(serializer.data["comment"], expected_comment) + self.assertEqual( + serializer.data["filter_object"], + [ + FilterObjectBucketSampleSerializer(experiment).data, + FilterObjectChannelSerializer(experiment).data, + FilterObjectVersionsSerializer(experiment).data, + FilterObjectLocaleSerializer(experiment).data, + FilterObjectCountrySerializer(experiment).data, + ], + ) + self.assertEqual( + serializer.data["arguments"], + ExperimentRecipeAddonArgumentsSerializer(experiment).data, + ) + + self.assertEqual(serializer.data["experimenter_slug"], experiment.slug) + + def test_serializer_outputs_expect_schema_for_branched_addon(self): + + experiment = ExperimentFactory.create( + firefox_min_version="70.0", + type=Experiment.TYPE_ADDON, + locales=[LocaleFactory.create()], + countries=[CountryFactory.create()], + public_description="this is my public description!", + public_name="public name", + normandy_slug="some-random-slug", + platform=Experiment.PLATFORM_LINUX, + ) + + variant = ExperimentVariant(slug="slug-value", ratio=25, experiment=experiment) + + variant.save() + + serializer = ExperimentRecipeSerializer(experiment) + self.assertEqual(serializer.data["action_name"], "branched-addon-study") + self.assertEqual(serializer.data["name"], experiment.name) + expected_comment = "Platform: All Linux\n{}".format(experiment.client_matching) + self.assertEqual(serializer.data["comment"], expected_comment) + self.assertEqual( + serializer.data["filter_object"], + [ + FilterObjectBucketSampleSerializer(experiment).data, + FilterObjectChannelSerializer(experiment).data, + FilterObjectVersionsSerializer(experiment).data, + FilterObjectLocaleSerializer(experiment).data, + FilterObjectCountrySerializer(experiment).data, + ], + ) + self.assertEqual( + serializer.data["arguments"], + { + "slug": "some-random-slug", + "userFacingName": "public name", + "userFacingDescription": "this is my public description!", + "branches": [{"ratio": 25, "slug": "slug-value", "extensionApiId": None}], + }, + ) + + def test_serializer_outputs_expected_multipref_schema_for_singularpref(self): + + experiment = ExperimentFactory.create( + pref_type=Experiment.PREF_TYPE_INT, + pref_branch=Experiment.PREF_BRANCH_DEFAULT, + firefox_min_version="70.0", + locales=[LocaleFactory.create()], + countries=[CountryFactory.create()], + public_description="this is my public description!", + public_name="public name", + normandy_slug="some-random-slug", + platform=Experiment.PLATFORM_WINDOWS, + ) + + variant = ExperimentVariant( + slug="slug-value", ratio=25, experiment=experiment, value=5 + ) + + variant.save() + + expected_comment = "Platform: All Windows\n{}".format(experiment.client_matching) + serializer = ExperimentRecipeSerializer(experiment) + self.assertEqual(serializer.data["action_name"], "multi-preference-experiment") + self.assertEqual(serializer.data["name"], experiment.name) + self.assertEqual(serializer.data["comment"], expected_comment) + self.assertEqual( + serializer.data["filter_object"], + [ + FilterObjectBucketSampleSerializer(experiment).data, + FilterObjectChannelSerializer(experiment).data, + FilterObjectVersionsSerializer(experiment).data, + FilterObjectLocaleSerializer(experiment).data, + FilterObjectCountrySerializer(experiment).data, + ], + ) + + expected_data = { + "slug": "some-random-slug", + "experimentDocumentUrl": experiment.experiment_url, + "userFacingName": "public name", + "userFacingDescription": "this is my public description!", + "branches": [ + { + "preferences": { + "some-random-slug": { + "preferenceBranchType": "default", + "preferenceType": Experiment.PREF_TYPE_INT, + "preferenceValue": 5, + } + }, + "ratio": 25, + "slug": "slug-value", + } + ], + } + + self.assertCountEqual(serializer.data["arguments"], expected_data) + + def test_serializer_outputs_expected_schema_for_multipref(self): + + experiment = ExperimentFactory.create( + firefox_min_version="70.0", + locales=[LocaleFactory.create()], + countries=[CountryFactory.create()], + public_description="this is my public description!", + public_name="public name", + normandy_slug="some-random-slug", + platform=Experiment.PLATFORM_WINDOWS, + is_multi_pref=True, + ) + + variant = ExperimentVariant( + slug="slug-value", ratio=25, experiment=experiment, is_control=True + ) + + variant.save() + + pref = VariantPreferencesFactory.create(variant=variant) + + expected_comment = "Platform: All Windows\n{}".format(experiment.client_matching) + serializer = ExperimentRecipeSerializer(experiment) + self.assertEqual(serializer.data["action_name"], "multi-preference-experiment") + self.assertEqual(serializer.data["name"], experiment.name) + self.assertEqual(serializer.data["comment"], expected_comment) + self.assertEqual( + serializer.data["filter_object"], + [ + FilterObjectBucketSampleSerializer(experiment).data, + FilterObjectChannelSerializer(experiment).data, + FilterObjectVersionsSerializer(experiment).data, + FilterObjectLocaleSerializer(experiment).data, + FilterObjectCountrySerializer(experiment).data, + ], + ) + + expected_data = { + "slug": "some-random-slug", + "experimentDocumentUrl": experiment.experiment_url, + "userFacingName": "public name", + "userFacingDescription": "this is my public description!", + "branches": [ + { + "preferences": { + "some-random-slug": { + "preferenceBranchType": pref.pref_branch, + "preferenceType": pref.pref_type, + "preferenceValue": pref.pref_value, + } + }, + "ratio": 25, + "slug": "slug-value", + } + ], + } + + self.assertCountEqual(serializer.data["arguments"], expected_data) + + def test_serializer_outputs_expected_schema_for_addon_rollout(self): + experiment = ExperimentFactory.create( + addon_release_url="https://www.example.com/addon.xpi", + countries=[], + firefox_channel=Experiment.CHANNEL_BETA, + firefox_max_version="71", + firefox_min_version="70", + locales=[], + name="Experimenter Name", + normandy_slug="normandy-slug", + platform=Experiment.PLATFORM_WINDOWS, + population_percent=30.0, + rollout_type=Experiment.TYPE_ADDON, + slug="experimenter-slug", + type=Experiment.TYPE_ROLLOUT, + ) + serializer = ExperimentRecipeSerializer(experiment) + self.assertDictEqual( + serializer.data, + { + "action_name": "addon-rollout", + "arguments": { + "extensionApiId": "TODO: https://www.example.com/addon.xpi", + "slug": "normandy-slug", + }, + "comment": "Platform: All Windows\n" + "Geos: US, CA, GB\n" + 'Some "additional" filtering', + "experimenter_slug": "experimenter-slug", + "filter_object": [ + { + "count": 3000, + "input": ["normandy.recipe.id", "normandy.userId"], + "start": 0, + "total": 10000, + "type": "bucketSample", + }, + {"channels": ["beta"], "type": "channel"}, + {"type": "version", "versions": [70, 71]}, + ], + "name": "Experimenter Name", + }, + ) + + def test_serializer_outputs_expected_schema_for_pref_rollout(self): + experiment = ExperimentFactory.create( + countries=[], + firefox_channel=Experiment.CHANNEL_BETA, + firefox_max_version="71", + firefox_min_version="70", + locales=[], + name="Experimenter Name", + normandy_slug="normandy-slug", + platform=Experiment.PLATFORM_WINDOWS, + population_percent=30.0, + pref_key="browser.pref", + pref_value="true", + rollout_type=Experiment.TYPE_PREF, + pref_type=Experiment.PREF_TYPE_BOOL, + slug="experimenter-slug", + type=Experiment.TYPE_ROLLOUT, + ) + serializer = ExperimentRecipeSerializer(experiment) + self.assertDictEqual( + serializer.data, + { + "action_name": "preference-rollout", + "arguments": { + "preferences": [{"preferenceName": "browser.pref", "value": True}], + "slug": "normandy-slug", + }, + "comment": "Platform: All Windows\n" + "Geos: US, CA, GB\n" + 'Some "additional" filtering', + "experimenter_slug": "experimenter-slug", + "filter_object": [ + { + "count": 3000, + "input": ["normandy.recipe.id", "normandy.userId"], + "start": 0, + "total": 10000, + "type": "bucketSample", + }, + {"type": "channel", "channels": ["beta"]}, + {"type": "version", "versions": [70, 71]}, + ], + "name": "Experimenter Name", + }, + ) + + def test_serializer_excludes_locales_if_none_set(self): + experiment = ExperimentFactory.create_with_status( + Experiment.STATUS_SHIP, type=Experiment.TYPE_ADDON + ) + experiment.locales.all().delete() + serializer = ExperimentRecipeSerializer(experiment) + filter_object_types = [f["type"] for f in serializer.data["filter_object"]] + self.assertNotIn("locale", filter_object_types) + + def test_serializer_excludes_countries_if_none_set(self): + experiment = ExperimentFactory.create_with_status( + Experiment.STATUS_SHIP, type=Experiment.TYPE_ADDON + ) + experiment.countries.all().delete() + serializer = ExperimentRecipeSerializer(experiment) + filter_object_types = [f["type"] for f in serializer.data["filter_object"]] + self.assertNotIn("country", filter_object_types) + + +class TestExperimentRecipeMultiPrefVariantSerializer(TestCase): + + def test_serializer_outputs_expected_schema_non_multi_pref_format(self): + experiment = ExperimentFactory.create( + normandy_slug="normandy-slug", + pref_branch=Experiment.PREF_BRANCH_DEFAULT, + pref_type=Experiment.PREF_TYPE_JSON_STR, + pref_key="browser.pref", + firefox_min_version="55.0", + ) + variant = ExperimentVariant( + slug="control", ratio=25, experiment=experiment, value='{"some": "json"}' + ) + serializer = ExperimentRecipeMultiPrefVariantSerializer(variant) + expected_data = { + "preferences": { + "browser.pref": { + "preferenceBranchType": "default", + "preferenceType": "string", + "preferenceValue": '{"some": "json"}', + } + }, + "ratio": 25, + "slug": "control", + } + + self.assertEqual(expected_data, serializer.data) + + def test_serializer_outputs_expected_schema_for_multi_pref_format(self): + experiment = ExperimentFactory.create( + normandy_slug="normandy-slug", firefox_min_version="55.0", is_multi_pref=True + ) + variant = ExperimentVariantFactory.create( + slug="control", ratio=25, experiment=experiment + ) + + preference = VariantPreferencesFactory.create(variant=variant) + serializer = ExperimentRecipeMultiPrefVariantSerializer(variant) + + self.assertEqual(serializer.data["ratio"], 25) + self.assertEqual(serializer.data["slug"], "control") + + serialized_preferences = serializer.data["preferences"] + self.assertEqual( + serialized_preferences[preference.pref_name], + { + "preferenceBranchType": preference.pref_branch, + "preferenceType": preference.pref_type, + "preferenceValue": preference.pref_value, + }, + ) + + def test_seriailzer_outputs_expected_schema_for_single_pref_experiment(self): + experiment = ExperimentFactory.create( + pref_type=Experiment.PREF_TYPE_JSON_STR, firefox_max_version="70.0" + ) + variant = ExperimentVariantFactory.create(experiment=experiment) + + serializer = ExperimentRecipeMultiPrefVariantSerializer(variant) + + self.assertEqual(serializer.data["ratio"], variant.ratio) + self.assertEqual(serializer.data["slug"], variant.slug) + + serialized_preferences = serializer.data["preferences"] + self.assertEqual( + serialized_preferences[experiment.pref_key], + { + "preferenceBranchType": experiment.pref_branch, + "preferenceType": PrefTypeField().to_representation(experiment.pref_type), + "preferenceValue": variant.value, + }, + ) + + def test_seriailzer_outputs_expected_schema_for_multi_pref_variant(self): + experiment = ExperimentFactory.create( + pref_type=Experiment.PREF_TYPE_JSON_STR, is_multi_pref=True + ) + variant = ExperimentVariantFactory.create(experiment=experiment) + preference = VariantPreferencesFactory.create(variant=variant) + serializer = ExperimentRecipeMultiPrefVariantSerializer(variant) + + self.assertEqual(serializer.data["ratio"], variant.ratio) + self.assertEqual(serializer.data["slug"], variant.slug) + + serialized_preferences = serializer.data["preferences"] + self.assertEqual( + serialized_preferences[preference.pref_name], + { + "preferenceBranchType": preference.pref_branch, + "preferenceType": PrefTypeField().to_representation(preference.pref_type), + "preferenceValue": preference.pref_value, + }, + ) + self.assertEqual(serializer.data["ratio"], variant.ratio) + self.assertEqual(serializer.data["slug"], variant.slug) + + +class TestExperimentRecipeVariantSerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + experiment = ExperimentFactory(pref_type=Experiment.PREF_TYPE_STR) + variant = ExperimentVariantFactory.create(experiment=experiment) + serializer = ExperimentRecipeVariantSerializer(variant) + self.assertEqual( + serializer.data, + {"ratio": variant.ratio, "slug": variant.slug, "value": variant.value}, + ) + + +class TestExperimentRecipePrefArgumentsSerializer(TestCase): + + def test_serializer_outputs_expected_schema(self): + experiment = ExperimentFactory(pref_type=Experiment.PREF_TYPE_INT) + serializer = ExperimentRecipePrefArgumentsSerializer(experiment) + self.assertEqual( + serializer.data, + { + "preferenceBranchType": experiment.pref_branch, + "slug": experiment.normandy_slug, + "experimentDocumentUrl": experiment.experiment_url, + "preferenceName": experiment.pref_key, + "preferenceType": experiment.pref_type, + "branches": [ + ExperimentRecipeVariantSerializer(variant).data + for variant in experiment.variants.all() + ], + }, + ) + + def test_serializer_outputs_expected_schema_with_json_str(self): + experiment = ExperimentFactory(pref_type=Experiment.PREF_TYPE_JSON_STR) + serializer = ExperimentRecipePrefArgumentsSerializer(experiment) + self.assertEqual( + serializer.data, + { + "preferenceBranchType": experiment.pref_branch, + "slug": experiment.normandy_slug, + "experimentDocumentUrl": experiment.experiment_url, + "preferenceName": experiment.pref_key, + "preferenceType": "string", + "branches": [ + ExperimentRecipeVariantSerializer(variant).data + for variant in experiment.variants.all() + ], + }, + ) diff --git a/app/experimenter/experiments/tests/test_api_views.py b/app/experimenter/experiments/tests/test_api_views.py index 1a7572c18a..226fe9a86c 100644 --- a/app/experimenter/experiments/tests/test_api_views.py +++ b/app/experimenter/experiments/tests/test_api_views.py @@ -7,14 +7,18 @@ from experimenter.experiments.constants import ExperimentConstants from experimenter.experiments.models import Experiment -from experimenter.experiments.serializers import ( - ExperimentSerializer, - ExperimentRecipeSerializer, +from experimenter.experiments.serializers.entities import ExperimentSerializer + +from experimenter.experiments.serializers.recipe import ExperimentRecipeSerializer + +from experimenter.experiments.serializers.design import ( ExperimentDesignPrefSerializer, ExperimentDesignMultiPrefSerializer, ExperimentDesignAddonSerializer, ExperimentDesignGenericSerializer, ) + + from experimenter.experiments.tests.factories import ( ExperimentFactory, ExperimentVariantFactory, diff --git a/app/experimenter/experiments/tests/test_changelog_utils.py b/app/experimenter/experiments/tests/test_changelog_utils.py index bfd0879692..3cc2fd30d5 100644 --- a/app/experimenter/experiments/tests/test_changelog_utils.py +++ b/app/experimenter/experiments/tests/test_changelog_utils.py @@ -5,7 +5,7 @@ ExperimentVariantFactory, UserFactory, ) -from experimenter.experiments.serializers import ChangeLogSerializer +from experimenter.experiments.serializers.entities import ChangeLogSerializer from experimenter.experiments.changelog_utils import generate_change_log diff --git a/app/experimenter/experiments/tests/test_models.py b/app/experimenter/experiments/tests/test_models.py index 03d976ae63..5208aa0166 100644 --- a/app/experimenter/experiments/tests/test_models.py +++ b/app/experimenter/experiments/tests/test_models.py @@ -13,7 +13,7 @@ VariantPreferences, ExperimentChangeLog, ) -from experimenter.experiments.serializers import ExperimentRecipeSerializer +from experimenter.experiments.serializers.recipe import ExperimentRecipeSerializer from experimenter.experiments.tests.factories import ( ExperimentFactory, ExperimentVariantFactory,