* locales and regions part of #971 Part of #971 * update README for API examples * remove print * test countries serializing * countries and locales in admin * blackfixe * more blackfixing * remove obsolete comment * remove view tests that checks output * load-initial-data management command * simplify * bet I forgot something * prettier js * feedbacked * 'all' choices called 'All locales' and 'All countries' * more blacked * manually unconflicting migration
This commit is contained in:
Родитель
1682e118c7
Коммит
46d9fdbafc
3
Makefile
3
Makefile
|
@ -59,6 +59,9 @@ migrate: compose_build
|
|||
createuser: compose_build
|
||||
docker-compose run app python manage.py createsuperuser
|
||||
|
||||
load_locales_countries: compose_build
|
||||
docker-compose run app python manage.py load-locales-countries
|
||||
|
||||
shell: compose_build
|
||||
docker-compose run app python manage.py shell
|
||||
|
||||
|
|
12
README.md
12
README.md
|
@ -83,6 +83,10 @@ An experiment has three parts:
|
|||
|
||||
make createuser
|
||||
|
||||
1. Load the initial data
|
||||
|
||||
make load_locales_countries
|
||||
|
||||
1. Run a dev instance
|
||||
|
||||
make up
|
||||
|
@ -159,7 +163,9 @@ Example: GET /api/v1/experiments/?project__slug=project-slug&status=Pending
|
|||
[
|
||||
{
|
||||
"accept_url":"https://localhost/api/v1/experiments/self-enabling-needs-based-hardware/accept",
|
||||
"client_matching":"Locales: en-US, en-CA, en-GB\nGeos: US, CA, GB\nSome \"additional\" filtering",
|
||||
"client_matching":"Some \"additional\" filtering",
|
||||
"locales": [{"code":"en-US", "name": "English (US)"}],
|
||||
"countries": [{"code": "US", "name": "United States of America"}],
|
||||
"control":{
|
||||
"description":"Eos sunt adipisci beatae. Aut sunt totam maiores reprehenderit sed vero. Nam fugit sequi repellendus cumque. Fugit maxime suscipit eius quas iure exercitationem voluptatibus.",
|
||||
"name":"Seamless 5thgeneration task-force",
|
||||
|
@ -200,7 +206,9 @@ Example: GET /api/v1/experiments/self-enabled-needs-based-hardware/
|
|||
|
||||
{
|
||||
"accept_url":"https://localhost/api/v1/experiments/self-enabling-needs-based-hardware/accept",
|
||||
"client_matching":"Locales: en-US, en-CA, en-GB\nGeos: US, CA, GB\nSome \"additional\" filtering",
|
||||
"client_matching":"Some \"additional\" filtering",
|
||||
"locales": [{"code":"en-US", "name": "English (US)"}],
|
||||
"countries": [{"code": "US", "name": "United States of America"}],
|
||||
"control":{
|
||||
"description":"Eos sunt adipisci beatae. Aut sunt totam maiores reprehenderit sed vero. Nam fugit sequi repellendus cumque. Fugit maxime suscipit eius quas iure exercitationem voluptatibus.",
|
||||
"name":"Seamless 5thgeneration task-force",
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from django_countries import countries
|
||||
from product_details import product_details
|
||||
|
||||
from experimenter.base.models import Country, Locale
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Insert all necessary locales and countries"
|
||||
|
||||
def handle(self, **options):
|
||||
self.ensure_all_locales()
|
||||
self.ensure_all_countries()
|
||||
|
||||
@staticmethod
|
||||
def ensure_all_locales():
|
||||
new = []
|
||||
existing = {
|
||||
code: name
|
||||
for code, name in Locale.objects.all().values_list("code", "name")
|
||||
}
|
||||
# It's important to use .items() here or else it will trigger
|
||||
# product_details.ProductDetails.__getattr__ for each key lookup.
|
||||
for code, data in product_details.languages.items():
|
||||
name = data["English"]
|
||||
if code not in existing:
|
||||
new.append(Locale(code=code, name=name))
|
||||
elif name != existing[code]:
|
||||
Locale.objects.filter(code=code).update(name=name)
|
||||
if new:
|
||||
Locale.objects.bulk_create(new)
|
||||
|
||||
@staticmethod
|
||||
def ensure_all_countries():
|
||||
new = []
|
||||
existing = {
|
||||
code: name
|
||||
for code, name in Country.objects.all().values_list("code", "name")
|
||||
}
|
||||
for code, name in countries:
|
||||
if code not in existing:
|
||||
new.append(Country(code=code, name=name))
|
||||
elif name != existing[code]:
|
||||
Country.objects.filter(code=code).update(name=name)
|
||||
if new:
|
||||
Country.objects.bulk_create(new)
|
|
@ -0,0 +1,55 @@
|
|||
# Generated by Django 2.1.7 on 2019-03-13 23:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Country",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("code", models.CharField(max_length=255, unique=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Country",
|
||||
"verbose_name_plural": "Countries",
|
||||
"ordering": ("name",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Locale",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("code", models.CharField(max_length=255, unique=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Locale",
|
||||
"verbose_name_plural": "Locales",
|
||||
"ordering": ("name",),
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,33 @@
|
|||
from django.db import models
|
||||
|
||||
|
||||
class Locale(models.Model):
|
||||
code = models.CharField(max_length=255, unique=True)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
ordering = ("name",)
|
||||
verbose_name = "Locale"
|
||||
verbose_name_plural = "Locales"
|
||||
|
||||
def __repr__(self): # pragma: no cover
|
||||
return f"<{self.__class__.__name__} {self.code}>"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.code})"
|
||||
|
||||
|
||||
class Country(models.Model):
|
||||
code = models.CharField(max_length=255, unique=True)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
ordering = ("name",)
|
||||
verbose_name = "Country"
|
||||
verbose_name_plural = "Countries"
|
||||
|
||||
def __repr__(self): # pragma: no cover
|
||||
return f"<{self.__class__.__name__} {self.code}>"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.code})"
|
|
@ -0,0 +1,21 @@
|
|||
import factory
|
||||
|
||||
from experimenter.base.models import Country, Locale
|
||||
|
||||
|
||||
class LocaleFactory(factory.django.DjangoModelFactory):
|
||||
code = "en-US"
|
||||
name = "English (US)"
|
||||
|
||||
class Meta:
|
||||
model = Locale
|
||||
django_get_or_create = ("code",)
|
||||
|
||||
|
||||
class CountryFactory(factory.django.DjangoModelFactory):
|
||||
code = "US"
|
||||
name = "United States of America"
|
||||
|
||||
class Meta:
|
||||
model = Country
|
||||
django_get_or_create = ("code",)
|
|
@ -0,0 +1,32 @@
|
|||
from django.test import TestCase
|
||||
from django.core.management import call_command
|
||||
|
||||
from experimenter.base.models import Country, Locale
|
||||
|
||||
|
||||
class TestInitialData(TestCase):
|
||||
|
||||
def test_load_locales_countries(self):
|
||||
self.assertTrue(not Country.objects.exists())
|
||||
self.assertTrue(not Locale.objects.exists())
|
||||
|
||||
call_command("load-locales-countries")
|
||||
|
||||
self.assertTrue(Country.objects.exists())
|
||||
self.assertTrue(Locale.objects.exists())
|
||||
|
||||
# First mess with the installed data, so it tests the "corrections"
|
||||
# that the managemeent does.
|
||||
Country.objects.filter(code="SV").delete()
|
||||
Country.objects.filter(code="FR").update(name="Frankies")
|
||||
# Also, mess with Locales
|
||||
Locale.objects.filter(code="sv-SE").delete()
|
||||
Locale.objects.filter(code="fr").update(name="Franchism")
|
||||
|
||||
call_command("load-locales-countries")
|
||||
|
||||
self.assertTrue(Country.objects.get(code="SV"))
|
||||
self.assertEqual(Country.objects.get(code="FR").name, "France")
|
||||
|
||||
self.assertTrue(Locale.objects.get(code="sv-SE"))
|
||||
self.assertEqual(Locale.objects.get(code="fr").name, "French")
|
|
@ -69,6 +69,8 @@ class ExperimentAdmin(admin.ModelAdmin):
|
|||
"pref_type",
|
||||
"pref_branch",
|
||||
"client_matching",
|
||||
"locales",
|
||||
"countries",
|
||||
)
|
||||
},
|
||||
),
|
||||
|
|
|
@ -4,8 +4,10 @@ from django import forms
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.forms import BaseInlineFormSet
|
||||
from django.forms import inlineformset_factory
|
||||
from django.forms.models import ModelChoiceIterator
|
||||
from django.utils import timezone
|
||||
|
||||
from experimenter.base.models import Country, Locale
|
||||
from experimenter.experiments.constants import ExperimentConstants
|
||||
from experimenter.experiments import tasks
|
||||
from experimenter.experiments.models import (
|
||||
|
@ -297,6 +299,35 @@ class ExperimentVariantsPrefFormSet(ExperimentVariantsFormSet):
|
|||
)
|
||||
|
||||
|
||||
class CustomModelChoiceIterator(ModelChoiceIterator):
|
||||
|
||||
def __iter__(self):
|
||||
yield (CustomModelMultipleChoiceField.ALL_KEY, self.field.all_label)
|
||||
for choice in super().__iter__():
|
||||
yield choice
|
||||
|
||||
|
||||
class CustomModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
"""Return a ModelMultipleChoiceField but with the exception that
|
||||
there's one extra "All" choice inserted as the first choice.
|
||||
And when submitted, if "All" was one of the choices, reset
|
||||
it to chose nothing."""
|
||||
|
||||
ALL_KEY = "__all__"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.all_label = kwargs.pop("all_label")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean(self, value):
|
||||
if value is not None:
|
||||
if self.ALL_KEY in value:
|
||||
value = []
|
||||
return super().clean(value)
|
||||
|
||||
iterator = CustomModelChoiceIterator
|
||||
|
||||
|
||||
class ExperimentVariantsAddonForm(ChangeLogMixin, forms.ModelForm):
|
||||
|
||||
FORMSET_FORM_CLASS = ExperimentVariantAddonForm
|
||||
|
@ -322,6 +353,28 @@ class ExperimentVariantsAddonForm(ChangeLogMixin, forms.ModelForm):
|
|||
widget=forms.Textarea(attrs={"class": "form-control", "rows": 10}),
|
||||
)
|
||||
|
||||
locales = CustomModelMultipleChoiceField(
|
||||
label="Locales",
|
||||
required=False,
|
||||
all_label="All locales",
|
||||
help_text="Applicable only if you don't select All",
|
||||
queryset=Locale.objects.all(),
|
||||
to_field_name="code",
|
||||
)
|
||||
countries = CustomModelMultipleChoiceField(
|
||||
label="Countries",
|
||||
required=False,
|
||||
all_label="All countries",
|
||||
help_text="Applicable only if you don't select All",
|
||||
queryset=Country.objects.all(),
|
||||
to_field_name="code",
|
||||
)
|
||||
# See https://developer.snapappointments.com/bootstrap-select/examples/
|
||||
# for more options that relate to the initial rendering of the HTML
|
||||
# as a way to customize how it works.
|
||||
locales.widget.attrs.update({"data-live-search": "true"})
|
||||
countries.widget.attrs.update({"data-live-search": "true"})
|
||||
|
||||
class Meta:
|
||||
model = Experiment
|
||||
fields = [
|
||||
|
@ -329,11 +382,27 @@ class ExperimentVariantsAddonForm(ChangeLogMixin, forms.ModelForm):
|
|||
"firefox_version",
|
||||
"firefox_channel",
|
||||
"client_matching",
|
||||
"locales",
|
||||
"countries",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
data = kwargs.pop("data", None)
|
||||
instance = kwargs.pop("instance", None)
|
||||
if instance:
|
||||
# The reason we must do this is because the form fields
|
||||
# for locales and countries don't know about the instance
|
||||
# not having anything set, and we want the "All" option to
|
||||
# appear in the generated HTML widget.
|
||||
kwargs.setdefault("initial", {})
|
||||
if not instance.locales.all().exists():
|
||||
kwargs["initial"]["locales"] = [
|
||||
CustomModelMultipleChoiceField.ALL_KEY
|
||||
]
|
||||
if not instance.countries.all().exists():
|
||||
kwargs["initial"]["countries"] = [
|
||||
CustomModelMultipleChoiceField.ALL_KEY
|
||||
]
|
||||
super().__init__(data=data, instance=instance, *args, **kwargs)
|
||||
|
||||
extra = 0
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 2.1.7 on 2019-03-13 23:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("base", "0001_initial"),
|
||||
("experiments", "0031_experiment_normandy_slug"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="experiment",
|
||||
name="countries",
|
||||
field=models.ManyToManyField(to="base.Country"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="experiment",
|
||||
name="locales",
|
||||
field=models.ManyToManyField(to="base.Locale"),
|
||||
),
|
||||
]
|
|
@ -12,6 +12,7 @@ from django.urls import reverse
|
|||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from experimenter.base.models import Country, Locale
|
||||
from experimenter.experiments.constants import ExperimentConstants
|
||||
|
||||
|
||||
|
@ -100,6 +101,8 @@ class Experiment(ExperimentConstants, models.Model):
|
|||
client_matching = models.TextField(
|
||||
default=ExperimentConstants.CLIENT_MATCHING_DEFAULT, blank=True
|
||||
)
|
||||
locales = models.ManyToManyField(Locale)
|
||||
countries = models.ManyToManyField(Country)
|
||||
objectives = models.TextField(
|
||||
default=ExperimentConstants.OBJECTIVES_DEFAULT, blank=True, null=True
|
||||
)
|
||||
|
|
|
@ -2,6 +2,7 @@ import time
|
|||
|
||||
from rest_framework import serializers
|
||||
|
||||
from experimenter.base.models import Country, Locale
|
||||
from experimenter.experiments.models import Experiment, ExperimentVariant
|
||||
|
||||
|
||||
|
@ -32,11 +33,27 @@ class ExperimentVariantSerializer(serializers.ModelSerializer):
|
|||
)
|
||||
|
||||
|
||||
class LocalesSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Locale
|
||||
fields = ("code", "name")
|
||||
|
||||
|
||||
class CountriesSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Country
|
||||
fields = ("code", "name")
|
||||
|
||||
|
||||
class ExperimentSerializer(serializers.ModelSerializer):
|
||||
start_date = JSTimestampField()
|
||||
end_date = JSTimestampField()
|
||||
proposed_start_date = JSTimestampField()
|
||||
variants = ExperimentVariantSerializer(many=True)
|
||||
locales = LocalesSerializer(many=True)
|
||||
countries = CountriesSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Experiment
|
||||
|
@ -47,6 +64,8 @@ class ExperimentSerializer(serializers.ModelSerializer):
|
|||
"slug",
|
||||
"short_description",
|
||||
"client_matching",
|
||||
"locales",
|
||||
"countries",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"population",
|
||||
|
|
|
@ -107,10 +107,7 @@ class ExperimentFactory(
|
|||
lambda o: random.randint(100000, 1000000)
|
||||
)
|
||||
|
||||
client_matching = (
|
||||
"Locales: en-US, en-CA, en-GB\nGeos: US, CA, GB\n"
|
||||
'Some "additional" filtering'
|
||||
)
|
||||
client_matching = "Geos: US, CA, GB\n" 'Some "additional" filtering'
|
||||
|
||||
review_advisory = False
|
||||
review_science = False
|
||||
|
|
|
@ -11,6 +11,7 @@ from parameterized import parameterized_class
|
|||
from experimenter.experiments.forms import (
|
||||
BugzillaURLField,
|
||||
ChangeLogMixin,
|
||||
CustomModelMultipleChoiceField,
|
||||
ExperimentArchiveForm,
|
||||
ExperimentCommentForm,
|
||||
ExperimentObjectivesForm,
|
||||
|
@ -25,6 +26,7 @@ from experimenter.experiments.forms import (
|
|||
JSONField,
|
||||
)
|
||||
from experimenter.experiments.models import Experiment, ExperimentVariant
|
||||
from experimenter.base.tests.factories import CountryFactory, LocaleFactory
|
||||
from experimenter.experiments.tests.factories import ExperimentFactory
|
||||
from experimenter.experiments.tests.mixins import (
|
||||
MockBugzillaMixin,
|
||||
|
@ -366,6 +368,8 @@ class TestExperimentVariantsAddonForm(MockRequestMixin, TestCase):
|
|||
"firefox_version": Experiment.VERSION_CHOICES[-1][0],
|
||||
"firefox_channel": Experiment.CHANNEL_NIGHTLY,
|
||||
"client_matching": "en-us only please",
|
||||
"locales": [],
|
||||
"countries": [],
|
||||
"variants-TOTAL_FORMS": "3",
|
||||
"variants-INITIAL_FORMS": "0",
|
||||
"variants-MIN_NUM_FORMS": "0",
|
||||
|
@ -660,6 +664,174 @@ class TestExperimentVariantsAddonForm(MockRequestMixin, TestCase):
|
|||
experiment2 = form2.save()
|
||||
self.assertEqual(experiment2.variants.count(), 3)
|
||||
|
||||
def test_locales_choices(self):
|
||||
locale1 = LocaleFactory(code="sv-SE", name="Swedish")
|
||||
locale2 = LocaleFactory(code="fr", name="French")
|
||||
|
||||
form = ExperimentVariantsAddonForm(
|
||||
request=self.request, data=self.data, instance=self.experiment
|
||||
)
|
||||
self.assertEqual(
|
||||
list(form.fields["locales"].choices),
|
||||
[
|
||||
(CustomModelMultipleChoiceField.ALL_KEY, "All locales"),
|
||||
(locale2.code, str(locale2)),
|
||||
(locale1.code, str(locale1)),
|
||||
],
|
||||
)
|
||||
|
||||
def test_locales_initials(self):
|
||||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT, num_variants=0
|
||||
)
|
||||
locale1 = LocaleFactory(code="sv-SE", name="Swedish")
|
||||
locale2 = LocaleFactory(code="fr", name="French")
|
||||
experiment.locales.add(locale1)
|
||||
experiment.locales.add(locale2)
|
||||
form = ExperimentVariantsAddonForm(
|
||||
request=self.request, data=self.data, instance=experiment
|
||||
)
|
||||
self.assertEqual(form.initial["locales"], [locale2, locale1])
|
||||
|
||||
def test_locales_initials_all_locales(self):
|
||||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT, num_variants=0
|
||||
)
|
||||
form = ExperimentVariantsAddonForm(
|
||||
request=self.request, data=self.data, instance=experiment
|
||||
)
|
||||
self.assertEqual(
|
||||
form.initial["locales"], [CustomModelMultipleChoiceField.ALL_KEY]
|
||||
)
|
||||
|
||||
def test_clean_locales(self):
|
||||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT, num_variants=0
|
||||
)
|
||||
locale1 = LocaleFactory(code="sv-SE", name="Swedish")
|
||||
locale2 = LocaleFactory(code="fr", name="French")
|
||||
self.data["locales"] = [locale2.code, locale1.code]
|
||||
form = ExperimentVariantsAddonForm(
|
||||
request=self.request, data=self.data, instance=experiment
|
||||
)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(
|
||||
list(form.cleaned_data["locales"]), [locale2, locale1]
|
||||
)
|
||||
|
||||
def test_clean_locales_all(self):
|
||||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT, num_variants=0
|
||||
)
|
||||
locale1 = LocaleFactory(code="sv-SE", name="Swedish")
|
||||
locale2 = LocaleFactory(code="fr", name="French")
|
||||
self.data["locales"] = [
|
||||
locale2.code,
|
||||
CustomModelMultipleChoiceField.ALL_KEY,
|
||||
locale1.code,
|
||||
]
|
||||
form = ExperimentVariantsAddonForm(
|
||||
request=self.request, data=self.data, instance=experiment
|
||||
)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(list(form.cleaned_data["locales"]), [])
|
||||
|
||||
def test_clean_unrecognized_locales(self):
|
||||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT, num_variants=0
|
||||
)
|
||||
self.data["locales"] = ["xxx"]
|
||||
form = ExperimentVariantsAddonForm(
|
||||
request=self.request, data=self.data, instance=experiment
|
||||
)
|
||||
self.assertTrue(not form.is_valid())
|
||||
self.assertTrue(form.errors["locales"])
|
||||
|
||||
def test_countries_choices(self):
|
||||
country1 = CountryFactory(code="SV", name="Sweden")
|
||||
country2 = CountryFactory(code="FR", name="France")
|
||||
|
||||
form = ExperimentVariantsAddonForm(
|
||||
request=self.request, data=self.data, instance=self.experiment
|
||||
)
|
||||
self.assertEqual(
|
||||
list(form.fields["countries"].choices),
|
||||
[
|
||||
(CustomModelMultipleChoiceField.ALL_KEY, "All countries"),
|
||||
(country2.code, str(country2)),
|
||||
(country1.code, str(country1)),
|
||||
],
|
||||
)
|
||||
|
||||
def test_countries_initials(self):
|
||||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT, num_variants=0
|
||||
)
|
||||
country1 = CountryFactory(code="SV", name="Sweden")
|
||||
country2 = CountryFactory(code="FR", name="France")
|
||||
experiment.countries.add(country1)
|
||||
experiment.countries.add(country2)
|
||||
form = ExperimentVariantsAddonForm(
|
||||
request=self.request, data=self.data, instance=experiment
|
||||
)
|
||||
self.assertEqual(form.initial["countries"], [country2, country1])
|
||||
|
||||
def test_countries_initials_all(self):
|
||||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT, num_variants=0
|
||||
)
|
||||
form = ExperimentVariantsAddonForm(
|
||||
request=self.request, data=self.data, instance=experiment
|
||||
)
|
||||
self.assertEqual(
|
||||
form.initial["countries"], [CustomModelMultipleChoiceField.ALL_KEY]
|
||||
)
|
||||
|
||||
def test_clean_countries(self):
|
||||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT, num_variants=0
|
||||
)
|
||||
country1 = CountryFactory(code="SV", name="Sweden")
|
||||
country2 = CountryFactory(code="FR", name="France")
|
||||
self.data["countries"] = [country1.code, country2.code]
|
||||
form = ExperimentVariantsAddonForm(
|
||||
request=self.request, data=self.data, instance=experiment
|
||||
)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(
|
||||
# form.cleaned_data["countries"] is a QuerySet to exhaust it.
|
||||
list(form.cleaned_data["countries"]),
|
||||
[country2, country1],
|
||||
)
|
||||
|
||||
def test_clean_countries_all(self):
|
||||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT, num_variants=0
|
||||
)
|
||||
country1 = CountryFactory(code="SV", name="Sweden")
|
||||
country2 = CountryFactory(code="FR", name="France")
|
||||
self.data["countries"] = [
|
||||
country1.code,
|
||||
CustomModelMultipleChoiceField.ALL_KEY,
|
||||
country2.code,
|
||||
]
|
||||
form = ExperimentVariantsAddonForm(
|
||||
request=self.request, data=self.data, instance=experiment
|
||||
)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(list(form.cleaned_data["countries"]), [])
|
||||
|
||||
def test_clean_unrecognized_countries(self):
|
||||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT, num_variants=0
|
||||
)
|
||||
self.data["countries"] = ["xxx"]
|
||||
form = ExperimentVariantsAddonForm(
|
||||
request=self.request, data=self.data, instance=experiment
|
||||
)
|
||||
self.assertTrue(not form.is_valid())
|
||||
self.assertTrue(form.errors["countries"])
|
||||
|
||||
|
||||
class TestExperimentVariantsPrefForm(MockRequestMixin, TestCase):
|
||||
|
||||
|
@ -697,6 +869,8 @@ class TestExperimentVariantsPrefForm(MockRequestMixin, TestCase):
|
|||
"variants-2-name": self.branch2_name,
|
||||
"variants-2-description": "branch 2 desc",
|
||||
"variants-2-value": '"branch 2 value"',
|
||||
"locales": [],
|
||||
"countries": [],
|
||||
}
|
||||
|
||||
def test_form_saves_variants(self):
|
||||
|
|
|
@ -2,6 +2,7 @@ import datetime
|
|||
|
||||
from django.test import TestCase
|
||||
|
||||
from experimenter.base.tests.factories import CountryFactory, LocaleFactory
|
||||
from experimenter.experiments.models import Experiment
|
||||
from experimenter.experiments.tests.factories import (
|
||||
ExperimentFactory,
|
||||
|
@ -87,9 +88,31 @@ class TestExperimentSerializer(TestCase):
|
|||
ExperimentVariantSerializer(variant).data
|
||||
for variant in experiment.variants.all()
|
||||
],
|
||||
"locales": [],
|
||||
"countries": [],
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
set(serialized.data.keys()), set(expected_data.keys())
|
||||
)
|
||||
self.assertEqual(serialized.data, expected_data)
|
||||
|
||||
def test_serializer_locales(self):
|
||||
locale = LocaleFactory()
|
||||
experiment = ExperimentFactory.create()
|
||||
experiment.locales.add(locale)
|
||||
serialized = ExperimentSerializer(experiment)
|
||||
self.assertEqual(
|
||||
serialized.data["locales"],
|
||||
[{"code": locale.code, "name": locale.name}],
|
||||
)
|
||||
|
||||
def test_serializer_countries(self):
|
||||
country = CountryFactory()
|
||||
experiment = ExperimentFactory.create()
|
||||
experiment.countries.add(country)
|
||||
serialized = ExperimentSerializer(experiment)
|
||||
self.assertEqual(
|
||||
serialized.data["countries"],
|
||||
[{"code": country.code, "name": country.name}],
|
||||
)
|
||||
|
|
|
@ -10,6 +10,7 @@ from django.test import TestCase
|
|||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from experimenter.base.tests.factories import CountryFactory, LocaleFactory
|
||||
from experimenter.experiments.forms import (
|
||||
ExperimentVariantsAddonForm,
|
||||
ExperimentVariantsPrefForm,
|
||||
|
@ -549,12 +550,16 @@ class TestExperimentVariantsUpdateView(TestCase):
|
|||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT
|
||||
)
|
||||
locale = LocaleFactory()
|
||||
country = CountryFactory()
|
||||
|
||||
data = {
|
||||
"population_percent": "11",
|
||||
"firefox_version": Experiment.VERSION_CHOICES[-1][0],
|
||||
"firefox_channel": Experiment.CHANNEL_NIGHTLY,
|
||||
"client_matching": "New matching!",
|
||||
"locales": [locale.code],
|
||||
"countries": [country.code],
|
||||
"pref_key": "browser.test.example",
|
||||
"pref_type": Experiment.PREF_TYPE_STR,
|
||||
"pref_branch": Experiment.PREF_BRANCH_DEFAULT,
|
||||
|
@ -601,6 +606,10 @@ class TestExperimentVariantsUpdateView(TestCase):
|
|||
self.assertEqual(experiment.pref_type, data["pref_type"])
|
||||
self.assertEqual(experiment.pref_branch, data["pref_branch"])
|
||||
|
||||
self.assertTrue(locale in experiment.locales.all())
|
||||
|
||||
self.assertTrue(country in experiment.countries.all())
|
||||
|
||||
self.assertEqual(experiment.changes.count(), 2)
|
||||
|
||||
change = experiment.changes.latest()
|
||||
|
@ -721,6 +730,32 @@ class TestExperimentDetailView(TestCase):
|
|||
self.assertTemplateUsed(response, "experiments/detail_draft.html")
|
||||
self.assertTemplateUsed(response, "experiments/detail_base.html")
|
||||
|
||||
def test_view_renders_locales_correctly(self):
|
||||
user_email = "user@example.com"
|
||||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT
|
||||
)
|
||||
experiment.locales.add(LocaleFactory(code="yy", name="Why"))
|
||||
experiment.locales.add(LocaleFactory(code="xx", name="Xess"))
|
||||
response = self.client.get(
|
||||
reverse("experiments-detail", kwargs={"slug": experiment.slug}),
|
||||
**{settings.OPENIDC_EMAIL_HEADER: user_email},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_view_renders_countries_correctly(self):
|
||||
user_email = "user@example.com"
|
||||
experiment = ExperimentFactory.create_with_status(
|
||||
Experiment.STATUS_DRAFT
|
||||
)
|
||||
experiment.countries.add(CountryFactory(code="YY", name="Wazoo"))
|
||||
experiment.countries.add(CountryFactory(code="XX", name="Xanadu"))
|
||||
response = self.client.get(
|
||||
reverse("experiments-detail", kwargs={"slug": experiment.slug}),
|
||||
**{settings.OPENIDC_EMAIL_HEADER: user_email},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class TestExperimentStatusUpdateView(MockTasksMixin, TestCase):
|
||||
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
.bootstrap-select .btn-light {
|
||||
background-color: white;
|
||||
border-color: #ced4da;
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
// Initialize the formset plugin
|
||||
jQuery(function($) {
|
||||
$("#formset").formset({
|
||||
animateForms: true,
|
||||
reorderMode: "dom"
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize the bootstrap-select plugin
|
||||
// https://developer.snapappointments.com/bootstrap-select/
|
||||
jQuery(function($) {
|
||||
$("select[multiple]")
|
||||
.selectpicker()
|
||||
.on("changed.bs.select", function(e, clickedIndex) {
|
||||
const $this = $(this);
|
||||
if (clickedIndex === 0) {
|
||||
// User selected 'All'
|
||||
$this.val("__all__");
|
||||
$this.selectpicker("refresh");
|
||||
} else {
|
||||
// User selected anything by 'All'
|
||||
if ($this.selectpicker("val").includes("__all__")) {
|
||||
$this.val(
|
||||
$this.selectpicker("val").filter(value => value !== "__all__")
|
||||
);
|
||||
$this.selectpicker("refresh");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,392 @@
|
|||
/*!
|
||||
* Bootstrap-select v1.13.2 (https://developer.snapappointments.com/bootstrap-select)
|
||||
*
|
||||
* Copyright 2012-2018 SnapAppointments, LLC
|
||||
* Licensed under MIT (https://github.com/snapappointments/bootstrap-select/blob/master/LICENSE)
|
||||
*/
|
||||
|
||||
select.bs-select-hidden,
|
||||
.bootstrap-select > select.bs-select-hidden,
|
||||
select.selectpicker {
|
||||
display: none !important;
|
||||
}
|
||||
.bootstrap-select {
|
||||
width: 220px \0;
|
||||
/*IE9 and below*/
|
||||
}
|
||||
.bootstrap-select > .dropdown-toggle {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder:hover,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder:focus,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder:active {
|
||||
color: #999;
|
||||
}
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary:hover,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary:hover,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success:hover,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger:hover,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info:hover,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark:hover,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary:focus,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary:focus,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success:focus,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger:focus,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info:focus,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark:focus,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary:active,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary:active,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success:active,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger:active,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info:active,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark:active {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.bootstrap-select > select {
|
||||
position: absolute !important;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
display: block !important;
|
||||
width: 0.5px !important;
|
||||
height: 100% !important;
|
||||
padding: 0 !important;
|
||||
opacity: 0 !important;
|
||||
border: none;
|
||||
}
|
||||
.bootstrap-select > select.mobile-device {
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
z-index: 2;
|
||||
}
|
||||
.has-error .bootstrap-select .dropdown-toggle,
|
||||
.error .bootstrap-select .dropdown-toggle,
|
||||
.bootstrap-select.is-invalid .dropdown-toggle,
|
||||
.was-validated .bootstrap-select .selectpicker:invalid + .dropdown-toggle {
|
||||
border-color: #b94a48;
|
||||
}
|
||||
.bootstrap-select.is-valid .dropdown-toggle,
|
||||
.was-validated .bootstrap-select .selectpicker:valid + .dropdown-toggle {
|
||||
border-color: #28a745;
|
||||
}
|
||||
.bootstrap-select.fit-width {
|
||||
width: auto !important;
|
||||
}
|
||||
.bootstrap-select:not([class*="col-"]):not([class*="form-control"]):not(.input-group-btn) {
|
||||
width: 220px;
|
||||
}
|
||||
.bootstrap-select .dropdown-toggle:focus {
|
||||
outline: thin dotted #333333 !important;
|
||||
outline: 5px auto -webkit-focus-ring-color !important;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.bootstrap-select.form-control {
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
:not(.input-group) > .bootstrap-select.form-control:not([class*="col-"]) {
|
||||
width: 100%;
|
||||
}
|
||||
.bootstrap-select.form-control.input-group-btn {
|
||||
z-index: auto;
|
||||
}
|
||||
.bootstrap-select.form-control.input-group-btn:not(:first-child):not(:last-child) > .btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
.bootstrap-select:not(.input-group-btn),
|
||||
.bootstrap-select[class*="col-"] {
|
||||
float: none;
|
||||
display: inline-block;
|
||||
margin-left: 0;
|
||||
}
|
||||
.bootstrap-select.dropdown-menu-right,
|
||||
.bootstrap-select[class*="col-"].dropdown-menu-right,
|
||||
.row .bootstrap-select[class*="col-"].dropdown-menu-right {
|
||||
float: right;
|
||||
}
|
||||
.form-inline .bootstrap-select,
|
||||
.form-horizontal .bootstrap-select,
|
||||
.form-group .bootstrap-select {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group-lg .bootstrap-select.form-control,
|
||||
.form-group-sm .bootstrap-select.form-control {
|
||||
padding: 0;
|
||||
}
|
||||
.form-group-lg .bootstrap-select.form-control .dropdown-toggle,
|
||||
.form-group-sm .bootstrap-select.form-control .dropdown-toggle {
|
||||
height: 100%;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.bootstrap-select.form-control-sm .dropdown-toggle,
|
||||
.bootstrap-select.form-control-lg .dropdown-toggle {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.bootstrap-select.form-control-sm .dropdown-toggle {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.bootstrap-select.form-control-lg .dropdown-toggle {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
.form-inline .bootstrap-select .form-control {
|
||||
width: 100%;
|
||||
}
|
||||
.bootstrap-select.disabled,
|
||||
.bootstrap-select > .disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.bootstrap-select.disabled:focus,
|
||||
.bootstrap-select > .disabled:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
.bootstrap-select.bs-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.bootstrap-select.bs-container .dropdown-menu {
|
||||
z-index: 1060;
|
||||
}
|
||||
.bootstrap-select .dropdown-toggle:before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
}
|
||||
.bootstrap-select .dropdown-toggle .filter-option {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding-top: inherit;
|
||||
padding-right: inherit;
|
||||
padding-bottom: inherit;
|
||||
padding-left: inherit;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.bootstrap-select .dropdown-toggle .filter-option-inner {
|
||||
padding-right: inherit;
|
||||
}
|
||||
.bootstrap-select .dropdown-toggle .filter-option-inner-inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
.bootstrap-select .dropdown-toggle .caret {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
margin-top: -2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.input-group .bootstrap-select.form-control .dropdown-toggle {
|
||||
border-radius: inherit;
|
||||
}
|
||||
.bootstrap-select[class*="col-"] .dropdown-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
.bootstrap-select .dropdown-menu {
|
||||
min-width: 100%;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.bootstrap-select .dropdown-menu > .inner:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
.bootstrap-select .dropdown-menu.inner {
|
||||
position: static;
|
||||
float: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.bootstrap-select .dropdown-menu li {
|
||||
position: relative;
|
||||
}
|
||||
.bootstrap-select .dropdown-menu li.active small {
|
||||
color: rgba(255, 255, 255, 0.5) !important;
|
||||
}
|
||||
.bootstrap-select .dropdown-menu li.disabled a {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.bootstrap-select .dropdown-menu li a {
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.bootstrap-select .dropdown-menu li a.opt {
|
||||
position: relative;
|
||||
padding-left: 2.25em;
|
||||
}
|
||||
.bootstrap-select .dropdown-menu li a span.check-mark {
|
||||
display: none;
|
||||
}
|
||||
.bootstrap-select .dropdown-menu li a span.text {
|
||||
display: inline-block;
|
||||
}
|
||||
.bootstrap-select .dropdown-menu li small {
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
.bootstrap-select .dropdown-menu .notify {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
width: 96%;
|
||||
margin: 0 2%;
|
||||
min-height: 26px;
|
||||
padding: 3px 5px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e3e3e3;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.bootstrap-select .no-results {
|
||||
padding: 3px;
|
||||
background: #f5f5f5;
|
||||
margin: 0 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bootstrap-select.fit-width .dropdown-toggle .filter-option {
|
||||
position: static;
|
||||
display: inline;
|
||||
padding: 0;
|
||||
}
|
||||
.bootstrap-select.fit-width .dropdown-toggle .filter-option-inner,
|
||||
.bootstrap-select.fit-width .dropdown-toggle .filter-option-inner-inner {
|
||||
display: inline;
|
||||
}
|
||||
.bootstrap-select.fit-width .dropdown-toggle .caret {
|
||||
position: static;
|
||||
top: auto;
|
||||
margin-top: -1px;
|
||||
}
|
||||
.bootstrap-select.show-tick .dropdown-menu .selected span.check-mark {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
right: 15px;
|
||||
top: 5px;
|
||||
}
|
||||
.bootstrap-select.show-tick .dropdown-menu li a span.text {
|
||||
margin-right: 34px;
|
||||
}
|
||||
.bootstrap-select .bs-ok-default:after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 0.5em;
|
||||
height: 1em;
|
||||
border-style: solid;
|
||||
border-width: 0 0.26em 0.26em 0;
|
||||
-webkit-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
-o-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.bootstrap-select.show-menu-arrow.open > .dropdown-toggle,
|
||||
.bootstrap-select.show-menu-arrow.show > .dropdown-toggle {
|
||||
z-index: 1061;
|
||||
}
|
||||
.bootstrap-select.show-menu-arrow .dropdown-toggle .filter-option:before {
|
||||
content: '';
|
||||
border-left: 7px solid transparent;
|
||||
border-right: 7px solid transparent;
|
||||
border-bottom: 7px solid rgba(204, 204, 204, 0.2);
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 9px;
|
||||
display: none;
|
||||
}
|
||||
.bootstrap-select.show-menu-arrow .dropdown-toggle .filter-option:after {
|
||||
content: '';
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-bottom: 6px solid white;
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 10px;
|
||||
display: none;
|
||||
}
|
||||
.bootstrap-select.show-menu-arrow.dropup .dropdown-toggle .filter-option:before {
|
||||
bottom: auto;
|
||||
top: -4px;
|
||||
border-top: 7px solid rgba(204, 204, 204, 0.2);
|
||||
border-bottom: 0;
|
||||
}
|
||||
.bootstrap-select.show-menu-arrow.dropup .dropdown-toggle .filter-option:after {
|
||||
bottom: auto;
|
||||
top: -4px;
|
||||
border-top: 6px solid white;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.bootstrap-select.show-menu-arrow.pull-right .dropdown-toggle .filter-option:before {
|
||||
right: 12px;
|
||||
left: auto;
|
||||
}
|
||||
.bootstrap-select.show-menu-arrow.pull-right .dropdown-toggle .filter-option:after {
|
||||
right: 13px;
|
||||
left: auto;
|
||||
}
|
||||
.bootstrap-select.show-menu-arrow.open > .dropdown-toggle .filter-option:before,
|
||||
.bootstrap-select.show-menu-arrow.show > .dropdown-toggle .filter-option:before,
|
||||
.bootstrap-select.show-menu-arrow.open > .dropdown-toggle .filter-option:after,
|
||||
.bootstrap-select.show-menu-arrow.show > .dropdown-toggle .filter-option:after {
|
||||
display: block;
|
||||
}
|
||||
.bs-searchbox,
|
||||
.bs-actionsbox,
|
||||
.bs-donebutton {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.bs-actionsbox {
|
||||
width: 100%;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.bs-actionsbox .btn-group button {
|
||||
width: 50%;
|
||||
}
|
||||
.bs-donebutton {
|
||||
float: left;
|
||||
width: 100%;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.bs-donebutton .btn-group button {
|
||||
width: 100%;
|
||||
}
|
||||
.bs-searchbox + .bs-actionsbox {
|
||||
padding: 0 8px 4px;
|
||||
}
|
||||
.bs-searchbox .form-control {
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
float: none;
|
||||
}
|
||||
/*# sourceMappingURL=bootstrap-select.css.map */
|
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
6
app/experimenter/static/lib/bootstrap-select/css/bootstrap-select.min.css
поставляемый
Normal file
6
app/experimenter/static/lib/bootstrap-select/css/bootstrap-select.min.css
поставляемый
Normal file
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
|
@ -21,6 +21,9 @@
|
|||
{% block title %}
|
||||
<title>Mozilla Experimenter</title>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
@ -131,6 +134,7 @@
|
|||
<script src="{% static "lib/jquery/jquery-3.3.1.min.js" %}"></script>
|
||||
|
||||
<!-- Latest compiled and minified JavaScript -->
|
||||
<script src="{% static "lib/popper/js/popper.min.js" %}"></script>
|
||||
<script src="{% static "lib/bootstrap/js/bootstrap.min.js" %}"></script>
|
||||
|
||||
{% if USE_GOOGLE_ANALYTICS %}
|
||||
|
|
|
@ -58,6 +58,10 @@
|
|||
|
||||
{% include "experiments/field_inline.html" with field=form.client_matching %}
|
||||
|
||||
{% include "experiments/field_inline.html" with field=form.locales %}
|
||||
|
||||
{% include "experiments/field_inline.html" with field=form.countries %}
|
||||
|
||||
<hr class="heavy-line my-5"/>
|
||||
|
||||
{% if experiment.is_pref_study %}
|
||||
|
@ -138,15 +142,14 @@
|
|||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
<link rel="stylesheet" href="{% static "lib/bootstrap-select/css/bootstrap-select.min.css" %}">
|
||||
<link rel="stylesheet" href="{% static "css/edit-variants.css" %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block extrascripts %}
|
||||
<script src="{% static "js/jquery.formset.js" %}"></script>
|
||||
|
||||
<script>
|
||||
jQuery(function($) {
|
||||
$("#formset").formset({
|
||||
animateForms: true,
|
||||
reorderMode: 'dom',
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{% static "lib/bootstrap-select/js/bootstrap-select.min.js" %}"></script>
|
||||
<script src="{% static "js/edit-variants.js" %}"></script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -7,4 +7,24 @@ Population
|
|||
{% block section_content %}
|
||||
<h5>{{ experiment.population }}</h5>
|
||||
{{ experiment.client_matching|urlize|linebreaks }}
|
||||
|
||||
<strong>Locales</strong>
|
||||
<p>
|
||||
{% for locale in experiment.locales.all %}
|
||||
{{ locale }}{% if not forloop.last %}, {% endif %}
|
||||
{% empty %}
|
||||
All
|
||||
{% endfor %}
|
||||
</p>
|
||||
|
||||
<strong>Countries</strong>
|
||||
<p>
|
||||
{% for country in experiment.countries.all %}
|
||||
{{ country }}{% if not forloop.last %}, {% endif %}
|
||||
{% empty %}
|
||||
All
|
||||
{% endfor %}
|
||||
</p>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -144,3 +144,9 @@ parameterized==0.7.0 \
|
|||
pytest-cov==2.6.1 \
|
||||
--hash=sha256:0ab664b25c6aa9716cbf203b17ddb301932383046082c081b9848a0edf5add33 \
|
||||
--hash=sha256:230ef817450ab0699c6cc3c9c8f7a829c34674456f2ed8df1fe1d39780f7c87f
|
||||
django-mozilla-product-details==0.13.1 \
|
||||
--hash=sha256:fad2022fb9289aca574a9c1cb6bea90c9977302ad31494608b540d35c201e3a9 \
|
||||
--hash=sha256:fdb87422ebd1b15ece9d338100c75d1dbe936930ca14e4f5644b9918b6b4f6f3
|
||||
django-countries==5.3.3 \
|
||||
--hash=sha256:5307a61172eee5740720e44ea08721858b7d8bf8509ec7701ccd7a8d21120b9a \
|
||||
--hash=sha256:e4eaaec9bddb9365365109f833d1fd0ecc0cfee3348bf5441c0ccefb2d6917cd
|
||||
|
|
Загрузка…
Ссылка в новой задаче