Skip to content

Commit

Permalink
Merge #2265
Browse files Browse the repository at this point in the history
2265: Add API parameter to filter by filter object values r=rehandalal a=mythmon

This will be helpful in making a new version of the namespaces tool. The current
Namespaces tool has to request every single revision from the server (via
graphql). With this API, clients can filter for revisions that have the desired
namespace much more easily.


Co-authored-by: Mike Cooper <mythmon@gmail.com>
  • Loading branch information
bors[bot] and mythmon committed Sep 1, 2020
2 parents 95e5c78 + 3cac0e6 commit 1d1ec02
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 1d1ec02

Please sign in to comment.