Skip to content

Commit 0ceb6fb

Browse files
feat(nimbus): exclude language/locale/country (#13753)
Becuase * We need a way to optionally exclude selected languages/locales/countries This commit * Adds a bool flag to exclude languages/locales/countries * Negates the JEXL expressions for each if exclude is selected * Adds checkboxes for each on the audience page * Updates the summary page to show whether each is included or excluded fixes #13752
1 parent e8a69f0 commit 0ceb6fb

File tree

9 files changed

+187
-9
lines changed

9 files changed

+187
-9
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 5.2.7 on 2025-10-22 20:57
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('experiments', '0294_alter_nimbusexperiment_channel'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='nimbusexperiment',
15+
name='exclude_countries',
16+
field=models.BooleanField(default=False, help_text='If True, exclude the selected countries instead of including them', verbose_name='Exclude Countries'),
17+
),
18+
migrations.AddField(
19+
model_name='nimbusexperiment',
20+
name='exclude_languages',
21+
field=models.BooleanField(default=False, help_text='If True, exclude the selected languages instead of including them', verbose_name='Exclude Languages'),
22+
),
23+
migrations.AddField(
24+
model_name='nimbusexperiment',
25+
name='exclude_locales',
26+
field=models.BooleanField(default=False, help_text='If True, exclude the selected locales instead of including them', verbose_name='Exclude Locales'),
27+
),
28+
]

experimenter/experimenter/experiments/models.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,21 @@ class NimbusExperiment(NimbusConstants, TargetingConstants, FilterMixin, models.
270270
languages = models.ManyToManyField[Language](
271271
Language, blank=True, verbose_name="Supported Languages"
272272
)
273+
exclude_locales = models.BooleanField(
274+
"Exclude Locales",
275+
default=False,
276+
help_text="If True, exclude the selected locales instead of including them",
277+
)
278+
exclude_countries = models.BooleanField(
279+
"Exclude Countries",
280+
default=False,
281+
help_text="If True, exclude the selected countries instead of including them",
282+
)
283+
exclude_languages = models.BooleanField(
284+
"Exclude Languages",
285+
default=False,
286+
help_text="If True, exclude the selected languages instead of including them",
287+
)
273288
is_sticky = models.BooleanField("Sticky Enrollment Flag", default=False)
274289
projects = models.ManyToManyField[Project](
275290
Project, blank=True, verbose_name="Supported Projects"
@@ -621,21 +636,28 @@ def targeting(self):
621636

622637
if locales := self.locales.all():
623638
locales = [locale.code for locale in sorted(locales, key=lambda l: l.code)]
624-
625-
sticky_expressions.append(f"locale in {locales}")
639+
locales_expression = f"locale in {locales}"
640+
if self.exclude_locales:
641+
locales_expression = f"!({locales_expression})"
642+
sticky_expressions.append(locales_expression)
626643

627644
if languages := self.languages.all():
628645
languages = [
629646
language.code for language in sorted(languages, key=lambda l: l.code)
630647
]
631-
632-
sticky_expressions.append(f"language in {languages}")
648+
languages_expression = f"language in {languages}"
649+
if self.exclude_languages:
650+
languages_expression = f"!({languages_expression})"
651+
sticky_expressions.append(languages_expression)
633652

634653
if countries := self.countries.all():
635654
countries = [
636655
country.code for country in sorted(countries, key=lambda c: c.code)
637656
]
638-
sticky_expressions.append(f"region in {countries}")
657+
countries_expression = f"region in {countries}"
658+
if self.exclude_countries:
659+
countries_expression = f"!({countries_expression})"
660+
sticky_expressions.append(countries_expression)
639661

640662
enrollments_map_key = "enrollments_map"
641663
if self.is_desktop:

experimenter/experimenter/experiments/tests/test_changelog_utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ def test_outputs_expected_schema_for_empty_experiment(self):
5858
"countries": [],
5959
"equal_branch_ratio": experiment.equal_branch_ratio,
6060
"excluded_experiments": [],
61+
"exclude_countries": experiment.exclude_countries,
62+
"exclude_languages": experiment.exclude_languages,
63+
"exclude_locales": experiment.exclude_locales,
6164
"feature_configs": [],
6265
"firefox_max_version": NimbusExperiment.Version.NO_VERSION,
6366
"firefox_min_version": NimbusExperiment.Version.NO_VERSION,
@@ -181,6 +184,9 @@ def test_outputs_expected_schema_for_complete_experiment(self):
181184
"conclusion_recommendations": [],
182185
"equal_branch_ratio": experiment.equal_branch_ratio,
183186
"excluded_experiments": [],
187+
"exclude_countries": experiment.exclude_countries,
188+
"exclude_languages": experiment.exclude_languages,
189+
"exclude_locales": experiment.exclude_locales,
184190
"firefox_max_version": experiment.firefox_max_version,
185191
"firefox_min_version": experiment.firefox_min_version,
186192
"firefox_labs_title": experiment.firefox_labs_title,

experimenter/experimenter/experiments/tests/test_models.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,28 @@ def test_targeting_with_locales(self):
827827
)
828828
JEXLParser().parse(experiment.targeting)
829829

830+
def test_targeting_with_exclude_locales(self):
831+
locale_ca = LocaleFactory.create(code="en-CA")
832+
locale_us = LocaleFactory.create(code="en-US")
833+
experiment = NimbusExperimentFactory.create_with_lifecycle(
834+
NimbusExperimentFactory.Lifecycles.LAUNCH_APPROVE_APPROVE,
835+
application=NimbusExperiment.Application.DESKTOP,
836+
firefox_min_version=NimbusExperiment.Version.NO_VERSION,
837+
firefox_max_version=NimbusExperiment.Version.NO_VERSION,
838+
targeting_config_slug=NimbusExperiment.TargetingConfig.MAC_ONLY,
839+
channel=NimbusExperiment.Channel.NO_CHANNEL,
840+
channels=[],
841+
locales=[locale_ca, locale_us],
842+
exclude_locales=True,
843+
countries=[],
844+
languages=[],
845+
)
846+
self.assertEqual(
847+
experiment.targeting,
848+
("(os.isMac) && (!(locale in ['en-CA', 'en-US']))"),
849+
)
850+
JEXLParser().parse(experiment.targeting)
851+
830852
def test_targeting_with_countries(self):
831853
country_ca = CountryFactory.create(code="CA")
832854
country_us = CountryFactory.create(code="US")
@@ -848,6 +870,28 @@ def test_targeting_with_countries(self):
848870
)
849871
JEXLParser().parse(experiment.targeting)
850872

