Skip to content

Commit

Permalink
Add API parameter to filter by filter object values
Browse files Browse the repository at this point in the history
  • Loading branch information
mythmon committed Sep 1, 2020
1 parent 95e5c78 commit 3cac0e6
Show file tree
Hide file tree
Showing 4 changed files with 101 additions and 0 deletions.
52 changes: 52 additions & 0 deletions normandy/recipes/api/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,55 @@ def filter(self, qs, value):
if value:
qs = qs.filter(**{"{}__in".format(self.field_name): value.split(",")})
return qs


class FilterObjectFieldFilter(django_filters.Filter):
"""
Find recipes that have a filter object with the given field
Format for the filter's value is `key1:value1,key2:value2`. This would
include recipes that have a filter object that has a field `key1` that
contains the value `value1`, and that have a filter object with a field
`key2` that contains `value2`. The two filter objects do not have to be
the same, but may be.
"""

def filter(self, qs, value):
if value is None:
return qs

needles = {k: v for k, v in [p.split(":") for p in value.split(",")]}

# Let the database do a first pass filter
for k, v in needles.items():
qs = qs.filter(latest_revision__filter_object_json__contains=k)
qs = qs.filter(latest_revision__filter_object_json__contains=v)

recipes = list(qs)
if not all(isinstance(recipe, Recipe) for recipe in recipes):
raise TypeError("FilterObjectFieldFilter can only be used to filter recipes")

# For every recipe that contains the right substrings, look through
# their filter objects for an actual match
match_ids = []
for recipe in recipes:
recipe_matches = True

# Recipes needs to have all the keys and values in the needles
for k, v in needles.items():
for filter_object in recipe.latest_revision.filter_object:
# Don't consider invalid filter objects
if not filter_object.is_valid():
continue
if k in filter_object.data and v in str(filter_object.data[k]):
# Found a match
break
else:
# Did not break, so no match was not found
recipe_matches = False
break

if recipe_matches:
match_ids.append(recipe.id)

return Recipe.objects.filter(id__in=match_ids)
2 changes: 2 additions & 0 deletions normandy/recipes/api/v3/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
CharSplitFilter,
EnabledStateFilter,
BaselineCapabilitiesFilter,
FilterObjectFieldFilter,
)
from normandy.recipes.api.v3 import shield_identicon
from normandy.recipes.api.v3.serializers import (
Expand All @@ -55,6 +56,7 @@ class RecipeFilters(django_filters.FilterSet):
locales = CharSplitFilter("latest_revision__locales__code")
countries = CharSplitFilter("latest_revision__countries__code")
uses_only_baseline_capabilities = BaselineCapabilitiesFilter()
filter_object = FilterObjectFieldFilter()

class Meta:
model = Recipe
Expand Down
1 change: 1 addition & 0 deletions normandy/recipes/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ def locales(self, create, extracted, **kwargs):
def filter_object(self, create, extracted, **kwargs):
if extracted:
self.latest_revision.filter_object = extracted
self.latest_revision.save()

# This should always be before `enabler`
@factory.post_generation
Expand Down
46 changes: 46 additions & 0 deletions normandy/recipes/tests/api/v3/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from normandy.base.tests import UserFactory, Whatever
from normandy.base.utils import canonical_json_dumps
from normandy.recipes.models import ApprovalRequest, Recipe, RecipeRevision
from normandy.recipes import filters as filter_objects
from normandy.recipes.tests import (
ActionFactory,
ApprovalRequestFactory,
Expand Down Expand Up @@ -172,6 +173,51 @@ def test_list_can_filter_baseline_recipes(
assert res.data["count"] == 1
assert res.data["results"][0]["id"] == recipe1.id

def test_list_can_filter_by_filter_object_fields(self, api_client):
locale = LocaleFactory()
recipe1 = RecipeFactory(
filter_object=[
filter_objects.BucketSampleFilter.create(
start=100,
count=200,
total=10_000,
input=["normandy.userId", '"global-v4'],
),
filter_objects.LocaleFilter.create(locales=[locale.code]),
]
)
recipe2 = RecipeFactory(
filter_object=[
filter_objects.PresetFilter.create(name="pocket-1"),
filter_objects.LocaleFilter.create(locales=[locale.code]),
]
)

# All recipes are visible
res = api_client.get("/api/v3/recipe/")
assert res.status_code == 200
assert {r["id"] for r in res.data["results"]} == {recipe1.id, recipe2.id}

# Filters can find simple-strings
res = api_client.get("/api/v3/recipe/?filter_object=name:pocket")
assert res.status_code == 200
assert {r["id"] for r in res.data["results"]} == {recipe2.id}

# Filters can find partial values in lists
res = api_client.get("/api/v3/recipe/?filter_object=input:global-v4")
assert res.status_code == 200
assert {r["id"] for r in res.data["results"]} == {recipe1.id}

# Filters can find multiple results
res = api_client.get(f"/api/v3/recipe/?filter_object=locales:{locale.code}")
assert res.status_code == 200
assert {r["id"] for r in res.data["results"]} == {recipe1.id, recipe2.id}

# Filters can find nothing
res = api_client.get("/api/v3/recipe/?filter_object=doesnt:exist")
assert res.status_code == 200
assert res.data["count"] == 0

@pytest.mark.django_db
class TestCreation(object):
def test_it_can_create_recipes(self, api_client):
Expand Down

0 comments on commit 3cac0e6

Please sign in to comment.