зеркало из https://github.com/mozilla/normandy.git
Add a PresetFilter filter object for Pocket
This commit is contained in:
Родитель
3bb1b27887
Коммит
c1de200b7d
|
@ -33,8 +33,11 @@ Filter Objects
|
|||
.. autoclass:: WindowsBuildNumberFilter()
|
||||
.. autoclass:: WindowsVersionFilter()
|
||||
.. autoclass:: NegateFilter()
|
||||
.. autoclass:: AndFilter()
|
||||
.. autoclass:: OrFilter()
|
||||
.. autoclass:: AddonActiveFilter()
|
||||
.. autoclass:: AddonInstalledFilter()
|
||||
.. autoclass:: PresetFilter()
|
||||
.. autoclass:: JexlFilter()
|
||||
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Загрузка…
Ссылка в новой задаче