Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add API parameter to filter by filter object values #2265

Merged
merged 1 commit into from
Sep 1, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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