diff --git a/docs/user/filters.rst b/docs/user/filters.rst index 20da5b231..3114e5807 100644 --- a/docs/user/filters.rst +++ b/docs/user/filters.rst @@ -30,6 +30,7 @@ Filter Objects .. autoclass:: PrefCompareFilter() .. autoclass:: PrefUserSetFilter() .. autoclass:: WindowsBuildNumberFilter() +.. autoclass:: WindowsVersionFilter() Filter Expressions diff --git a/normandy/recipes/filters.py b/normandy/recipes/filters.py index 5b8d32649..532202cd4 100644 --- a/normandy/recipes/filters.py +++ b/normandy/recipes/filters.py @@ -592,6 +592,50 @@ def to_jexl(self): return f"(normandy.os.isWindows && {super().to_jexl()})" +class WindowsVersionFilter(BaseComparisonFilter): + """ + Match a user based on what windows version they are running. This filter + creates jexl that compares the windows NT version. + + .. attribute:: type + + ``windows_version`` + + .. attribute:: value + number, decimal, must be one of the following: 6.1, 6.2, 6.3, 10.0 + + :example: ``6.1`` + + .. attribute:: comparison + Options are ``equal``, ``not_equal``, ``greater_than``, + ``less_than``, ``greater_than_equal`` and ``less_than_equal``. + + :example: ``not_equal`` + """ + + type = "windows_version" + value = serializers.DecimalField(max_digits=3, decimal_places=1) + + @property + def left_of_operator(self): + return "normandy.os.windowsVersion" + + def to_jexl(self): + return f"(normandy.os.isWindows && {super().to_jexl()})" + + def validate_value(self, value): + from normandy.recipes.models import WindowsVersion + + if not WindowsVersion.objects.filter(nt_version=value).exists(): + raise serializers.ValidationError(f"Unrecognized windows version slug {value!r}") + + return value + + @property + def capabilities(self): + return set() + + class ProfileCreateDateFilter(BaseFilter): """ This filter is meant to distinguish between new and existing users. @@ -656,6 +700,7 @@ def capabilities(self): PrefExistsFilter, PrefCompareFilter, PrefUserSetFilter, + WindowsVersionFilter, ] } diff --git a/normandy/recipes/management/commands/initial_data.py b/normandy/recipes/management/commands/initial_data.py index 6338c3280..0b4a0999f 100644 --- a/normandy/recipes/management/commands/initial_data.py +++ b/normandy/recipes/management/commands/initial_data.py @@ -1,7 +1,7 @@ from django.core.management.base import BaseCommand from django_countries import countries -from normandy.recipes.models import Channel, Country +from normandy.recipes.models import Channel, Country, WindowsVersion class Command(BaseCommand): @@ -22,6 +22,7 @@ class Command(BaseCommand): def handle(self, *args, **options): self.add_release_channels() self.add_countries() + self.add_windows_versions() def add_release_channels(self): self.stdout.write("Adding Release Channels...", ending="") @@ -41,3 +42,16 @@ def add_countries(self): for code, name in countries: Country.objects.update_or_create(code=code, defaults={"name": name}) self.stdout.write("Done") + + def add_windows_versions(self): + self.stdout.write("Adding Windows Versions...", ending="") + versions = [ + (6.1, "Windows 7"), + (6.2, "Windows 8"), + (6.3, "Windows 8.1"), + (10.0, "Windows 10"), + ] + + for nt_version, name in versions: + WindowsVersion.objects.update_or_create(nt_version=nt_version, defaults={"name": name}) + self.stdout.write("Done") diff --git a/normandy/recipes/migrations/0018_windowsversion.py b/normandy/recipes/migrations/0018_windowsversion.py new file mode 100644 index 000000000..04b3b93c3 --- /dev/null +++ b/normandy/recipes/migrations/0018_windowsversion.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.10 on 2020-03-18 20:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("recipes", "0017_auto_20191008_1930")] + + operations = [ + migrations.CreateModel( + name="WindowsVersion", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("nt_version", models.DecimalField(decimal_places=1, max_digits=3)), + ("name", models.CharField(max_length=255)), + ], + options={"ordering": ("nt_version",)}, + ) + ] diff --git a/normandy/recipes/models.py b/normandy/recipes/models.py index ab25ab73d..30a69c9f0 100644 --- a/normandy/recipes/models.py +++ b/normandy/recipes/models.py @@ -44,6 +44,17 @@ def __repr__(self): return "".format(self.slug) +class WindowsVersion(models.Model): + nt_version = models.DecimalField(max_digits=3, decimal_places=1) + name = models.CharField(max_length=255) + + class Meta: + ordering = ("nt_version",) + + def __repr__(self): + return "".format(self.nt_version) + + class Country(models.Model): code = models.CharField(max_length=255, unique=True) name = models.CharField(max_length=255) diff --git a/normandy/recipes/tests/__init__.py b/normandy/recipes/tests/__init__.py index b113a9297..765858324 100644 --- a/normandy/recipes/tests/__init__.py +++ b/normandy/recipes/tests/__init__.py @@ -18,6 +18,7 @@ Recipe, RecipeRevision, Signature, + WindowsVersion, ) @@ -30,6 +31,15 @@ class Meta: name = "Beta" +class WindowsVersionFactory(factory.DjangoModelFactory): + class Meta: + model = WindowsVersion + django_get_or_create = ("nt_version",) + + nt_version = 6.1 + name = "Windows 7" + + class CountryFactory(factory.DjangoModelFactory): class Meta: model = Country diff --git a/normandy/recipes/tests/test_filters.py b/normandy/recipes/tests/test_filters.py index c14c99f68..0b678061d 100644 --- a/normandy/recipes/tests/test_filters.py +++ b/normandy/recipes/tests/test_filters.py @@ -16,8 +16,14 @@ PrefExistsFilter, PrefUserSetFilter, WindowsBuildNumberFilter, + WindowsVersionFilter, +) +from normandy.recipes.tests import ( + ChannelFactory, + LocaleFactory, + CountryFactory, + WindowsVersionFactory, ) -from normandy.recipes.tests import ChannelFactory, LocaleFactory, CountryFactory @pytest.mark.django_db @@ -122,7 +128,7 @@ def test_generates_jexl(self): } -class TestWindowsBuildNumberFiter(FilterTestsBase): +class TestWindowsBuildNumberFilter(FilterTestsBase): def create_basic_filter(self, value=12345, comparison="equal"): return WindowsBuildNumberFilter.create(value=value, comparison=comparison) @@ -149,6 +155,39 @@ def test_generates_jexl_error_on_bad_comparison(self): filter.to_jexl() +class TestWindowsVersionFilter(FilterTestsBase): + def create_basic_filter(self, value=6.1, comparison="equal"): + WindowsVersionFactory(nt_version=6.1) + + return WindowsVersionFilter.create(value=value, comparison=comparison) + + @pytest.mark.parametrize( + "comparison,symbol", + [ + ("equal", "=="), + ("greater_than", ">"), + ("greater_than_equal", ">="), + ("less_than", "<"), + ("less_than_equal", "<="), + ], + ) + def test_generates_jexl_number_ops(self, comparison, symbol): + filter = self.create_basic_filter(comparison=comparison) + assert ( + filter.to_jexl() + == f"(normandy.os.isWindows && normandy.os.windowsVersion {symbol} 6.1)" + ) + + def test_generates_jexl_error_on_bad_comparison(self): + filter = self.create_basic_filter(comparison="typo") + with pytest.raises(serializers.ValidationError): + filter.to_jexl() + + def test_generates_jexl_error_on_bad_version(self): + with pytest.raises(AssertionError): + self.create_basic_filter(value="abcd") + + class TestChannelFilter(FilterTestsBase): def create_basic_filter(self, channels=None): if channels: