303 lines
9.2 KiB
Markdown
303 lines
9.2 KiB
Markdown
|
# Filters
|
||
|
|
||
|
## Diseño
|
||
|
|
||
|
- Filtro de recetas por ingredientes/tags
|
||
|
- Filtro de ingredientes/tags asignados a recetas, faciltando una lista para
|
||
|
elegir
|
||
|
- Definición de parametros **OpenAPI**, actualizar documentación
|
||
|
|
||
|
### Requests de ejemplo
|
||
|
|
||
|
- Filtrar recetas por **tags(s)**
|
||
|
- `GET` `/api/recipe/recipes/?tags=1,2,3`
|
||
|
- Filtrar recetas por **ingrediente(s)**
|
||
|
- `GET` `/api/recipe/recipes/?ingredients=1,2,3`
|
||
|
- Filtrar tags por **receta asignada**
|
||
|
- `GET` `/api/recipe/tags/?assigned_only=1`
|
||
|
- Filtrar ingredientes por **receta asignada**
|
||
|
- `GET` `/api/recipe/ingredients/?assigned_only=1`
|
||
|
|
||
|
### OpenAPI Schema
|
||
|
|
||
|
- *Schema* auto generada
|
||
|
- Configuracion manual de cierta documentación (custom query parmas filtering)
|
||
|
- Uso del decorador `extend_schema_view` de **DRF_Spectacular**. Ej.
|
||
|
|
||
|
```py
|
||
|
@extend_schema_view(
|
||
|
list=extend_schema(
|
||
|
parameters=[
|
||
|
OpenApiParameter(
|
||
|
'tags',
|
||
|
OpenApiTypes.STR,
|
||
|
description='Coma separated list of tags IDs to filter',
|
||
|
),
|
||
|
OpenApiParameter(
|
||
|
'ingredients',
|
||
|
OpenApiTypes.STR,
|
||
|
description='Coma separated list of ingredients IDs to filter',
|
||
|
),
|
||
|
]
|
||
|
)
|
||
|
)
|
||
|
class RecipeViewSet(viewsets.ModelViewSet):
|
||
|
"""View for manage recipe APIs."""
|
||
|
...
|
||
|
```
|
||
|
|
||
|
## Test filtros
|
||
|
|
||
|
[`test_recipe_api.py`](./app/recipe/tests/test_recipe_api.py)
|
||
|
|
||
|
```py
|
||
|
class PrivateRecipeApiTests(TestCase):
|
||
|
"""Test authenticated API requests."""
|
||
|
|
||
|
...
|
||
|
|
||
|
def test_fitler_by_tags(self):
|
||
|
"""Test filtering recipes by tags."""
|
||
|
r1 = create_recipe(user=self.user, title='Sopa de Verduras')
|
||
|
r2 = create_recipe(user=self.user, title='Arroz con Huevo')
|
||
|
tag1 = Tag.objects.create(user=self.user, name='Vergan')
|
||
|
tag2 = Tag.objects.create(user=self.user, name='Vegetariana')
|
||
|
r1.tags.add(tag1)
|
||
|
r2.tags.add(tag2)
|
||
|
r3 = create_recipe(user=self.user, title='Pure con Prietas')
|
||
|
|
||
|
params = {'tags': f'{tag1.id}, {tag2.id}'}
|
||
|
res = self.client.get(RECIPES_URL, params)
|
||
|
|
||
|
s1 = RecipeSerializer(r1)
|
||
|
s2 = RecipeSerializer(r2)
|
||
|
s3 = RecipeSerializer(r3)
|
||
|
self.assertIn(s1.data, res.data)
|
||
|
self.assertIn(s2.data, res.data)
|
||
|
self.assertNotIn(s3.data, res.data)
|
||
|
|
||
|
def test_filter_by_ingredients(self):
|
||
|
"""Test filtering recipes by ingredients."""
|
||
|
r1 = create_recipe(user=self.user, title='Porotos con rienda')
|
||
|
r2 = create_recipe(user=self.user, title='Pollo al jugo')
|
||
|
in1 = Ingredient.objects.create(user=self.user, name='Porotos')
|
||
|
in2 = Ingredient.objects.create(user=self.user, name='Pollo')
|
||
|
r1.ingredients.add(in1)
|
||
|
r2.ingredients.add(in2)
|
||
|
r3 = create_recipe(user=self.user, title='Lentejas con arroz')
|
||
|
|
||
|
params = {'ingredients': f'{in1.id}, {in2.id}'}
|
||
|
res = self.client.get(RECIPES_URL, params)
|
||
|
|
||
|
s1 = RecipeSerializer(r1)
|
||
|
s2 = RecipeSerializer(r2)
|
||
|
s3 = RecipeSerializer(r3)
|
||
|
self.assertIn(s1.data, res.data)
|
||
|
self.assertIn(s2.data, res.data)
|
||
|
self.assertNotIn(s3.data, res.data)
|
||
|
```
|
||
|
|
||
|
## Implementación filtros
|
||
|
|
||
|
[`recipe/views.py`](./app/recipe/views.py)
|
||
|
|
||
|
```py
|
||
|
from drf_spectacular.types import OpenApiTypes
|
||
|
from drf_spectacular.utils import (
|
||
|
extend_schema_view,
|
||
|
extend_schema,
|
||
|
OpenApiParameter,
|
||
|
)
|
||
|
...
|
||
|
|
||
|
@extend_schema_view(
|
||
|
list=extend_schema(
|
||
|
parameters=[
|
||
|
OpenApiParameter(
|
||
|
'tags',
|
||
|
OpenApiTypes.STR,
|
||
|
description='Lista separada por coma de tags IDs a filtrar'
|
||
|
),
|
||
|
OpenApiParameter(
|
||
|
'ingredients',
|
||
|
OpenApiTypes.STR,
|
||
|
description='Lista separada por coma de ingredientes IDs a \
|
||
|
filtrar'
|
||
|
),
|
||
|
]
|
||
|
)
|
||
|
)
|
||
|
class RecipeViewSet(viewsets.ModelViewSet):
|
||
|
...
|
||
|
|
||
|
def _params_to_ints(self, qs):
|
||
|
"""Convert a list of strings to integers."""
|
||
|
return [int(str_id) for str_id in qs.split(',')]
|
||
|
|
||
|
def get_queryset(self):
|
||
|
"""Retrieve recipes for authenticated user."""
|
||
|
tags = self.request.query_params.get('tags')
|
||
|
ingredients = self.request.query_params.get('ingredients')
|
||
|
queryset = self.queryset
|
||
|
if tags:
|
||
|
tag_ids = self._params_to_ints(tags)
|
||
|
queryset = queryset.filter(tags__id__in=tag_ids)
|
||
|
if ingredients:
|
||
|
ingredients_ids = self._params_to_ints(ingredients)
|
||
|
queryset = queryset.filter(ingredients__id__in=ingredients_ids)
|
||
|
|
||
|
return queryset.filter(
|
||
|
user=self.request.user
|
||
|
).order_by('-id').distinct()
|
||
|
```
|
||
|
|
||
|
## Test para filtrar por tags e ingredientes
|
||
|
|
||
|
[`test_ingredients_api.py`](./app/recipe/tests/test_ingredients_api.py)
|
||
|
|
||
|
```py
|
||
|
from decimal import Decimal
|
||
|
from core.model import Recipe
|
||
|
...
|
||
|
|
||
|
def test_filter_ingredients_assigned_to_recipes(self):
|
||
|
"""Test listing ingredients by those assigned to recipes."""
|
||
|
in1 = Ingredient.objects.create(user=self.user, name='Manzana')
|
||
|
in2 = Ingredient.objects.create(user=self.user, name='Pavo')
|
||
|
recipe = Recipe.objects.create(
|
||
|
title='Pure de Manzana',
|
||
|
time_minutes=5,
|
||
|
price=Decimal('4.5'),
|
||
|
user=self.user,
|
||
|
)
|
||
|
recipe.ingredients.add(in1)
|
||
|
|
||
|
res = self.client.get(INGREDIENTS_URL, {'assigned_only': 1})
|
||
|
|
||
|
s1 = IngredientSerializer(in1)
|
||
|
s2 = IngredientSerializer(in2)
|
||
|
self.assertIn(s1.data, res.data)
|
||
|
self.assertNotIn(s2.data, res.data)
|
||
|
|
||
|
def test_filtered_ingredients_unique(self):
|
||
|
"""Test filtered ingredients returns a unique list."""
|
||
|
ing = Ingredient.objects.create(user=self.user, name='Huevo')
|
||
|
Ingredient.objects.create(user=self.user, name='Lentejas')
|
||
|
recipe1 = Recipe.objects.create(
|
||
|
title='Huevos a la copa',
|
||
|
time_minutes=4,
|
||
|
price=Decimal('1.0'),
|
||
|
user=self.user,
|
||
|
)
|
||
|
recipe2 = Recipe.objects.create(
|
||
|
title='Huevos a cocidos',
|
||
|
time_minutes=5,
|
||
|
price=Decimal('1.0'),
|
||
|
user=self.user,
|
||
|
)
|
||
|
recipe1.ingredients.add(ing)
|
||
|
recipe2.ingredients.add(ing)
|
||
|
|
||
|
res = self.client.get(INGREDIENTS_URL, {'assigned_only': 1})
|
||
|
|
||
|
self.assertEqual(len(res.data), 1)
|
||
|
```
|
||
|
|
||
|
[`test_tags_api.py`](./app/recipe/tests/test_tags_api.py)
|
||
|
|
||
|
```py
|
||
|
from decimal import Decimal
|
||
|
from core.model import Recipe
|
||
|
...
|
||
|
|
||
|
def test_filter_tags_assigned_to_recipes(self):
|
||
|
"""Test listing tags to those assigned to recipes."""
|
||
|
tag1 = Tag.objects.create(user=self.user, name='Desayuno')
|
||
|
tag2 = Tag.objects.create(user=self.user, name='Almuerzo')
|
||
|
recipe = Recipe.objects.create(
|
||
|
title='Huevos Fritos',
|
||
|
time_minutes='5',
|
||
|
price=Decimal('2.5'),
|
||
|
user=self.user,
|
||
|
)
|
||
|
recipe.tags.add(tag1)
|
||
|
|
||
|
res = self.client.get(TAGS_URL, {'assigned_only': 1})
|
||
|
|
||
|
s1 = TagSerializer(tag1)
|
||
|
s2 = TagSerializer(tag2)
|
||
|
self.assertIn(s1.data, res.data)
|
||
|
self.assertNotIn(s2.data, res.data)
|
||
|
|
||
|
def test_filter_tags_unique(self):
|
||
|
"""Test filtered tags retunrs a unique list."""
|
||
|
tag = Tag.objects.create(user=self.user, name='Desayuno')
|
||
|
Tag.objects.create(user=self.user, name='Almuerzo')
|
||
|
recipe1 = Recipe.objects.create(
|
||
|
title='Panqueques',
|
||
|
time_minutes='25',
|
||
|
price=Decimal('5.0'),
|
||
|
user=self.user,
|
||
|
)
|
||
|
recipe2 = Recipe.objects.create(
|
||
|
title='Avena con fruta',
|
||
|
time_minutes='15',
|
||
|
price=Decimal('7.0'),
|
||
|
user=self.user,
|
||
|
)
|
||
|
recipe1.tags.add(tag)
|
||
|
recipe2.tags.add(tag)
|
||
|
|
||
|
res = self.client.get(TAGS_URL, {'assigned_only': 1})
|
||
|
|
||
|
self.assertEqual(len(res.data), 1)
|
||
|
```
|
||
|
|
||
|
## Implementación filtrado por tags e ingredientes
|
||
|
|
||
|
[`recipe/views.py`](./app/recipe/views.py)
|
||
|
|
||
|
```py
|
||
|
@extend_schema_view(
|
||
|
list=extend_schema(
|
||
|
parameters=[
|
||
|
OpenApiParameter(
|
||
|
'assigned_only',
|
||
|
OpenApiTypes.INT, enum=[0, 1],
|
||
|
description='Filtro por items asignados a recetas.'
|
||
|
),
|
||
|
]
|
||
|
)
|
||
|
)
|
||
|
class BaseRecipeAtrrViewSet(mixins.DestroyModelMixin,
|
||
|
mixins.UpdateModelMixin,
|
||
|
mixins.ListModelMixin,
|
||
|
viewsets.GenericViewSet):
|
||
|
"""Base viewset for recipe attributes."""
|
||
|
authentication_classes = [TokenAuthentication]
|
||
|
permission_classes = [IsAuthenticated]
|
||
|
|
||
|
def get_queryset(self):
|
||
|
"""Filter queryset to authenticated user."""
|
||
|
assigned_only = bool(
|
||
|
int(self.request.query_params.get('assigned_only', 0))
|
||
|
)
|
||
|
queryset = self.queryset
|
||
|
if assigned_only:
|
||
|
queryset = queryset.filter(recipe__isnull=False)
|
||
|
|
||
|
return queryset.filter(
|
||
|
user=self.request.user
|
||
|
).order_by('-name').distinct()
|
||
|
```
|
||
|
|
||
|
----
|
||
|
|
||
|
- [**Inicio**](./README.md)
|
||
|
- [User API](./01_user_api.md)
|
||
|
- [Recipe API](./02_recipe_api.md)
|
||
|
- [Tag API](./03_tag_api.md)
|
||
|
- [Ingredient API](./04_ingredient_api.md)
|
||
|
- [Image API](./05_image_api.md)
|
||
|
- [Filters](./06_filters.md)
|