* 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:
Peter Bengtsson 2019-03-15 15:50:48 -04:00 коммит произвёл Jared Kerim
Родитель 1682e118c7
Коммит 46d9fdbafc
33 изменённых файлов: 3777 добавлений и 14 удалений

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

@ -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

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

@ -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 */

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

5
app/experimenter/static/lib/popper/js/popper.min.js поставляемый 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