873+
def test_targeting_with_exclude_countries(self):
874+
country_ca = CountryFactory.create(code="CA")
875+
country_us = CountryFactory.create(code="US")
876+
experiment = NimbusExperimentFactory.create_with_lifecycle(
877+
NimbusExperimentFactory.Lifecycles.LAUNCH_APPROVE_APPROVE,
878+
application=NimbusExperiment.Application.DESKTOP,
879+
firefox_min_version=NimbusExperiment.Version.NO_VERSION,
880+
firefox_max_version=NimbusExperiment.Version.NO_VERSION,
881+
targeting_config_slug=NimbusExperiment.TargetingConfig.MAC_ONLY,
882+
channel=NimbusExperiment.Channel.NO_CHANNEL,
883+
channels=[],
884+
locales=[],
885+
countries=[country_ca, country_us],
886+
exclude_countries=True,
887+
languages=[],
888+
)
889+
self.assertEqual(
890+
experiment.targeting,
891+
("(os.isMac) && (!(region in ['CA', 'US']))"),
892+
)
893+
JEXLParser().parse(experiment.targeting)
894+
851895
def test_targeting_with_locales_and_countries_desktop(self):
852896
locale_ca = LocaleFactory.create(code="en-CA")
853897
locale_us = LocaleFactory.create(code="en-US")
@@ -891,6 +935,27 @@ def test_targeting_with_languages_mobile(self):
891935
)
892936
JEXLParser().parse(experiment.targeting)
893937

