Add a PresetFilter filter object for Pocket

This commit is contained in:
Mike Cooper 2020-07-28 16:24:42 -07:00
Родитель 3bb1b27887
Коммит c1de200b7d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 9424CEA6F89AB334
5 изменённых файлов: 281 добавлений и 25 удалений

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

@ -33,8 +33,11 @@ Filter Objects
.. autoclass:: WindowsBuildNumberFilter()
.. autoclass:: WindowsVersionFilter()
.. autoclass:: NegateFilter()
.. autoclass:: AndFilter()
.. autoclass:: OrFilter()
.. autoclass:: AddonActiveFilter()
.. autoclass:: AddonInstalledFilter()
.. autoclass:: PresetFilter()
.. autoclass:: JexlFilter()

31
normandy/base/jexl.py Normal file
Просмотреть файл

@ -0,0 +1,31 @@
from pyjexl import JEXL
_cached_jexl = None
def get_normandy_jexl():
global _cached_jexl
if not _cached_jexl:
_cached_jexl = JEXL()
# Add mock transforms for validation. See
# https://mozilla.github.io/normandy/user/filters.html#transforms
# for a list of what transforms we expect to be available.
transforms = [
"bucketSample",
"date",
"keys",
"length",
"mapToProperty",
"preferenceExists",
"preferenceIsUserSet",
"preferenceValue",
"regExpMatch",
"stableSample",
"versionCompare",
]
for transform in transforms:
_cached_jexl.add_transform(transform, lambda x: x)
return _cached_jexl

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