938+
def test_targeting_with_exclude_languages_mobile(self):
939+
language_en = LanguageFactory.create(code="en")
940+
language_fr = LanguageFactory.create(code="fr")
941+
language_es = LanguageFactory.create(code="es")
942+
experiment = NimbusExperimentFactory.create_with_lifecycle(
943+
NimbusExperimentFactory.Lifecycles.LAUNCH_APPROVE_APPROVE,
944+
application=NimbusExperiment.Application.FENIX,
945+
firefox_min_version=NimbusExperiment.Version.NO_VERSION,
946+
firefox_max_version=NimbusExperiment.Version.NO_VERSION,
947+
targeting_config_slug=NimbusExperiment.TargetingConfig.MOBILE_NEW_USERS,
948+
channel=NimbusExperiment.Channel.NO_CHANNEL,
949+
channels=[],
950+
languages=[language_en, language_es, language_fr],
951+
exclude_languages=True,
952+
)
953+
self.assertEqual(
954+
experiment.targeting,
955+
"(days_since_install < 7) && (!(language in ['en', 'es', 'fr']))",
956+
)
957+
JEXLParser().parse(experiment.targeting)
958+
894959
def test_targeting_with_projects(self):
895960
project_mdn = ProjectFactory.create(slug="mdn")
896961

experimenter/experimenter/nimbus_ui/forms.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -966,16 +966,19 @@ def get_targeting_config_choices(self):
966966
queryset=Locale.objects.all().order_by("code"),
967967
widget=MultiSelectWidget(),
968968
)
969+
exclude_locales = forms.BooleanField(required=False)
969970
languages = forms.ModelMultipleChoiceField(
970971
required=False,
971972
queryset=Language.objects.all().order_by("code"),
972973
widget=MultiSelectWidget(),
973974
)
975+
exclude_languages = forms.BooleanField(required=False)
974976
countries = forms.ModelMultipleChoiceField(
975977
required=False,
976978
queryset=Country.objects.all().order_by("code"),
977979
widget=MultiSelectWidget(),
978980
)
981+
exclude_countries = forms.BooleanField(required=False)
979982
targeting_config_slug = forms.ChoiceField(
980983
required=False,
981984
label="",
@@ -1015,11 +1018,14 @@ class Meta:
10151018
"channel",
10161019
"channels",
10171020
"countries",
1021+
"exclude_countries",
1022+
"exclude_languages",
1023+
"exclude_locales",
10181024
"excluded_experiments_branches",
10191025
"firefox_max_version",
10201026
"firefox_min_version",
1021-
"is_sticky",
10221027
"is_first_run",
1028+
"is_sticky",
10231029
"languages",
10241030
"locales",
10251031
"population_percent",

experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/detail.html

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,17 +249,38 @@ <h4>Audience</h4>
249249
</tr>
250250
<tr>
251251
{% if experiment.is_mobile %}
252-
<th>Languages</th>
252+
<th>
253+
{% if experiment.exclude_languages %}
254+
Excluded
255+
{% else %}
256+
Included
257+
{% endif %}
258+
Languages
259+
</th>
253260
<td>
254261
{{ experiment.languages.all|join:"<br>"|default:"All Languages" }}
255262
</td>
256263
{% else %}
257-
<th>Locales</th>
264+
<th>
265+
{% if experiment.exclude_locales %}
266+
Excluded
267+
{% else %}
268+
Included
269+
{% endif %}
270+
Locales
271+
</th>
258272
<td>
259273
{{ experiment.locales.all|join:"<br>"|default:"All Locales" }}
260274
</td>
261275
{% endif %}
262-
<th>Countries</th>
276+
<th>
277+
{% if experiment.exclude_countries %}
278+
Excluded
279+
{% else %}
280+
Included
281+
{% endif %}
282+
Countries
283+
</th>
263284
<td>
264285
{{ experiment.countries.all|join:"<br>"| default:"All Countries" }}
265286
</td>

experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/edit_audience.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,30 @@ <h4>Audience</h4>
5656
{% if experiment.is_desktop %}
5757
<label for="id_locales" class="form-label">Locales</label>
5858
{{ form.locales|add_class:"form-control"|add_error_class:"is-invalid" }}
59+
<label for="id_exclude_locales" class="form-label mt-2">
60+
{{ form.exclude_locales|add_error_class:"is-invalid" }}
61+
Exclude selected locales
62+
</label>
5963
{% for error in form.locales.errors %}<div class="invalid-feedback">{{ error }}</div>{% endfor %}
6064
{% for error in validation_errors.locales %}<div class="form-text text-danger">{{ error }}</div>{% endfor %}
6165
{% else %}
6266
<label for="id_languages" class="form-label">Languages</label>
6367
{{ form.languages|add_class:"form-control"|add_error_class:"is-invalid" }}
68+
<label for="id_exclude_languages" class="form-label mt-2">
69+
{{ form.exclude_languages|add_error_class:"is-invalid" }}
70+
Exclude selected languages
71+
</label>
6472
{% for error in form.languages.errors %}<div class="invalid-feedback">{{ error }}</div>{% endfor %}
6573
{% for error in validation_errors.languages %}<div class="form-text text-danger">{{ error }}</div>{% endfor %}
6674
{% endif %}
6775
</div>
6876
<div data-testid="countries" class="col-6">
6977
<label for="id_countries" class="form-label">Countries</label>
7078
{{ form.countries|add_class:"form-control"|add_error_class:"is-invalid" }}
79+
<label for="id_exclude_countries" class="form-label mt-2">
80+
{{ form.exclude_countries|add_error_class:"is-invalid" }}
81+
Exclude selected countries
82+
</label>
7183
{% for error in form.countries.errors %}<div class="invalid-feedback">{{ error }}</div>{% endfor %}
7284
{% for error in validation_errors.countries %}<div class="form-text text-danger">{{ error }}</div>{% endfor %}
7385
</div>

experimenter/experimenter/nimbus_ui/tests/test_forms.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,6 +1347,9 @@ def test_valid_form_saves_desktop(self):
13471347
countries=[],
13481348
locales=[],
13491349
languages=[],
1350+
exclude_countries=False,
1351+
exclude_locales=False,
1352+
exclude_languages=False,
13501353
)
13511354