@ -1,8 +1,8 @@
from pyjexl import JEXL
from rest_framework import serializers
from factory.fuzzy import FuzzyText
from normandy.base.api.v3.serializers import UserSerializer
from normandy.base.jexl import get_normandy_jexl
from normandy.recipes import filters
from normandy.recipes.api.fields import ActionImplementationHyperlinkField, FilterObjectField
from normandy.recipes.models import (
@ -190,17 +190,7 @@ class RecipeSerializer(CustomizableSerializerMixin, serializers.ModelSerializer)
def validate_extra_filter_expression(self, value):
if value:
jexl = JEXL()
# Add mock transforms for validation. See
# https://mozilla.github.io/normandy/user/filters.html#transforms
# for a list of what transforms we expect to be available.
jexl.add_transform("date", lambda x: x)
jexl.add_transform("stableSample", lambda x: x)
jexl.add_transform("bucketSample", lambda x: x)
jexl.add_transform("preferenceValue", lambda x: x)
jexl.add_transform("preferenceIsUserSet", lambda x: x)
jexl.add_transform("preferenceExists", lambda x: x)
jexl = get_normandy_jexl()
errors = list(jexl.validate(value))
if errors:

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

@ -23,9 +23,10 @@ field, so the final JSON would look something like this:
import json
from datetime import datetime
from pyjexl import JEXL
from rest_framework import serializers
from normandy.base.jexl import get_normandy_jexl
# If you add a new filter to this file, remember to update the docs too!
class BaseFilter(serializers.Serializer):
@ -402,6 +403,12 @@ class PrefUserSetFilter(BaseFilter):
``preferenceIsUserSet``
.. attribute:: pref
The preference to check
:example: ``app.normandy.enabled``
.. attribute:: value
Boolean true or false.
@ -817,6 +824,75 @@ class NegateFilter(BaseFilter):
return set()
class _CompositeFilter(BaseFilter):
"""Internal building block to combine many filters using a single operator"""
def _get_operator(self):
raise NotImplementedError()
def _get_subfilters(self):
raise NotImplementedError()
def to_jexl(self):
parts = [f.to_jexl() for f in self._get_subfilters()]
expr = self._get_operator().join(parts)
return f"({expr})"
@property
def capabilities(self):
return set.union(*(subfilter.capabilities for subfilter in self._get_subfilters()))
class AndFilter(_CompositeFilter):
"""
This filter combines one or more other filters, requiring all subfilters to match.
.. attribute:: type
``and``
.. attribute:: subfilters
The filters to combine
:example: `[{"type": "locale", "locales": "en-US"}, {"type": "country", "countries": "US"}]`
"""
type = "and"
subfilters = serializers.ListField(child=serializers.JSONField(), min_length=1)
def _get_operator(self):
return "&&"
def _get_subfilters(self):
return [from_data(filter) for filter in self.initial_data["subfilters"]]
class OrFilter(_CompositeFilter):
"""
This filter combines one or more other filters, requiring at least one subfilter to match.
.. attribute:: type
``or``
.. attribute:: subfilters
The filters to combine
:example: `[{"type": "locale", "locales": "en-US"}, {"type": "country", "countries": "US"}]`
"""
type = "or"
subfilters = serializers.ListField(child=serializers.JSONField(), min_length=1)
def _get_operator(self):
return "||"
def _get_subfilters(self):
return [from_data(filter) for filter in self.initial_data["subfilters"]]
class ProfileCreateDateFilter(BaseFilter):
"""
This filter is meant to distinguish between new and existing users.
@ -907,17 +983,7 @@ class JexlFilter(BaseFilter):
def to_jexl(self):
built_expression = "(" + self.initial_data["expression"] + ")"
jexl = JEXL()
# Add mock transforms for validation. See
# https://mozilla.github.io/normandy/user/filters.html#transforms
# for a list of what transforms we expect to be available.
jexl.add_transform("date", lambda x: x)
jexl.add_transform("stableSample", lambda x: x)
jexl.add_transform("bucketSample", lambda x: x)
jexl.add_transform("preferenceValue", lambda x: x)
jexl.add_transform("preferenceIsUserSet", lambda x: x)
jexl.add_transform("preferenceExists", lambda x: x)
jexl = get_normandy_jexl()
errors = list(jexl.validate(built_expression))
if errors:
@ -930,6 +996,55 @@ class JexlFilter(BaseFilter):
return set(self.initial_data["capabilities"])
class PresetFilter(_CompositeFilter):
"""
A named preset of filters.
.. attribute:: type
``preset``
.. attribute:: expression
The name of the preset to evaluate
:example: ``pocket-1``
"""
type = "preset"
name = serializers.CharField()
def _get_operator(self):
return "&&"
def _get_subfilters(self):
def not_user_set(pref):
return {"type": "preferenceIsUserSet", "pref": pref, "value": False}
subfilter_data = None
preset_name = self.initial_data["name"]
if preset_name == "pocket-1":
subfilter_data = [
{
"type": "or",
"subfilters": [
not_user_set("browser.newtabpage.enabled"),
not_user_set("browser.startup.homepage"),
],
},
not_user_set("browser.newtabpage.activity-stream.showSearch"),
not_user_set("browser.newtabpage.activity-stream.feeds.topsites"),
not_user_set("browser.newtabpage.activity-stream.feeds.section.topstories"),
not_user_set("browser.newtabpage.activity-stream.feeds.section.highlights"),
]
if subfilter_data is None:
raise serializers.ValidationError([f"Unknown preset type {preset_name}"])
return [from_data(d) for d in subfilter_data]
def _calculate_by_type():
"""
Gather all filters and build a map of types to filters.

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

@ -1,7 +1,9 @@
import pytest
import re
from collections import defaultdict
from rest_framework import serializers
from normandy.base.jexl import get_normandy_jexl
from normandy.recipes import filters
from normandy.recipes.tests import (
ChannelFactory,
@ -32,7 +34,11 @@ class FilterTestsBase:
def test_jexl_works(self):
filter = self.create_basic_filter()
# Would throw if not defined
assert isinstance(filter.to_jexl(), str)
expr = filter.to_jexl()
assert isinstance(expr, str)
jexl = get_normandy_jexl()
errors = jexl.validate(expr)
assert list(errors) == []
def test_uses_only_baseline_capabilities(self, settings):
filter = self.create_basic_filter()
@ -233,6 +239,72 @@ class TestNegateFilter(FilterTestsBase):
assert negate_filter.to_jexl() == '!(normandy.channel in ["release","beta"])'
class TestAndFilter(FilterTestsBase):
def create_basic_filter(self, subfilters=None):
if subfilters is None:
subfilters = [
{"type": "channel", "channels": ["release", "beta"]},
{"type": "locale", "locales": ["en-US", "de"]},
]
return filters.AndFilter.create(subfilters=subfilters)
def test_generates_jexl_zero_subfilters(self):
with pytest.raises(AssertionError) as excinfo:
self.create_basic_filter(subfilters=[])
assert "has at least 1 element" in str(excinfo.value)
def test_generates_jexl_one_subfilter(self):
negate_filter = self.create_basic_filter(
subfilters=[{"type": "channel", "channels": ["release"]}]
)
assert negate_filter.to_jexl() == '(normandy.channel in ["release"])'
def test_generates_jexl_two_subfilters(self):
negate_filter = self.create_basic_filter(
subfilters=[
{"type": "channel", "channels": ["release"]},
{"type": "locale", "locales": ["en-US"]},
]
)
assert (
negate_filter.to_jexl()
== '(normandy.channel in ["release"]&&normandy.locale in ["en-US"])'
)
class TestOrFilter(FilterTestsBase):
def create_basic_filter(self, subfilters=None):
if subfilters is None:
subfilters = [
{"type": "channel", "channels": ["release", "beta"]},
{"type": "locale", "locales": ["en-US", "de"]},
]
return filters.OrFilter.create(subfilters=subfilters)
def test_generates_jexl_zero_subfilters(self):
with pytest.raises(AssertionError) as excinfo:
self.create_basic_filter(subfilters=[])
assert "has at least 1 element" in str(excinfo.value)
def test_generates_jexl_one_subfilter(self):
negate_filter = self.create_basic_filter(
subfilters=[{"type": "channel", "channels": ["release"]}]
)
assert negate_filter.to_jexl() == '(normandy.channel in ["release"])'
def test_generates_jexl_two_subfilters(self):
negate_filter = self.create_basic_filter(
subfilters=[
{"type": "channel", "channels": ["release"]},
{"type": "locale", "locales": ["en-US"]},
]
)
assert (
negate_filter.to_jexl()
== '(normandy.channel in ["release"]||normandy.locale in ["en-US"])'
)
class TestAddonInstalledFilter(FilterTestsBase):
def create_basic_filter(self, addons=["@abcdef", "ghijk@lmnop"], any_or_all="any"):
return filters.AddonInstalledFilter.create(addons=addons, any_or_all=any_or_all)
@ -409,3 +481,48 @@ class TestJexlFilter(FilterTestsBase):
def test_it_has_capabilities(self):
filter = self.create_basic_filter(capabilities=["a.b", "c.d"])
assert filter.capabilities == {"a.b", "c.d"}
class TestPresetFilter(FilterTestsBase):
def create_basic_filter(self, name="pocket-1"):
return filters.PresetFilter.create(name=name)
def test_pocket_1(self):
filter_object = self.create_basic_filter(name="pocket-1")
# The preset is an and filter
assert filter_object._get_operator() == "&&"
# Pull out the first level subfilters
subfilters = defaultdict(lambda: [])
for filter in filter_object._get_subfilters():
subfilters[type(filter)].append(filter)
# There should be one or filter
or_filters = subfilters.pop(filters.OrFilter)
assert len(or_filters) == 1
or_subfilters = or_filters[0]._get_subfilters()
# It should be made up of negative PrefUserSet filters
for f in or_subfilters:
assert isinstance(f, filters.PrefUserSetFilter)
assert f.initial_data["value"] is False
# And it should use the exected prefs
assert set(f.initial_data["pref"] for f in or_subfilters) == set(
["browser.newtabpage.enabled", "browser.startup.homepage"]
)
# There should be a bunch more negative PrefUserSet filters at the top level
pref_subfilters = subfilters.pop(filters.PrefUserSetFilter)
for f in pref_subfilters:
assert f.initial_data["value"] is False
# and they should be the expected prefs
assert set(f.initial_data["pref"] for f in pref_subfilters) == set(
[
"browser.newtabpage.activity-stream.showSearch",
"browser.newtabpage.activity-stream.feeds.topsites",
"browser.newtabpage.activity-stream.feeds.section.topstories",
"browser.newtabpage.activity-stream.feeds.section.highlights",
]
)
# There should be no other filters
assert subfilters == {}, "no unexpected filters"