13521355
form = AudienceForm(
@@ -1359,6 +1362,9 @@ def test_valid_form_saves_desktop(self):
13591362
],
13601363
"countries": [country.id],
13611364
"excluded_experiments_branches": [f"{excluded.slug}:None"],
1365+
"exclude_countries": True,
1366+
"exclude_locales": True,
1367+
"exclude_languages": True,
13621368
"firefox_max_version": NimbusExperiment.Version.FIREFOX_84,
13631369
"firefox_min_version": NimbusExperiment.Version.FIREFOX_83,
13641370
"is_sticky": True,
@@ -1400,6 +1406,9 @@ def test_valid_form_saves_desktop(self):
14001406
self.assertEqual(list(experiment.countries.all()), [country])
14011407
self.assertEqual(list(experiment.locales.all()), [locale])
14021408
self.assertEqual(list(experiment.languages.all()), [language])
1409+
self.assertTrue(experiment.exclude_countries)
1410+
self.assertTrue(experiment.exclude_locales)
1411+
self.assertTrue(experiment.exclude_languages)
14031412
self.assertTrue(experiment.is_sticky)
14041413
self.assertEqual(experiment.excluded_experiments.get(), excluded)
14051414
self.assertTrue(

experimenter/experimenter/nimbus_ui/tests/test_views.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2750,6 +2750,9 @@ def test_post_updates_overview(self):
27502750
countries=[],
27512751
locales=[],
27522752
languages=[],
2753+
exclude_countries=False,
2754+
exclude_locales=False,
2755+
exclude_languages=False,
27532756
)
27542757

27552758
response = self.client.post(
@@ -2758,6 +2761,9 @@ def test_post_updates_overview(self):
27582761
"channel": NimbusExperiment.Channel.BETA,
27592762
"countries": [country.id],
27602763
"excluded_experiments_branches": [f"{excluded.slug}:None"],
2764+
"exclude_countries": True,
2765+
"exclude_locales": True,
2766+
"exclude_languages": True,
27612767
"firefox_max_version": NimbusExperiment.Version.FIREFOX_84,
27622768
"firefox_min_version": NimbusExperiment.Version.FIREFOX_83,
27632769
"is_sticky": True,
@@ -2794,6 +2800,9 @@ def test_post_updates_overview(self):
27942800
self.assertEqual(list(experiment.countries.all()), [country])
27952801
self.assertEqual(list(experiment.locales.all()), [locale])
27962802
self.assertEqual(list(experiment.languages.all()), [language])
2803+
self.assertTrue(experiment.exclude_countries)
2804+
self.assertTrue(experiment.exclude_locales)
2805+
self.assertTrue(experiment.exclude_languages)
27972806
self.assertTrue(experiment.is_sticky)
27982807
self.assertEqual(experiment.excluded_experiments.get(), excluded)
27992808
self.assertTrue(

0 commit comments

Comments
 (0)